diff --git a/api/_common/middleware.js b/api/_common/middleware.js index 2c5d801f..709195e7 100644 --- a/api/_common/middleware.js +++ b/api/_common/middleware.js @@ -1,3 +1,5 @@ +import { assertSafeUrl, installSsrfGuards } from './ssrf.js'; + const normalizeUrl = (url) => { return url.startsWith('http') ? url : `https://${url}`; }; @@ -43,6 +45,7 @@ const disabledErrorMsg = 'Error - WebCheck Temporarily Disabled.\n\n' // A middleware function used by all API routes on all platforms const commonMiddleware = (handler) => { + installSsrfGuards(); // Create a timeout promise, to throw an error if a request takes too long const createTimeoutPromise = (timeoutMs) => { @@ -67,7 +70,13 @@ const commonMiddleware = (handler) => { return response.status(500).json({ error: 'No URL specified' }); } - const url = normalizeUrl(rawUrl); + let url = normalizeUrl(rawUrl); + + try { + url = await assertSafeUrl(url); + } catch (error) { + return response.status(400).json({ error: error.message }); + } try { // Race the handler against the timeout @@ -116,7 +125,18 @@ const commonMiddleware = (handler) => { return; } - const url = normalizeUrl(rawUrl); + let url = normalizeUrl(rawUrl); + + try { + url = await assertSafeUrl(url); + } catch (error) { + callback(null, { + statusCode: 400, + body: JSON.stringify({ error: error.message }), + headers, + }); + return; + } try { // Race the handler against the timeout diff --git a/api/_common/ssrf.js b/api/_common/ssrf.js new file mode 100644 index 00000000..c8cc9080 --- /dev/null +++ b/api/_common/ssrf.js @@ -0,0 +1,314 @@ +import dns from 'dns'; +import dnsPromises from 'dns/promises'; +import http from 'http'; +import https from 'https'; +import net from 'net'; + + + +const DEFAULT_METADATA_HOSTS = [ + 'metadata', + 'metadata.google.internal', + 'metadata.google.internal.', + 'metadata.azure.internal', + 'metadata.azure.internal.', + 'metadata.aws.internal', + 'instance-data.ec2.internal', + 'instance-data', + 'metadata.tencentyun.com', + 'metadata.tencentcloud.com', + 'metadata.oraclecloud.com', + 'metadata.oci.oraclecloud.com', + 'metadata.myhuaweicloud.com', + 'metadata.huaweicloud.com', + 'metadata.aliyun.internal', + 'metadata.digitalocean.com', + 'metadata.linode.com', + 'metadata.vultr.com', + 'metadata.ibmcloud.com', + 'metadata.openstack.org', + 'metadata.packet.net', +]; + +const DEFAULT_METADATA_IPS = [ + '169.254.169.254', // AWS/GCP/Azure/OCI/OpenStack/DigitalOcean + '169.254.169.253', // GCP (legacy) + '169.254.169.250', // Oracle (legacy) + '100.100.100.200', // Alibaba Cloud + '100.100.100.201', // Alibaba Cloud (secondary) + 'fd00:ec2::254', // AWS IPv6 IMDS +]; + +const parseEnvList = (value) => { + if (!value) return []; + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +const METADATA_HOSTS = new Set([ + ...DEFAULT_METADATA_HOSTS, + ...parseEnvList(process.env.SSRF_METADATA_HOSTS), +].map((host) => host.toLowerCase())); + +const METADATA_IPS = new Set([ + ...DEFAULT_METADATA_IPS, + ...parseEnvList(process.env.SSRF_METADATA_IPS), +]); + +const IPV4_BLOCK_RANGES = [ + [0x00000000, 0x00ffffff], // 0.0.0.0/8 + [0x0a000000, 0x0affffff], // 10.0.0.0/8 + [0x64400000, 0x647fffff], // 100.64.0.0/10 + [0x7f000000, 0x7fffffff], // 127.0.0.0/8 + [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 + [0xac100000, 0xac1fffff], // 172.16.0.0/12 + [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16 + [0xc0000000, 0xc00000ff], // 192.0.0.0/24 + [0xc0000200, 0xc00002ff], // 192.0.2.0/24 + [0xc6120000, 0xc613ffff], // 198.18.0.0/15 + [0xc6336400, 0xc63364ff], // 198.51.100.0/24 + [0xcb007100, 0xcb0071ff], // 203.0.113.0/24 + [0xe0000000, 0xefffffff], // 224.0.0.0/4 + [0xf0000000, 0xffffffff], // 240.0.0.0/4 +]; + +const ipv4ToInt = (ip) => { + const parts = ip.split('.').map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) { + return null; + } + + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +}; + +const isPrivateIpv4 = (ip) => { + const value = ipv4ToInt(ip); + if (value === null) return true; + return IPV4_BLOCK_RANGES.some(([start, end]) => value >= start && value <= end); +}; + +const isPrivateIpv6 = (ip) => { + const normalized = ip.toLowerCase(); + if (normalized === '::' || normalized === '::1') return true; + if (normalized.startsWith('fe80:') || normalized.startsWith('fe80::')) return true; + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // Unique local + if (normalized.startsWith('ff')) return true; // Multicast + if (normalized.startsWith('2001:db8:')) return true; // Documentation + if (normalized.startsWith('2001:10:')) return true; // ORCHID (deprecated) + + if (normalized.startsWith('::ffff:')) { + const mapped = normalized.replace('::ffff:', ''); + return net.isIPv4(mapped) ? isPrivateIpv4(mapped) : true; + } + + return false; +}; + +const isPrivateIp = (ip) => { + if (METADATA_IPS.has(ip)) { + return true; + } + + if (net.isIPv4(ip)) { + return isPrivateIpv4(ip); + } + if (net.isIPv6(ip)) { + return isPrivateIpv6(ip); + } + + return true; +}; + +const isDisallowedHostname = (hostname) => { + const lower = hostname.toLowerCase(); + if (METADATA_HOSTS.has(lower)) return true; + if (lower === 'localhost' || lower.endsWith('.localhost')) return true; + if (lower.endsWith('.local') || lower.endsWith('.localdomain')) return true; + if (lower.endsWith('.internal')) return true; + return false; +}; + +const resolveAndCheck = async (hostname) => { + const records = await dnsPromises.lookup(hostname, { all: true }); + if (!records.length) { + throw new Error('Host resolves to no addresses'); + } + + for (const record of records) { + if (isPrivateIp(record.address)) { + throw new Error('Host resolves to a private or metadata address'); + } + } +}; + +const originalLookup = dns.lookup.bind(dns); + +export const safeLookup = (hostname, options, callback) => { + const opts = typeof options === 'function' ? {} : options || {}; + const cb = typeof options === 'function' ? options : callback; + + originalLookup(hostname, { ...opts, all: true }, (error, addresses) => { + if (error) { + cb(error); + return; + } + + if (!addresses || addresses.length === 0) { + cb(new Error('Host resolves to no addresses')); + return; + } + + for (const record of addresses) { + if (isPrivateIp(record.address)) { + cb(new Error('Host resolves to a private or metadata address')); + return; + } + } + + if (opts.all) { + cb(null, addresses); + return; + } + + cb(null, addresses[0].address, addresses[0].family); + }); +}; + +const extractHostname = (input, options) => { + if (input instanceof URL) { + return input.hostname; + } + + if (typeof input === 'string') { + try { + return new URL(input).hostname; + } catch (_) { + return null; + } + } + + const fromOptions = options || input || {}; + let host = fromOptions.hostname || fromOptions.host || null; + if (!host) return null; + + if (host.startsWith('[') && host.includes(']')) { + host = host.slice(1, host.indexOf(']')); + } else if (host.includes(':')) { + host = host.split(':')[0]; + } + + return host; +}; + +const parseRequestTarget = (input, options) => { + if (input instanceof URL) { + return { hostname: input.hostname, pathname: input.pathname, port: input.port || '' }; + } + + if (typeof input === 'string') { + try { + const parsed = new URL(input); + return { hostname: parsed.hostname, pathname: parsed.pathname, port: parsed.port || '' }; + } catch (_) { + return null; + } + } + + const fromOptions = options || input || {}; + const hostname = fromOptions.hostname || fromOptions.host || null; + const pathname = fromOptions.path || '/'; + const port = fromOptions.port ? String(fromOptions.port) : ''; + return hostname ? { hostname, pathname, port } : null; +}; + +const isDevtoolsRequest = (input, options) => { + const target = parseRequestTarget(input, options); + if (!target) return false; + + const host = target.hostname; + if (!host || !(host === '127.0.0.1' || host === '::1' || host === 'localhost')) { + return false; + } + + const path = target.pathname || '/'; + return path.startsWith('/json') || path.startsWith('/devtools'); +}; + +const assertSafeHostSync = (hostname, input, options) => { + if (!hostname) return; + if (isDisallowedHostname(hostname)) { + throw new Error('URL hostname is blocked'); + } + if (net.isIP(hostname) && isPrivateIp(hostname)) { + if (isDevtoolsRequest(input, options)) { + return; + } + throw new Error('URL resolves to a private or metadata address'); + } +}; + +let guardsInstalled = false; +const originalFns = { + lookup: dns.lookup.bind(dns), + httpRequest: http.request.bind(http), + httpsRequest: https.request.bind(https), + httpGet: http.get.bind(http), + httpsGet: https.get.bind(https), +}; + +export const installSsrfGuards = () => { + if (guardsInstalled) return; + guardsInstalled = true; + + dns.lookup = safeLookup; + + const wrapRequest = (original) => (...args) => { + const hostname = extractHostname(args[0], args[1]); + assertSafeHostSync(hostname, args[0], args[1]); + return original(...args); + }; + + http.request = wrapRequest(originalFns.httpRequest); + https.request = wrapRequest(originalFns.httpsRequest); + http.get = wrapRequest(originalFns.httpGet); + https.get = wrapRequest(originalFns.httpsGet); +}; + +export const assertSafeUrl = async (rawUrl) => { + let parsed; + try { + parsed = new URL(rawUrl); + } catch (error) { + throw new Error('URL provided is invalid'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('URL scheme not allowed'); + } + + if (parsed.username || parsed.password) { + throw new Error('URL credentials are not allowed'); + } + + const hostname = parsed.hostname; + if (!hostname) { + throw new Error('URL hostname is missing'); + } + + if (isDisallowedHostname(hostname)) { + throw new Error('URL hostname is blocked'); + } + + if (net.isIP(hostname)) { + if (isPrivateIp(hostname)) { + throw new Error('URL resolves to a private or metadata address'); + } + return parsed.toString(); + } + + await resolveAndCheck(hostname); + + return parsed.toString(); +}; diff --git a/api/dnssec.js b/api/dnssec.js index 21f75109..ebf41100 100644 --- a/api/dnssec.js +++ b/api/dnssec.js @@ -37,6 +37,10 @@ const dnsSecHandler = async (domain) => { }); }); + req.on('error', error => { + reject(error); + }); + req.end(); }); diff --git a/api/screenshot.js b/api/screenshot.js index 8425e333..2ccb9bd1 100644 --- a/api/screenshot.js +++ b/api/screenshot.js @@ -5,6 +5,7 @@ import { execFile } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; import pkg from 'uuid'; +import { assertSafeUrl } from './_common/ssrf.js'; const { v4: uuidv4 } = pkg; // Helper function for direct chromium screenshot as fallback @@ -76,25 +77,72 @@ const screenshotHandler = async (targetUrl) => { throw new Error('URL provided is invalid'); } - // First try direct Chromium - try { - console.log(`[SCREENSHOT] Using direct Chromium method for URL: ${targetUrl}`); - const base64Screenshot = await directChromiumScreenshot(targetUrl); - console.log(`[SCREENSHOT] Direct screenshot successful`); - return { image: base64Screenshot }; - } catch (directError) { - console.error(`[SCREENSHOT] Direct screenshot method failed: ${directError.message}`); - console.log(`[SCREENSHOT] Falling back to puppeteer method...`); + const allowDirect = process.env.ALLOW_DIRECT_SCREENSHOT === 'true'; + if (allowDirect) { + try { + console.log(`[SCREENSHOT] Using direct Chromium method for URL: ${targetUrl}`); + const base64Screenshot = await directChromiumScreenshot(targetUrl); + console.log(`[SCREENSHOT] Direct screenshot successful`); + return { image: base64Screenshot }; + } catch (directError) { + console.error(`[SCREENSHOT] Direct screenshot method failed: ${directError.message}`); + console.log(`[SCREENSHOT] Falling back to puppeteer method...`); + } + } else { + console.log('[SCREENSHOT] Direct Chromium method disabled for SSRF safety'); } + const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium'; + + try { + await fs.access(chromePath); + } catch (error) { + console.error(`[SCREENSHOT] Chromium path not accessible: ${chromePath}`); + throw new Error(`Chromium binary not accessible at ${chromePath}`); + } + + try { + const versionInfo = await new Promise((resolve, reject) => { + execFile(chromePath, ['--headless', '--no-sandbox', '--disable-gpu', '--version'], (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(stdout.trim()); + }); + }); + console.log(`[SCREENSHOT] Chromium version: ${versionInfo}`); + } catch (error) { + console.error(`[SCREENSHOT] Chromium launch check failed: ${error.message}`); + throw new Error(`Chromium launch check failed: ${error.message}`); + } + // fall back puppeteer let browser = null; try { console.log(`[SCREENSHOT] Launching puppeteer browser`); + const useLambdaArgs = !!(process.env.AWS_EXECUTION_ENV || process.env.AWS_LAMBDA_FUNCTION_NAME); + const launchArgs = useLambdaArgs + ? [ + ...chromium.args, + '--no-sandbox', + '--disable-dev-shm-usage', + '--no-zygote', + ] + : [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-zygote', + '--use-gl=swiftshader', + '--disable-features=UseDBus', + ]; + browser = await puppeteer.launch({ - args: [...chromium.args, '--no-sandbox'], // Add --no-sandbox flag + args: launchArgs, defaultViewport: { width: 800, height: 600 }, - executablePath: process.env.CHROME_PATH || '/usr/bin/chromium', + executablePath: chromePath, headless: true, ignoreHTTPSErrors: true, ignoreDefaultArgs: ['--disable-extensions'], @@ -102,6 +150,19 @@ const screenshotHandler = async (targetUrl) => { console.log(`[SCREENSHOT] Creating new page`); let page = await browser.newPage(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const requestUrl = request.url(); + if (requestUrl.startsWith('data:') || requestUrl.startsWith('blob:') || requestUrl.startsWith('about:')) { + request.continue(); + return; + } + + assertSafeUrl(requestUrl) + .then(() => request.continue()) + .catch(() => request.abort()); + }); console.log(`[SCREENSHOT] Setting page preferences`); await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);