mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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!
217 lines
9.7 KiB
TypeScript
217 lines
9.7 KiB
TypeScript
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.
|
|
//
|
|
// Architecture mirrors the existing Vercel relay (same x-relay-target /
|
|
// x-relay-path / x-relay-auth header spec, same SSRF guard inlined into the
|
|
// worker, same fail-closed missing-relayAuth check). Only the deployment
|
|
// target changes: instead of POSTing to the Vercel /v13/deployments API,
|
|
// we PUT a Worker script to Cloudflare's accounts/{accountId}/workers/scripts
|
|
// API and enable the workers.dev subdomain.
|
|
//
|
|
// Coverage:
|
|
// 1. buildCloudflareWorkerScript(relayAuth) returns an Edge-runtime worker
|
|
// module whose source enforces the same x-relay-auth check as the
|
|
// Vercel relay (so a leaked workers.dev URL cannot be used as an
|
|
// open SSRF proxy by a third party).
|
|
// 2. proxyFetch's relay short-circuit treats type "cloudflare" the same
|
|
// way as "vercel" — uses buildVercelRelayHeaders + routes to the
|
|
// workers.dev origin, never the upstream target.
|
|
// 3. The proxyDispatcher / DB layer recognise "cloudflare" as a relay
|
|
// type (extractRelayAuth fires, dispatcher returns the worker URL).
|
|
|
|
// --- Install the relay sink BEFORE importing the module under test. ---
|
|
type FetchCall = { input: unknown; init: RequestInit & { headers?: HeadersInit } };
|
|
const relayCalls: FetchCall[] = [];
|
|
const realGlobalFetch = globalThis.fetch;
|
|
|
|
const relaySink = (async (input: unknown, init: RequestInit = {}) => {
|
|
relayCalls.push({ input, init });
|
|
return Response.json({ via: "cloudflare-relay" });
|
|
}) as unknown as typeof globalThis.fetch;
|
|
|
|
globalThis.fetch = relaySink;
|
|
|
|
const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts");
|
|
const { buildVercelRelayHeaders, proxyConfigToUrl } = proxyDispatcher;
|
|
const proxyFetchMod = await import("../../open-sse/utils/proxyFetch.ts");
|
|
const { proxyFetch, runWithProxyContext } = proxyFetchMod;
|
|
|
|
const cfDeploy = await import("../../src/lib/proxyRelay/cloudflareWorkerScript.ts");
|
|
const { buildCloudflareWorkerScript } = cfDeploy;
|
|
|
|
test.after(() => {
|
|
globalThis.fetch = realGlobalFetch;
|
|
});
|
|
|
|
test.beforeEach(() => {
|
|
relayCalls.length = 0;
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 1. buildCloudflareWorkerScript — emitted worker source contract
|
|
// --------------------------------------------------------------------------
|
|
|
|
test("buildCloudflareWorkerScript embeds the supplied relayAuth literal", () => {
|
|
const src = buildCloudflareWorkerScript("a-very-specific-secret-token");
|
|
assert.ok(
|
|
src.includes('"a-very-specific-secret-token"'),
|
|
"worker source must embed the relayAuth secret as a string literal"
|
|
);
|
|
});
|
|
|
|
test("buildCloudflareWorkerScript rejects requests without a valid x-relay-auth header", () => {
|
|
// The worker source must enforce the same auth check as the Vercel relay:
|
|
// a 401 short-circuit when x-relay-auth does not match the embedded token.
|
|
// We don't run the worker here — we check the source contains the guard.
|
|
const src = buildCloudflareWorkerScript("the-secret");
|
|
assert.ok(/x-relay-auth/.test(src), "worker source must reference the x-relay-auth header");
|
|
assert.ok(
|
|
/401|Unauthorized/.test(src),
|
|
"worker source must short-circuit unauthorised requests with 401"
|
|
);
|
|
});
|
|
|
|
test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (SSRF guard)", () => {
|
|
// 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");
|
|
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)", () => {
|
|
// Cloudflare's PUT /workers/scripts API parses a plain `application/javascript`
|
|
// script part as Service Worker syntax regardless of any `main_module`
|
|
// metadata — `main_module` requires the script to actually be an ES module
|
|
// (top-level `export`), which rejects the upload with "Unexpected token
|
|
// 'export'" (#6496). The handler must instead register a `fetch` event
|
|
// listener (`addEventListener("fetch", ...)`), with no top-level `export`.
|
|
const src = buildCloudflareWorkerScript("tok");
|
|
assert.ok(
|
|
!/^\s*export\s+default/m.test(src),
|
|
"must not be an ES module (no top-level `export default`)"
|
|
);
|
|
assert.ok(
|
|
/addEventListener\(\s*["']fetch["']/.test(src),
|
|
"must register a fetch event listener (Service Worker syntax)"
|
|
);
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 2. proxyFetch — cloudflare type takes the relay short-circuit
|
|
// --------------------------------------------------------------------------
|
|
|
|
const CLOUDFLARE_CTX = {
|
|
type: "cloudflare" as const,
|
|
host: "omniroute-relay.acme.workers.dev",
|
|
relayAuth: "live-cf-secret",
|
|
};
|
|
|
|
test("proxyFetch routes a cloudflare-type context through the relay endpoint with relay headers", async () => {
|
|
// #9100: the relay branch now egresses through the pooled undici Agent
|
|
// (deps.undiciFetch) instead of `originalFetch`, so the test injects the
|
|
// relay sink via deps to keep the dispatch hermetic.
|
|
const response = await runWithProxyContext(CLOUDFLARE_CTX, () =>
|
|
proxyFetch(
|
|
"https://api.anthropic.com/v1/messages?x=1",
|
|
{
|
|
method: "POST",
|
|
headers: { "x-existing": "keep-me" },
|
|
},
|
|
{ undiciFetch: relaySink as never }
|
|
)
|
|
);
|
|
|
|
assert.deepEqual(await response.json(), { via: "cloudflare-relay" });
|
|
assert.equal(relayCalls.length, 1, "exactly one relay dispatch");
|
|
const call = relayCalls[0];
|
|
|
|
// Rewritten to the workers.dev origin, NOT the upstream target.
|
|
assert.equal(call.input, "https://omniroute-relay.acme.workers.dev");
|
|
|
|
const sentHeaders = new Headers(call.init.headers);
|
|
assert.equal(sentHeaders.get("x-relay-target"), "https://api.anthropic.com");
|
|
assert.equal(sentHeaders.get("x-relay-path"), "/v1/messages?x=1");
|
|
assert.equal(sentHeaders.get("x-relay-auth"), "live-cf-secret");
|
|
assert.equal(sentHeaders.get("x-existing"), "keep-me");
|
|
assert.equal(call.init.method, "POST");
|
|
assert.equal((call.init as { duplex?: string }).duplex, "half");
|
|
});
|
|
|
|
test("proxyFetch throws (without dispatching) when a cloudflare context is missing relayAuth", async () => {
|
|
await assert.rejects(
|
|
runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, () =>
|
|
proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" })
|
|
),
|
|
/relay configuration error: missing relayAuth/
|
|
);
|
|
assert.equal(relayCalls.length, 0, "no relay dispatch when relayAuth is missing");
|
|
});
|
|
|
|
test("the missing-relayAuth error message does not leak internal [ProxyFetch] diagnostics", async () => {
|
|
await runWithProxyContext({ type: "cloudflare", host: "x.workers.dev" }, async () => {
|
|
try {
|
|
await proxyFetch("https://api.anthropic.com/v1/messages", { method: "POST" });
|
|
assert.fail("expected the relay branch to throw on missing relayAuth");
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
assert.ok(!message.includes("[ProxyFetch]"), "no internal [ProxyFetch] label");
|
|
assert.ok(!message.includes("at /"), "no stack-trace path leaked");
|
|
}
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 3. proxyConfigToUrl — cloudflare type yields the worker https origin
|
|
// --------------------------------------------------------------------------
|
|
|
|
test("proxyConfigToUrl returns the cloudflare worker URL (no HTTP-proxy dispatcher needed)", () => {
|
|
const url = proxyConfigToUrl({
|
|
type: "cloudflare",
|
|
host: "omniroute-relay.acme.workers.dev",
|
|
});
|
|
assert.equal(url, "https://omniroute-relay.acme.workers.dev");
|
|
});
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 4. buildVercelRelayHeaders is shared (renamed-or-aliased? at minimum still
|
|
// works for cloudflare — same header spec).
|
|
// --------------------------------------------------------------------------
|
|
|
|
test("buildVercelRelayHeaders is the shared relay-header builder used for cloudflare too", () => {
|
|
const headers = buildVercelRelayHeaders("https://api.openai.com/v1/chat/completions", "cf-tok");
|
|
assert.deepEqual(headers, {
|
|
"x-relay-target": "https://api.openai.com",
|
|
"x-relay-path": "/v1/chat/completions",
|
|
"x-relay-auth": "cf-tok",
|
|
});
|
|
});
|