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

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