mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user