diff --git a/changelog.d/fixes/10941-relay-private-host-guard.md b/changelog.d/fixes/10941-relay-private-host-guard.md new file mode 100644 index 0000000000..53d3aedeec --- /dev/null +++ b/changelog.d/fixes/10941-relay-private-host-guard.md @@ -0,0 +1 @@ +- **fix(relay):** the private/loopback guard the three proxy-relay workers embed no longer misses four host spellings, and now lives in one place instead of three byte-identical inline copies. Driving `new URL(target).hostname` the way the workers do, the previous guard allowed `::` (the unspecified address, which reaches a service bound to the IPv6 loopback), `localhost.` (the FQDN root dot defeated the exact match and every `.localhost`/`.local`/`.internal` suffix rule, so `svc.internal.` slipped too), `::127.0.0.1` (the deprecated IPv4-compatible form — only `::ffff:` was checked), and `feb0::1` (link-local is `fe80::/10`, spanning `fe80`–`febf`, but only the literal `fe80:` spelling matched). The policy moved to `src/lib/proxyRelay/privateHostname.ts` and is embedded verbatim via `Function#toString` under a literal const name, the same mechanism `resolveRelayTarget` already uses for these workers, so a minified standalone build cannot break the call site (#6149). Nothing previously blocked is now allowed. Severity is low — reaching a worker needs the `x-relay-auth` secret and these are edge runtimes where loopback has nothing listening — but the suffix-rule bypass held regardless of runtime diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts index 7ec8764d87..9f55dc46e6 100644 --- a/src/app/api/settings/proxy/deno-deploy/route.ts +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -5,6 +5,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { denoDeploySchema } from "@/shared/validation/freeProxySchemas"; import { createProxy } from "@/lib/localDb"; import { encrypt } from "@/lib/db/encryption"; +import { isPrivateRelayHostname } from "@/lib/proxyRelay/privateHostname"; const DENO_API_BASE = process.env.DENO_DEPLOY_API_BASE || "https://api.deno.com/v2"; const POLL_INTERVAL_MS = 2000; @@ -80,34 +81,7 @@ export function resolveRelayTarget( function buildRelayWorker(relayAuth: string): string { return `const resolveRelayTarget = ${resolveRelayTarget.toString()}; -function isPrivateHostname(h) { - if (!h) return true; - const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "127.0.0.1" || - host === "::1" || - host.endsWith(".localhost") || - host.endsWith(".local") || - host.endsWith(".internal") || - host.startsWith("::ffff:") - ) return true; - const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); - if (v4) { - const a = +v4[1], b = +v4[2]; - if (a === 0 || a === 10 || a === 127) return true; - if (a === 169 && b === 254) return true; - if (a === 192 && b === 168) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 100 && b >= 64 && b <= 127) return true; - return false; - } - if (host.includes(":")) { - return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); - } - return false; -} +const isPrivateHostname = ${isPrivateRelayHostname.toString()}; Deno.serve(async (request) => { const auth = request.headers.get("x-relay-auth"); diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index 6d6b859396..fe83e35814 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -9,6 +9,7 @@ import { encrypt } from "@/lib/db/encryption"; // Deno Deploy worker. Both edge relays must enforce identical path validation, // so they import one source of truth rather than diverging copies. import { resolveRelayTarget } from "../deno-deploy/route"; +import { isPrivateRelayHostname } from "@/lib/proxyRelay/privateHostname"; const VERCEL_API_BASE = process.env.VERCEL_API_BASE || "https://api.vercel.com"; const POLL_INTERVAL_MS = 3000; @@ -28,34 +29,7 @@ function buildRelayFunction(relayAuth: string): string { const resolveRelayTarget = ${resolveRelayTarget.toString()}; -function isPrivateHostname(h) { - if (!h) return true; - const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "127.0.0.1" || - host === "::1" || - host.endsWith(".localhost") || - host.endsWith(".local") || - host.endsWith(".internal") || - host.startsWith("::ffff:") - ) return true; - const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); - if (v4) { - const a = +v4[1], b = +v4[2]; - if (a === 0 || a === 10 || a === 127) return true; - if (a === 169 && b === 254) return true; - if (a === 192 && b === 168) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 100 && b >= 64 && b <= 127) return true; - return false; - } - if (host.includes(":")) { - return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); - } - return false; -} +const isPrivateHostname = ${isPrivateRelayHostname.toString()}; export default async function handler(req) { const auth = req.headers.get("x-relay-auth"); diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts index b01d446b53..c25c602122 100644 --- a/src/lib/proxyRelay/cloudflareWorkerScript.ts +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -30,6 +30,7 @@ */ import { randomUUID } from "crypto"; import { resolveRelayTarget } from "@/app/api/settings/proxy/deno-deploy/route"; +import { isPrivateRelayHostname } from "@/lib/proxyRelay/privateHostname"; /** * Build the multipart/form-data request body for Cloudflare's Worker @@ -84,33 +85,7 @@ export function buildCloudflareWorkerScript(relayAuth: string): string { return `// OmniRoute Cloudflare Worker proxy relay — generated at deploy time. const resolveRelayTarget = ${resolveRelayTarget.toString()}; -function isPrivateHostname(h) { - if (!h) return true; - const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); - if ( - host === "localhost" || - host === "0.0.0.0" || host === "127.0.0.1" || host === "::1" || - host.endsWith(".localhost") || - host.endsWith(".local") || - host.endsWith(".internal") || - host.startsWith("::ffff:") - ) return true; - const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); - if (v4) { - const a = +v4[1], b = +v4[2]; - if (a === 0 || a === 10 || a === 127) return true; - if (a === 169 && b === 254) return true; // link-local IPv4 - if (a === 192 && b === 168) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 100 && b >= 64 && b <= 127) return true; - return false; - } - if (host.includes(":")) { - // IPv6 loopback/ULA/link-local (fe80::/10) - return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); - } - return false; -} +const isPrivateHostname = ${isPrivateRelayHostname.toString()}; async function handleRelay(request) { const auth = request.headers.get("x-relay-auth"); diff --git a/src/lib/proxyRelay/privateHostname.ts b/src/lib/proxyRelay/privateHostname.ts new file mode 100644 index 0000000000..7cc800baf0 --- /dev/null +++ b/src/lib/proxyRelay/privateHostname.ts @@ -0,0 +1,67 @@ +/** + * Shared private/loopback host guard for the three proxy-relay workers + * (Cloudflare, Deno Deploy, Vercel Edge). + * + * The three generators each carried a byte-identical copy of this policy inlined + * as a string, so a gap had to be found and fixed three times. It is now written + * once and embedded verbatim via `Function#toString`, the same mechanism + * `resolveRelayTarget` already uses — the edge runtimes cannot import Node + * helpers, so the source has to travel as text. + * + * Pure (only `String`/`RegExp`, no Node or Deno globals) so the SAME source runs + * in every worker and is unit-testable directly in Node. + * + * Callers pass `new URL(target).hostname`, which is already WHATWG-normalized: + * `2130706433` arrives as `127.0.0.1`, and `::ffff:127.0.0.1` arrives as + * `[::ffff:7f00:1]`. The brackets are stripped here. + */ +export function isPrivateRelayHostname(h: string): boolean { + if (!h) return true; + let host = String(h) + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + // Drop the FQDN root dot. `localhost.` resolves exactly like `localhost`, and + // a trailing dot otherwise slips past every exact and suffix test below — + // including `.internal`, so `svc.internal.` would have been allowed. + if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1); + if (!host) return true; + + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "127.0.0.1" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") + ) { + return true; + } + + // Everything in ::/96 — the unspecified address, IPv6 loopback, IPv4-mapped + // (`::ffff:7f00:1`) and the deprecated IPv4-compatible form (`::7f00:1`). + // None of them is a legitimate public relay target, and `http://[::]/` reaches + // a service bound to the IPv6 loopback. + if (host.startsWith("::")) return true; + + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const a = Number(v4[1]); + const b = Number(v4[2]); + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; // link-local IPv4 + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + + if (host.includes(":")) { + if (host.startsWith("fc") || host.startsWith("fd")) return true; // ULA fc00::/7 + // Link-local is fe80::/10 — fe80 through febf, not only the `fe80:` spelling. + if (/^fe[89ab]/.test(host)) return true; + return false; + } + + return false; +} diff --git a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts index 483e28032f..ebd77fba36 100644 --- a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts +++ b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import vm from "node:vm"; // Port of upstream decolua/9router PR #1360: Cloudflare Workers as proxy relay. // @@ -74,14 +75,36 @@ test("buildCloudflareWorkerScript rejects requests without a valid x-relay-auth }); test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (SSRF guard)", () => { - // Mirrors the Vercel relay's inlined SSRF guard. A leaked workers.dev URL - // must not be usable to scan internal networks. + // A leaked workers.dev URL must not be usable to scan internal networks. + // + // Asserted on the guard's BEHAVIOUR rather than on literal substrings of the + // emitted source. The guard is now embedded from + // `src/lib/proxyRelay/privateHostname.ts` and travels through the + // transpiler, so comments are stripped and `169.254` is expressed as an + // octet comparison — a substring grep stopped tracking what is actually + // blocked, while the classes below are the property that matters. const src = buildCloudflareWorkerScript("tok"); - // The guard recognises private CIDRs / loopback by literal substrings in - // the inline function. These specific tokens are load-bearing. - assert.ok(/127\.0\.0\.1|localhost/.test(src), "blocks loopback hosts"); - assert.ok(/192\.168|10\.|172/.test(src), "blocks RFC1918 hosts"); - assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts"); + const binding = src.match(/const isPrivateHostname = [\s\S]*?;\s/); + assert.ok(binding, "worker must embed the private-host guard"); + + // node:vm, not new Function — Hard Rule #3 bans the Function constructor. + const context: Record = {}; + vm.createContext(context); + vm.runInContext(`${binding[0]} globalThis.__guard = isPrivateHostname;`, context); + const isPrivate = (context as { __guard?: (h: string) => boolean }).__guard; + assert.equal(typeof isPrivate, "function", "embedded guard must be reachable"); + if (!isPrivate) return; + + for (const host of ["127.0.0.1", "localhost", "0.0.0.0"]) { + assert.equal(isPrivate(host), true, `blocks loopback host ${host}`); + } + for (const host of ["10.0.0.1", "192.168.1.1", "172.16.0.1"]) { + assert.equal(isPrivate(host), true, `blocks RFC1918 host ${host}`); + } + for (const host of ["169.254.169.254", "fe80::1"]) { + assert.equal(isPrivate(host), true, `blocks link-local host ${host}`); + } + assert.equal(isPrivate("api.example.com"), false, "a public host stays reachable"); }); test("buildCloudflareWorkerScript uses Service Worker syntax, not an ES module (#6416/#6496)", () => { diff --git a/tests/unit/relay-deploy-5128.test.ts b/tests/unit/relay-deploy-5128.test.ts index 96bb9e1a49..41a30a4f8b 100644 --- a/tests/unit/relay-deploy-5128.test.ts +++ b/tests/unit/relay-deploy-5128.test.ts @@ -208,12 +208,19 @@ test("#6416: Cloudflare worker script body is Service Worker syntax (no top-leve `Cloudflare worker script must be syntactically valid JavaScript (stderr: ${parseCheck.stderr.trim()})` ); - const privateHostnameFnSource = capturedScriptBody.match( - /function isPrivateHostname\(h\) \{[\s\S]*?\n\}/ - )?.[0]; - assert.ok(privateHostnameFnSource, "emitted worker script should contain isPrivateHostname"); + assert.ok( + capturedScriptBody.includes("isPrivateHostname"), + "emitted worker script should contain isPrivateHostname" + ); - const assertionScript = `${privateHostnameFnSource} + // Exercise the guard exactly as the worker defines it, instead of slicing + // its source out by declaration shape: the guard may be a `function` + // statement or a `const` bound to a shared implementation, and either way + // the worker body itself is the authority. `addEventListener` is stubbed so + // only the top-level declarations run. + const assertionScript = `globalThis.addEventListener = () => {}; +${capturedScriptBody} +if (typeof isPrivateHostname !== "function") throw new Error("worker must define isPrivateHostname"); if (!isPrivateHostname("[::1]")) throw new Error("bracketed IPv6 loopback must stay blocked"); if (!isPrivateHostname("[fd00::1]")) throw new Error("bracketed IPv6 ULA must stay blocked"); `; diff --git a/tests/unit/relay-private-host-guard-gaps.test.ts b/tests/unit/relay-private-host-guard-gaps.test.ts new file mode 100644 index 0000000000..3943776cd7 --- /dev/null +++ b/tests/unit/relay-private-host-guard-gaps.test.ts @@ -0,0 +1,125 @@ +// The private/loopback guard the three relay workers embed had four gaps, and +// because the policy was inlined as a byte-identical copy in each generator, +// every gap existed three times. +// +// Measured against the pre-fix guard, driving `new URL(target).hostname` the way +// the workers do: +// +// :: -> [::] allowed (unspecified; reaches ::1) +// localhost. -> localhost. allowed (FQDN root dot defeats every +// exact AND suffix test) +// ::127.0.0.1 -> [::7f00:1] allowed (IPv4-compatible ::/96) +// feb0::1 -> [feb0::1] allowed (fe80::/10 spans fe80-febf) +// +// The policy now lives once in `src/lib/proxyRelay/privateHostname.ts` and is +// embedded verbatim, the same way `resolveRelayTarget` already is. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { isPrivateRelayHostname } from "../../src/lib/proxyRelay/privateHostname"; +import { buildCloudflareWorkerScript } from "../../src/lib/proxyRelay/cloudflareWorkerScript"; +import { __buildRelayWorkerForTest } from "../../src/app/api/settings/proxy/deno-deploy/route"; +import { __buildRelayFunctionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; + +/** What the workers actually pass in: `new URL(...).hostname`, brackets included. */ +function asUrlHostname(host: string): string { + const bracketed = host.includes(":") ? `[${host}]` : host; + return new URL(`http://${bracketed}/`).hostname; +} + +describe("relay private-host guard — the four gaps", () => { + for (const host of ["::", "localhost.", "::127.0.0.1", "feb0::1"]) { + it(`blocks ${host}`, () => { + assert.equal(isPrivateRelayHostname(asUrlHostname(host)), true); + }); + } + + it("blocks a trailing-dot form of every suffix rule", () => { + // The root dot used to defeat .localhost / .local / .internal as well. + for (const host of ["app.localhost.", "printer.local.", "svc.internal."]) { + assert.equal(isPrivateRelayHostname(host), true, host); + } + }); +}); + +describe("relay private-host guard — previously-correct behaviour is unchanged", () => { + for (const host of [ + "localhost", + "0.0.0.0", + "127.0.0.1", + "::1", + "::ffff:127.0.0.1", + "10.0.0.1", + "192.168.1.1", + "172.16.0.1", + "169.254.169.254", + "100.100.100.200", + "fd00::1", + "fe80::1", + "app.localhost", + "printer.local", + "svc.internal", + ]) { + it(`still blocks ${host}`, () => { + assert.equal(isPrivateRelayHostname(asUrlHostname(host)), true); + }); + } + + for (const host of ["example.com", "api.anthropic.com", "8.8.8.8", "2606:4700::1111"]) { + it(`still allows ${host}`, () => { + assert.equal(isPrivateRelayHostname(asUrlHostname(host)), false); + }); + } + + it("blocks an empty or whitespace host", () => { + assert.equal(isPrivateRelayHostname(""), true); + assert.equal(isPrivateRelayHostname(" "), true); + }); + + it("does not treat a public host as private just because it ends in a dot", () => { + assert.equal(isPrivateRelayHostname("example.com."), false); + }); +}); + +describe("all three workers embed the shared guard, not their own copy", () => { + const workers: Array<[string, string]> = [ + ["cloudflare", buildCloudflareWorkerScript("deadbeefcafe")], + ["deno", __buildRelayWorkerForTest("deadbeefcafe")], + ["vercel", __buildRelayFunctionForTest("deadbeefcafe")], + ]; + + for (const [name, worker] of workers) { + it(`${name}: no inlined function declaration remains`, () => { + assert.ok( + !worker.includes("function isPrivateHostname(h)"), + `${name} worker must not re-declare the guard inline` + ); + }); + + it(`${name}: binds the guard to a literal const name (#6149)`, () => { + assert.ok( + /const\s+isPrivateHostname\s*=/.test(worker), + `${name} worker must bind the embedded guard to a stable name` + ); + }); + + it(`${name}: the embedded guard still closes the gaps`, () => { + // node:vm, not new Function — Hard Rule #3 bans the Function constructor, + // and relay-minified-fn-6149.test.ts already evaluates emitted worker + // source this way. + const binding = worker.match(/const isPrivateHostname = [\s\S]*?;\s/); + assert.ok(binding, `${name}: embedded guard binding not found`); + const context: Record = {}; + vm.createContext(context); + vm.runInContext(`${binding[0]} globalThis.__guard = isPrivateHostname;`, context); + const embedded = (context as { __guard?: (h: string) => boolean }).__guard; + assert.equal(typeof embedded, "function", `${name}: guard must be reachable`); + if (!embedded) return; + assert.equal(embedded("::"), true); + assert.equal(embedded("localhost."), true); + assert.equal(embedded("::7f00:1"), true); + assert.equal(embedded("feb0::1"), true); + assert.equal(embedded("example.com"), false); + }); + } +});