diff --git a/.env.sample b/.env.sample index b9c5e4090..af7704d45 100644 --- a/.env.sample +++ b/.env.sample @@ -26,6 +26,7 @@ WHO_API_KEY='' # API_DISABLED_CHECKS='' # Comma-separated list of checks to disable (e.g. 'trace-route,ports') # API_ENABLED_CHECKS='' # If set, only these checks will run ('get-ip' stays on unless disabled) # API_BLOCKED_HOSTS='' # Hosts that must never be scanned (e.g. 'lan.example.com,192.168.0.0/16') +# ALLOW_PRIVATE_TARGETS='false' # Allow scanning private networks (unsafe on public instances) # REACT_APP_API_ENDPOINT='/api' # The endpoint for the API (can be local or remote) # ENABLE_ANALYTICS='false' # Enable Plausible hit counter for the frontend # BOSS_SERVER='false' # Marketing homepage (only used by official instance) diff --git a/api/_common/middleware.js b/api/_common/middleware.js index f80a4f82b..6b10c5d5f 100644 --- a/api/_common/middleware.js +++ b/api/_common/middleware.js @@ -1,4 +1,5 @@ import { bracketIPv6 } from './parse-target.js'; +import { assertSafeUrl, installSsrfGuards } from './ssrf.js'; import { shouldSkip } from './check-skipper.js'; const normalizeUrl = (url) => { @@ -39,6 +40,8 @@ const timeoutErrorMsg = // 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) => { return new Promise((_, reject) => { @@ -62,8 +65,14 @@ const commonMiddleware = (handler) => { return response.status(500).json({ error: 'No URL specified' }); } + let url = normalizeUrl(rawUrl); + try { + url = await assertSafeUrl(url); + } catch (error) { + return response.status(400).json({ error: error.message }); + } + try { - const url = normalizeUrl(rawUrl); const result = await Promise.race([handler(url, request), createTimeoutPromise(TIMEOUT)]); response.status(200).json(typeof result === 'object' ? result : JSON.parse(result)); } catch (error) { @@ -87,8 +96,14 @@ const commonMiddleware = (handler) => { return { statusCode: 500, body: JSON.stringify({ error: 'No URL specified' }), headers }; } + let url = normalizeUrl(rawUrl); + try { + url = await assertSafeUrl(url); + } catch (error) { + return { statusCode: 400, body: JSON.stringify({ error: error.message }), headers }; + } + try { - const url = normalizeUrl(rawUrl); const result = await Promise.race([ handler(url, event, context), createTimeoutPromise(TIMEOUT), diff --git a/api/_common/ssrf.js b/api/_common/ssrf.js new file mode 100644 index 000000000..d31c926a3 --- /dev/null +++ b/api/_common/ssrf.js @@ -0,0 +1,339 @@ +import dns from 'node:dns'; +import dnsPromises from 'node:dns/promises'; +import http from 'node:http'; +import https from 'node:https'; +import net from 'node: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 normalizeHostname = (hostname) => + hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + +const METADATA_HOSTS = new Set( + [...DEFAULT_METADATA_HOSTS, ...parseEnvList(process.env.SSRF_METADATA_HOSTS)].map( + normalizeHostname, + ), +); + +const METADATA_IPS = new Set( + [...DEFAULT_METADATA_IPS, ...parseEnvList(process.env.SSRF_METADATA_IPS)].map(normalizeHostname), +); + +const IPV4_BLOCK_SUBNETS = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]; + +const IPV6_BLOCK_SUBNETS = [ + ['::', 128], + ['::1', 128], + ['64:ff9b::', 96], + ['64:ff9b:1::', 48], + ['100::', 64], + ['100:0:0:1::', 64], + ['2001::', 23], + ['2001:db8::', 32], + ['2002::', 16], + ['3fff::', 20], + ['5f00::', 16], + ['fc00::', 7], + ['fe80::', 10], + ['ff00::', 8], +]; + +const blockedAddresses = new net.BlockList(); +IPV4_BLOCK_SUBNETS.forEach(([address, prefix]) => + blockedAddresses.addSubnet(address, prefix, 'ipv4'), +); +IPV6_BLOCK_SUBNETS.forEach(([address, prefix]) => + blockedAddresses.addSubnet(address, prefix, 'ipv6'), +); + +const privateTargetsAllowed = () => process.env.ALLOW_PRIVATE_TARGETS === 'true'; + +const isPrivateIp = (ip) => { + const normalized = normalizeHostname(ip); + if (METADATA_IPS.has(normalized)) { + return true; + } + + if (net.isIPv4(normalized)) { + return blockedAddresses.check(normalized, 'ipv4'); + } + if (net.isIPv6(normalized)) { + return blockedAddresses.check(normalized, 'ipv6'); + } + + return true; +}; + +const isDisallowedHostname = (hostname) => { + const normalized = normalizeHostname(hostname); + if (METADATA_HOSTS.has(normalized)) return true; + if (normalized === 'localhost' || normalized.endsWith('.localhost')) return true; + if (normalized.endsWith('.local') || normalized.endsWith('.localdomain')) return true; + if (normalized.endsWith('.internal')) return true; + return false; +}; + +const resolveAndCheck = async (hostname, lookup) => { + const records = await 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 === 'number' + ? { family: options } + : typeof options === 'function' + ? {} + : options || {}; + const cb = typeof options === 'function' ? options : callback; + + if (privateTargetsAllowed()) { + return typeof options === 'function' + ? originalLookup(hostname, options) + : originalLookup(hostname, 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 normalizeHostname(input.hostname); + } + + if (typeof input === 'string') { + try { + return normalizeHostname(new URL(input).hostname); + } catch (_) { + return null; + } + } + + const fromOptions = options && typeof options === 'object' ? 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 (!net.isIP(host) && host.indexOf(':') === host.lastIndexOf(':')) { + host = host.split(':')[0]; + } + + return normalizeHostname(host); +}; + +const parseRequestTarget = (input, options) => { + if (input instanceof URL) { + return { + hostname: normalizeHostname(input.hostname), + pathname: input.pathname, + port: input.port || '', + }; + } + + if (typeof input === 'string') { + try { + const parsed = new URL(input); + return { + hostname: normalizeHostname(parsed.hostname), + pathname: parsed.pathname, + port: parsed.port || '', + }; + } catch (_) { + return null; + } + } + + const fromOptions = options && typeof options === 'object' ? options : input || {}; + const hostname = fromOptions.hostname || fromOptions.host || null; + const pathname = fromOptions.path || '/'; + const port = fromOptions.port ? String(fromOptions.port) : ''; + return hostname ? { hostname: extractHostname(fromOptions), 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 || privateTargetsAllowed()) 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 = { + 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, lookup = dnsPromises.lookup) => { + 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 = normalizeHostname(parsed.hostname); + if (!hostname) { + throw new Error('URL hostname is missing'); + } + + if (privateTargetsAllowed()) { + return parsed.toString(); + } + + 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, lookup); + + return parsed.toString(); +}; diff --git a/api/_common/ssrf.test.js b/api/_common/ssrf.test.js new file mode 100644 index 000000000..02d6cfbd6 --- /dev/null +++ b/api/_common/ssrf.test.js @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; + +import { assertSafeUrl, installSsrfGuards } from './ssrf.js'; + +const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }]; + +test('allows public HTTP and HTTPS targets', async () => { + assert.equal( + await assertSafeUrl('https://example.com/path', publicLookup), + 'https://example.com/path', + ); + assert.equal(await assertSafeUrl('http://1.1.1.1'), 'http://1.1.1.1/'); + assert.equal( + await assertSafeUrl('http://[2001:4860:4860::8888]'), + 'http://[2001:4860:4860::8888]/', + ); +}); + +test('blocks private and non-routable IP address forms', async () => { + const targets = [ + 'http://0.0.0.0', + 'http://10.0.0.1', + 'http://127.0.0.1', + 'http://169.254.169.254/latest/meta-data', + 'http://172.16.0.1', + 'http://192.168.0.1', + 'http://2130706433', + 'http://0x7f000001', + 'http://127.1', + 'http://[::1]', + 'http://[::ffff:127.0.0.1]', + 'http://[fe80::1]', + 'http://[fc00::1]', + ]; + + for (const target of targets) { + await assert.rejects(assertSafeUrl(target), /private or metadata address/); + } +}); + +test('blocks reserved hostnames without resolving them', async () => { + let lookupCalled = false; + const lookup = async () => { + lookupCalled = true; + return [{ address: '93.184.216.34', family: 4 }]; + }; + + for (const target of [ + 'http://localhost', + 'http://service.local', + 'http://service.internal', + 'http://metadata.google.internal', + ]) { + await assert.rejects(assertSafeUrl(target, lookup), /hostname is blocked/); + } + assert.equal(lookupCalled, false); +}); + +test('checks every resolved address', async () => { + const lookup = async () => [ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]; + + await assert.rejects( + assertSafeUrl('https://mixed.example', lookup), + /private or metadata address/, + ); +}); + +test('rejects unsupported schemes and embedded credentials', async () => { + await assert.rejects(assertSafeUrl('file:///etc/passwd'), /scheme not allowed/); + await assert.rejects( + assertSafeUrl('https://user:password@example.com', publicLookup), + /credentials are not allowed/, + ); +}); + +test('allows private targets only when explicitly configured', async () => { + const previous = process.env.ALLOW_PRIVATE_TARGETS; + process.env.ALLOW_PRIVATE_TARGETS = 'true'; + try { + assert.equal(await assertSafeUrl('http://127.0.0.1'), 'http://127.0.0.1/'); + } finally { + if (previous === undefined) { + delete process.env.ALLOW_PRIVATE_TARGETS; + } else { + process.env.ALLOW_PRIVATE_TARGETS = previous; + } + } +}); + +test('blocks direct private addresses passed as request options', () => { + installSsrfGuards(); + assert.throws( + () => http.get({ hostname: '127.0.0.1', path: '/' }), + /private or metadata address/, + ); +}); diff --git a/api/screenshot.js b/api/screenshot.js index 725841b35..972302e07 100644 --- a/api/screenshot.js +++ b/api/screenshot.js @@ -6,6 +6,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import middleware from './_common/middleware.js'; import { createLogger } from './_common/logger.js'; +import { assertSafeUrl } from './_common/ssrf.js'; const log = createLogger('screenshot'); @@ -52,6 +53,27 @@ const puppeteerScreenshot = async (targetUrl) => { ignoreDefaultArgs: ['--disable-extensions'], }); const page = await browser.newPage(); + + await page.setRequestInterception(true); + page.on('request', async (request) => { + const requestUrl = request.url(); + if ( + requestUrl.startsWith('data:') || + requestUrl.startsWith('blob:') || + requestUrl.startsWith('about:') + ) { + request.continue(); + return; + } + + try { + await assertSafeUrl(requestUrl); + request.continue(); + } catch { + request.abort(); + } + }); + await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); page.setDefaultNavigationTimeout(8000); await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); @@ -76,11 +98,16 @@ const screenshotHandler = async (targetUrl) => { } log.debug(`request received: ${targetUrl}`); - try { - return { image: await directChromiumScreenshot(targetUrl) }; - } catch (directError) { - log.warn(`direct chromium failed, falling back to puppeteer: ${directError.message}`); + if (process.env.ALLOW_DIRECT_SCREENSHOT === 'true') { + try { + return { image: await directChromiumScreenshot(targetUrl) }; + } catch (directError) { + log.warn(`direct chromium failed, falling back to puppeteer: ${directError.message}`); + } + } else { + log.debug('direct chromium disabled for ssrf safety'); } + try { return { image: await puppeteerScreenshot(targetUrl) }; } catch (error) { diff --git a/package.json b/package.json index e1a01bb4e..cb457579a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "start": "node server", + "test": "node --test", "build": "astro check && astro build", "dev:vercel": "PLATFORM='vercel' npx vercel dev", "dev:netlify": "PLATFORM='netlify' npx netlify dev",