diff --git a/src/compiled/providers/ssrf_guard.js b/src/compiled/providers/ssrf_guard.js index ac3af93e..9d57e151 100644 --- a/src/compiled/providers/ssrf_guard.js +++ b/src/compiled/providers/ssrf_guard.js @@ -17,78 +17,126 @@ // services (169.254.169.254) even when LLM_ALLOW_PUBLIC_ENDPOINTS=1 // is set, unless the URL was an explicit allowlist match. This blocks // classic SSRF-to-cloud-metadata attacks. +// - We canonicalize IPv6 aliases of IPv4 addresses (`::ffff:a.b.c.d`, +// `::ffff:wwww:xxxx`, `::a.b.c.d`) before checking. Node's URL parser +// normalizes `http://[::ffff:169.254.169.254]/` to hostname +// `[::ffff:a9fe:a9fe]`, which a naive dotted-quad regex misses but the +// OS routes straight to the underlying IPv4 metadata IP. Object.defineProperty(exports, "__esModule", { value: true }); exports.assertEndpointAllowed = assertEndpointAllowed; - -function isLoopback(host) { - const h = host.toLowerCase(); - if (h === "localhost" || h === "::1" || h === "::") return true; - if (h.startsWith("127.")) return true; +// Return every representation of `host` that the OS might route to: +// - the input lowered +// - the input with surrounding [] stripped (URL.hostname keeps them for IPv6) +// - if it's an IPv4-mapped or IPv4-compatible IPv6, the embedded dotted-quad +// +// Checking every variant defeats the classic alias bypass where a hostname +// like `[::ffff:169.254.169.254]` slips a dotted-quad regex but resolves +// directly to 169.254.169.254 at connect time. +function hostVariants(host) { + const out = new Set(); + const lower = host.toLowerCase(); + out.add(lower); + let h = lower; + if (h.startsWith("[") && h.endsWith("]")) { + h = h.slice(1, -1); + out.add(h); + } + const dotted = h.match(/^(?:::ffff:|::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) + out.add(dotted[1]); + const hex = h.match(/^(?:::ffff:|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const a = parseInt(hex[1], 16), b = parseInt(hex[2], 16); + out.add(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`); + } + return [...out]; +} +function isLoopbackOne(h) { + if (h === "localhost" || h === "::1" || h === "::") + return true; + if (h.startsWith("127.")) + return true; return false; } - -function isRfc1918(host) { - const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (!m) return false; +function isLoopback(host) { + return hostVariants(host).some(isLoopbackOne); +} +function isRfc1918One(h) { + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) + return false; const a = Number(m[1]), b = Number(m[2]); - if (a === 10) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; + if (a === 10) + return true; + if (a === 172 && b >= 16 && b <= 31) + return true; + if (a === 192 && b === 168) + return true; return false; } - -// Cloud metadata + link-local + CGNAT — never allowed implicitly. -function isAlwaysBlocked(host) { - const h = host.toLowerCase(); - if (h === "169.254.169.254") return true; // AWS/GCP/Azure metadata IPv4 - if (h === "fd00:ec2::254" || h === "[fd00:ec2::254]") return true; // AWS IPv6 metadata - if (h === "metadata.google.internal") return true; - if (h === "metadata") return true; +function isRfc1918(host) { + return hostVariants(host).some(isRfc1918One); +} +function isAlwaysBlockedOne(h) { + if (h === "169.254.169.254") + return true; + if (h === "fd00:ec2::254" || h === "[fd00:ec2::254]") + return true; + if (h === "metadata.google.internal") + return true; + if (h === "metadata") + return true; const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (m) { const a = Number(m[1]), b = Number(m[2]); - if (a === 169 && b === 254) return true; // 169.254/16 link-local - if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT - if (a === 0) return true; // 0.0.0.0/8 reserved + if (a === 169 && b === 254) + return true; + if (a === 100 && b >= 64 && b <= 127) + return true; + if (a === 0) + return true; } return false; } - +function isAlwaysBlocked(host) { + return hostVariants(host).some(isAlwaysBlockedOne); +} function originOf(url) { - // URL.origin returns "scheme://host[:port]" — exactly what we want - // for prefix matching without falling for path-suffix tricks. return url.origin.replace(/\/+$/, ""); } - function originMatches(allowEntry, url) { let allowUrl; - try { allowUrl = new URL(allowEntry); } catch { return false; } + try { + allowUrl = new URL(allowEntry); + } + catch { + return false; + } return originOf(allowUrl) === originOf(url); } - function assertEndpointAllowed(endpoint) { let url; try { url = new URL(endpoint); - } catch { + } + catch { throw new Error(`Invalid endpoint URL: ${endpoint}`); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Endpoint must use http(s): ${endpoint}`); } - // Always block cloud metadata + link-local + CGNAT, even when the - // operator explicitly opted into public endpoints. These are never - // intentionally targeted by an LLM provider. if (isAlwaysBlocked(url.hostname)) { throw new Error(`Endpoint ${endpoint} targets a metadata/link-local address; refusing.`); } - if (process.env.LLM_ALLOW_PUBLIC_ENDPOINTS === "1") return; - + if (process.env.LLM_ALLOW_PUBLIC_ENDPOINTS === "1") + return; const allow = (process.env.LLM_ENDPOINT_ALLOWLIST || "").split(",").map(s => s.trim()).filter(Boolean); for (const a of allow) { - if (originMatches(a, url)) return; + if (originMatches(a, url)) + return; } - if (isLoopback(url.hostname) || isRfc1918(url.hostname)) return; + if (isLoopback(url.hostname) || isRfc1918(url.hostname)) + return; throw new Error(`Endpoint ${endpoint} is not in LLM_ENDPOINT_ALLOWLIST and is not a private host. ` + `Set LLM_ENDPOINT_ALLOWLIST= or LLM_ALLOW_PUBLIC_ENDPOINTS=1 to permit.`); } diff --git a/src/compiled/providers/ssrf_guard.ts b/src/compiled/providers/ssrf_guard.ts index b7ad3723..c8f8babe 100644 --- a/src/compiled/providers/ssrf_guard.ts +++ b/src/compiled/providers/ssrf_guard.ts @@ -6,16 +6,60 @@ // Set LLM_ENDPOINT_ALLOWLIST=https://api.example.com,http://internal:8080 // to permit additional endpoints. Setting LLM_ALLOW_PUBLIC_ENDPOINTS=1 // disables the host filter entirely (production-only opt-in). +// +// SECURITY NOTES: +// - We compare ORIGIN (scheme + host + port), not raw startsWith on the +// URL string. Otherwise an attacker can register +// `https://api.evil.com.attacker.com/` against an allowlist of +// `https://api.evil.com` and bypass the check. +// - We refuse link-local (169.254/16), CGNAT (100.64/10), and metadata +// services (169.254.169.254) even when LLM_ALLOW_PUBLIC_ENDPOINTS=1 +// is set, unless the URL was an explicit allowlist match. This blocks +// classic SSRF-to-cloud-metadata attacks. +// - We canonicalize IPv6 aliases of IPv4 addresses (`::ffff:a.b.c.d`, +// `::ffff:wwww:xxxx`, `::a.b.c.d`) before checking. Node's URL parser +// normalizes `http://[::ffff:169.254.169.254]/` to hostname +// `[::ffff:a9fe:a9fe]`, which a naive dotted-quad regex misses but the +// OS routes straight to the underlying IPv4 metadata IP. -function isLoopback(host: string): boolean { - const h = host.toLowerCase(); +// Return every representation of `host` that the OS might route to: +// - the input lowered +// - the input with surrounding [] stripped (URL.hostname keeps them for IPv6) +// - if it's an IPv4-mapped or IPv4-compatible IPv6, the embedded dotted-quad +// +// Checking every variant defeats the classic alias bypass where a hostname +// like `[::ffff:169.254.169.254]` slips a dotted-quad regex but resolves +// directly to 169.254.169.254 at connect time. +function hostVariants(host: string): string[] { + const out = new Set(); + const lower = host.toLowerCase(); + out.add(lower); + let h = lower; + if (h.startsWith("[") && h.endsWith("]")) { + h = h.slice(1, -1); + out.add(h); + } + const dotted = h.match(/^(?:::ffff:|::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) out.add(dotted[1]); + const hex = h.match(/^(?:::ffff:|::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const a = parseInt(hex[1], 16), b = parseInt(hex[2], 16); + out.add(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`); + } + return [...out]; +} + +function isLoopbackOne(h: string): boolean { if (h === "localhost" || h === "::1" || h === "::") return true; if (h.startsWith("127.")) return true; return false; } +function isLoopback(host: string): boolean { + return hostVariants(host).some(isLoopbackOne); +} -function isRfc1918(host: string): boolean { - const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); +function isRfc1918One(h: string): boolean { + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return false; const a = Number(m[1]), b = Number(m[2]); if (a === 10) return true; @@ -23,21 +67,58 @@ function isRfc1918(host: string): boolean { if (a === 192 && b === 168) return true; return false; } +function isRfc1918(host: string): boolean { + return hostVariants(host).some(isRfc1918One); +} + +function isAlwaysBlockedOne(h: string): boolean { + if (h === "169.254.169.254") return true; + if (h === "fd00:ec2::254" || h === "[fd00:ec2::254]") return true; + if (h === "metadata.google.internal") return true; + if (h === "metadata") return true; + const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (m) { + const a = Number(m[1]), b = Number(m[2]); + if (a === 169 && b === 254) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 0) return true; + } + return false; +} +function isAlwaysBlocked(host: string): boolean { + return hostVariants(host).some(isAlwaysBlockedOne); +} + +function originOf(url: URL): string { + return url.origin.replace(/\/+$/, ""); +} + +function originMatches(allowEntry: string, url: URL): boolean { + let allowUrl: URL; + try { allowUrl = new URL(allowEntry); } catch { return false; } + return originOf(allowUrl) === originOf(url); +} export function assertEndpointAllowed(endpoint: string): void { - if (process.env.LLM_ALLOW_PUBLIC_ENDPOINTS === "1") return; let url: URL; - try { url = new URL(endpoint); } catch { throw new Error(`Invalid endpoint URL: ${endpoint}`); } + try { + url = new URL(endpoint); + } catch { + throw new Error(`Invalid endpoint URL: ${endpoint}`); + } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Endpoint must use http(s): ${endpoint}`); } + if (isAlwaysBlocked(url.hostname)) { + throw new Error(`Endpoint ${endpoint} targets a metadata/link-local address; refusing.`); + } + if (process.env.LLM_ALLOW_PUBLIC_ENDPOINTS === "1") return; + const allow = (process.env.LLM_ENDPOINT_ALLOWLIST || "").split(",").map(s => s.trim()).filter(Boolean); for (const a of allow) { - if (endpoint.startsWith(a)) return; + if (originMatches(a, url)) return; } if (isLoopback(url.hostname) || isRfc1918(url.hostname)) return; - throw new Error( - `Endpoint ${endpoint} is not in LLM_ENDPOINT_ALLOWLIST and is not a private host. ` + - `Set LLM_ENDPOINT_ALLOWLIST= or LLM_ALLOW_PUBLIC_ENDPOINTS=1 to permit.` - ); + throw new Error(`Endpoint ${endpoint} is not in LLM_ENDPOINT_ALLOWLIST and is not a private host. ` + + `Set LLM_ENDPOINT_ALLOWLIST= or LLM_ALLOW_PUBLIC_ENDPOINTS=1 to permit.`); } diff --git a/src/tools/builtin/web_browse.js b/src/tools/builtin/web_browse.js index 8f2f68c5..ac32877c 100644 --- a/src/tools/builtin/web_browse.js +++ b/src/tools/builtin/web_browse.js @@ -153,8 +153,22 @@ async function webFetch(url, maxChars = 5000) { async function _fetchWithBrowser(browser, url, maxChars) { const page = await browser.newPage(); try { + // Re-validate every URL the page touches (initial request, redirects, + // and any subresource the document fetches). page.goto follows HTTP + // redirects with no per-hop control, so an LLM-supplied URL that 302s + // to 169.254.169.254 would otherwise bypass the guard — matching the + // exact "don't auto-follow" reasoning _fetchSimple already documents + // a few lines down. + await page.route('**/*', async (route) => { + try { + _assertUrlSafe(route.request().url()); + await route.continue(); + } catch { + await route.abort(); + } + }); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); - + // Extract readable text (strip nav, ads, etc.) const text = await page.evaluate(() => { // Remove noise elements diff --git a/test/ssrf_guard.test.js b/test/ssrf_guard.test.js new file mode 100644 index 00000000..3b4a7d8c --- /dev/null +++ b/test/ssrf_guard.test.js @@ -0,0 +1,108 @@ +// Regression tests for the SSRF guard. Run with `node --test test/`. +// +// What was broken before the fix: +// 1. `isAlwaysBlocked` matched on a dotted-quad regex only, so +// `http://[::ffff:169.254.169.254]/` (and its hex / expanded forms) +// slipped past — Node's URL parser normalizes the hostname to +// `[::ffff:a9fe:a9fe]`, but the OS still routes the connection to the +// underlying 169.254.169.254 IMDS endpoint. +// 2. With `LLM_ALLOW_PUBLIC_ENDPOINTS=1`, the only barrier between an +// LLM-supplied web_fetch URL and cloud metadata is `isAlwaysBlocked`, +// so the same bypass leaked AWS IMDS via web_fetch in production mode. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { assertEndpointAllowed } = require('../src/compiled/providers/ssrf_guard'); + +function blocked(url, env = {}) { + const prev = { ...process.env }; + Object.assign(process.env, env); + try { + assertEndpointAllowed(url); + return null; + } catch (e) { + return e.message; + } finally { + process.env = prev; + } +} + +test('IMDS IPv4-mapped IPv6 (dotted) is blocked under LLM_ALLOW_PUBLIC_ENDPOINTS=1', () => { + const msg = blocked('http://[::ffff:169.254.169.254]/latest/meta-data/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg), `expected block, got: ${msg}`); +}); + +test('IMDS IPv4-mapped IPv6 (hex) is blocked under LLM_ALLOW_PUBLIC_ENDPOINTS=1', () => { + const msg = blocked('http://[::ffff:a9fe:a9fe]/latest/meta-data/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg), `expected block, got: ${msg}`); +}); + +test('IMDS IPv4-mapped IPv6 (expanded zeros) is blocked under LLM_ALLOW_PUBLIC_ENDPOINTS=1', () => { + const msg = blocked('http://[0:0:0:0:0:ffff:169.254.169.254]/latest/meta-data/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg), `expected block, got: ${msg}`); +}); + +test('IMDS dotted-quad remains blocked', () => { + const msg = blocked('http://169.254.169.254/latest/meta-data/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg)); +}); + +test('GCP metadata.google.internal remains blocked', () => { + const msg = blocked('http://metadata.google.internal/computeMetadata/v1/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg)); +}); + +test('AWS IPv6 IMDS (fd00:ec2::254) remains blocked', () => { + const msg = blocked('http://[fd00:ec2::254]/latest/meta-data/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg)); +}); + +test('link-local range (169.254.x.x) blocked even outside IMDS literal', () => { + const msg = blocked('http://169.254.1.5/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg)); +}); + +test('CGNAT 100.64.0.0/10 blocked', () => { + const msg = blocked('http://100.100.0.1/', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /metadata\/link-local/.test(msg)); +}); + +test('legitimate public endpoint allowed when LLM_ALLOW_PUBLIC_ENDPOINTS=1', () => { + assert.strictEqual(blocked('https://api.openai.com/v1/chat/completions', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }), null); +}); + +test('loopback http://127.0.0.1 allowed in default mode (no env flags)', () => { + assert.strictEqual(blocked('http://127.0.0.1:11434/v1/chat', { LLM_ALLOW_PUBLIC_ENDPOINTS: '', LLM_ENDPOINT_ALLOWLIST: '' }), null); +}); + +test('RFC1918 http://192.168.x.x allowed in default mode', () => { + assert.strictEqual(blocked('http://192.168.1.10:8080/v1/chat', { LLM_ALLOW_PUBLIC_ENDPOINTS: '', LLM_ENDPOINT_ALLOWLIST: '' }), null); +}); + +test('explicit allowlist origin match works (not raw prefix)', () => { + // The pre-fix .ts version had a `endpoint.startsWith(a)` bypass; the + // .js + new .ts use origin equality. Both should accept the exact origin + // and reject the prefix-spoof. + assert.strictEqual(blocked('https://api.openai.com/v1/chat', { + LLM_ALLOW_PUBLIC_ENDPOINTS: '', + LLM_ENDPOINT_ALLOWLIST: 'https://api.openai.com', + }), null); + const msg = blocked('https://api.openai.com.attacker.com/v1/chat', { + LLM_ALLOW_PUBLIC_ENDPOINTS: '', + LLM_ENDPOINT_ALLOWLIST: 'https://api.openai.com', + }); + assert.ok(msg && /not in LLM_ENDPOINT_ALLOWLIST/.test(msg), `expected block, got: ${msg}`); +}); + +test('non-http(s) scheme rejected', () => { + const msg = blocked('file:///etc/passwd', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /Endpoint must use http/.test(msg)); +}); + +test('invalid URL rejected with clear message', () => { + const msg = blocked('http://[malformed', { LLM_ALLOW_PUBLIC_ENDPOINTS: '1' }); + assert.ok(msg && /Invalid endpoint URL/.test(msg)); +});