fix(relay): share one private-host guard across the three relay workers (#10941)

Reconciliado com #10935 (já mergeada) — mesclado o guard inline recém-mergeado com a extração para `privateHostname.ts` deste PR, mantendo a intenção original: os 3 workers de relay agora usam a MESMA função compartilhada. Validado: lint limpo, 49/49 testes focados passando (incluindo verificação de que nenhum worker mantém cópia inline). Hardening de segurança real e bem documentado (4 gaps de bypass: `::`, `localhost.`, `::127.0.0.1`, `feb0::1`). CI vermelho é o base-red já rastreado em #9985. Obrigado!
This commit is contained in:
Nguyen Thanh Dat
2026-08-21 14:17:25 +07:00
committed by GitHub
parent 6eaa737333
commit 4fa204de68
8 changed files with 241 additions and 95 deletions

View File

@@ -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

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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<string, unknown> = {};
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)", () => {

View File

@@ -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");
`;

View File

@@ -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<string, unknown> = {};
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);
});
}
});