mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
1
changelog.d/fixes/10941-relay-private-host-guard.md
Normal file
1
changelog.d/fixes/10941-relay-private-host-guard.md
Normal 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
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
67
src/lib/proxyRelay/privateHostname.ts
Normal file
67
src/lib/proxyRelay/privateHostname.ts
Normal 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;
|
||||
}
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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");
|
||||
`;
|
||||
|
||||
125
tests/unit/relay-private-host-guard-gaps.test.ts
Normal file
125
tests/unit/relay-private-host-guard-gaps.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user