diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd429a96d..56eef95e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [3.8.45] — TBD +### 🔧 Bug Fixes + +- **fix(api):** relay worker now binds the SSRF guard to a stable `const` name so minified standalone (Docker) builds resolve it ([#6149](https://github.com/diegosouzapw/OmniRoute/issues/6149)) — the Vercel/Deno relay generators embedded the shared `resolveRelayTarget` guard as a bare `${fn.toString()}` declaration while the worker body called the hardcoded literal name; SWC minification mangled the source function's name, so the deployed worker defined `` but still called `resolveRelayTarget` → `ReferenceError`. Both templates now emit `const resolveRelayTarget = ${fn.toString()};` (the const name is a template literal, immune to minification). Regression guard: `tests/unit/relay-minified-fn-6149.test.ts` (4). (thanks @SeaXen) + ### ⚡ Performance & Infrastructure - **perf(test):** test-suite loader quick wins ([#6214](https://github.com/diegosouzapw/OmniRoute/pull/6214)) — the 19 test scripts switch `--import tsx` → `--import tsx/esm` (the repo is pure ESM; the unused CJS hook cost ~1.3s per test process × 2,462 processes — CI fast-path unit shards dropped 14.8→7.5 min, −49%), tsx bumped to ^4.23.0 (tsx#809 startup-regression fix), **37 orphan `.test.mjs` files (224 cases) recovered** into the canonical glob (they matched no runner and never ran in any CI job; `check:test-discovery` now scans `.mjs` too), and ci.yml/quality.yml unit jobs now call the canonical npm script `test:unit:ci:shard` (single source of truth — closes two silent drifts: missing `setupPolyfill` import in CI and `memory/`+`usage/` dirs absent from the fast-path glob). `tests/unit/dashboard/**` keeps the full tsx hook in its own invocation (`@lobehub/icons` es/ build internally `require()`s ESM-syntax files). diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts index 8b305770fa..7ec8764d87 100644 --- a/src/app/api/settings/proxy/deno-deploy/route.ts +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -74,8 +74,11 @@ export function resolveRelayTarget( // SAME source used by the server and by the unit tests — embedded here via // Function#toString so the worker enforces byte-for-byte the audited policy. // Mirrors the Vercel-relay guard so a future audit can diff the two. +// The guard is bound to a LITERAL const name (not a bare declaration) so the +// hardcoded call site below resolves even when the SWC-minified standalone build +// mangles the source function's own name in `.toString()` output (#6149). function buildRelayWorker(relayAuth: string): string { - return `${resolveRelayTarget.toString()} + return `const resolveRelayTarget = ${resolveRelayTarget.toString()}; function isPrivateHostname(h) { if (!h) return true; diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index eb46cfb336..42f432f2c6 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -20,10 +20,13 @@ function buildRelayFunction(relayAuth: string): string { // Node-side helpers from the Edge runtime); it blocks RFC1918, loopback, // link-local, IPv6 ULA, and embedded credentials on the x-relay-target host. // `resolveRelayTarget` (shared with the Deno worker) closes the x-relay-path - // host-confusion hole and is embedded verbatim via Function#toString. + // host-confusion hole and is embedded verbatim via Function#toString. It is + // bound to a LITERAL const name (not a bare declaration) so the hardcoded + // call site below resolves even when the SWC-minified standalone build mangles + // the source function's own name in `.toString()` output (#6149). return `export const config = { runtime: "edge" }; -${resolveRelayTarget.toString()} +const resolveRelayTarget = ${resolveRelayTarget.toString()}; function isPrivateHostname(h) { if (!h) return true; diff --git a/tests/unit/relay-minified-fn-6149.test.ts b/tests/unit/relay-minified-fn-6149.test.ts new file mode 100644 index 0000000000..71c8751a6f --- /dev/null +++ b/tests/unit/relay-minified-fn-6149.test.ts @@ -0,0 +1,144 @@ +// Regression guard for #6149 — relay worker throws +// `ReferenceError: resolveRelayTarget is not defined` on minified standalone +// (SWC) Docker builds. +// +// Root cause: both the Vercel and Deno relay generators embed the shared SSRF +// guard as a BARE function declaration via `${resolveRelayTarget.toString()}`, +// but the worker body CALLS the hardcoded string literal `resolveRelayTarget(...)`. +// In the SWC-minified standalone build the SOURCE identifier gets mangled, so +// `.toString()` emits `function (...)` — the worker defines `` +// while the template still calls `resolveRelayTarget` → ReferenceError at runtime. +// Unminified source tests never catch this because the source name is intact. +// +// The fix embeds the guard under a NAME-STABLE binding — +// `const resolveRelayTarget = ${resolveRelayTarget.toString()};` — so the const +// name is a literal in the template (immune to minification) and resolves the +// hardcoded call regardless of the mangled inner function name. +// +// This test reproduces the defect WITHOUT a real build: it simulates the +// minifier by renaming ONLY the embedded guard function's own declared name +// (located precisely via `resolveRelayTarget.toString()`), then eval-runs the +// emitted worker in a `node:vm` sandbox and asserts the handler still resolves +// the guard instead of throwing ReferenceError. A structural assertion (stable +// `const resolveRelayTarget =` binding) backs it up. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { resolveRelayTarget } from "../../src/app/api/settings/proxy/deno-deploy/route"; +import { __buildRelayFunctionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route"; +import { __buildRelayWorkerForTest } from "../../src/app/api/settings/proxy/deno-deploy/route"; + +const RELAY_AUTH = "testrelayauth"; +const TARGET = "https://api.anthropic.com"; +const PATH = "/v1/messages"; + +// Minimal WHATWG-ish stubs so the generated worker can run under node:vm. +class FakeHeaders { + m: Map; + constructor(init?: FakeHeaders) { + this.m = new Map(); + if (init && init.m) for (const [k, v] of init.m) this.m.set(k, v); + } + get(k: string): string | null { + const key = k.toLowerCase(); + return this.m.has(key) ? (this.m.get(key) as string) : null; + } + set(k: string, v: string): void { + this.m.set(k.toLowerCase(), v); + } + delete(k: string): void { + this.m.delete(k.toLowerCase()); + } + forEach(fn: (v: string, k: string) => void): void { + this.m.forEach((v, k) => fn(v, k)); + } +} + +class FakeResponse { + body: unknown; + status: number; + headers: unknown; + constructor(body: unknown, init?: { status?: number; headers?: unknown }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = init?.headers; + } +} + +function buildRequest(): { method: string; body: string; headers: FakeHeaders } { + const headers = new FakeHeaders(); + headers.set("x-relay-auth", RELAY_AUTH); + headers.set("x-relay-target", TARGET); + headers.set("x-relay-path", PATH); + return { method: "POST", body: "payload", headers }; +} + +/** + * Simulate the SWC minifier: rename ONLY the embedded guard's declared function + * name (the source `resolveRelayTarget` identifier that gets mangled), leaving + * the hardcoded string-literal call sites in the template untouched — exactly + * what happens in the standalone build. + */ +function minify(worker: string): string { + const guardSrc = resolveRelayTarget.toString(); + // First occurrence inside the guard source is its own declaration name. + const mangledGuard = guardSrc.replace("resolveRelayTarget", "m0mangled0m"); + return worker.replace(guardSrc, mangledGuard); +} + +async function runWorker( + worker: string, + kind: "vercel" | "deno" +): Promise { + const ctx: Record = { + URL, + Headers: FakeHeaders, + Response: FakeResponse, + console, + fetch: async () => ({ body: "ok", status: 200, headers: new FakeHeaders() }), + }; + let captured: ((req: unknown) => Promise) | undefined; + ctx.Deno = { serve: (h: (req: unknown) => Promise) => (captured = h) }; + vm.createContext(ctx); + + let code = worker; + if (kind === "vercel") { + code = code + .replace(/export const config[^\n]*\n/, "") + .replace("export default async function handler", "__vercelHandler = async function handler"); + } + vm.runInContext(code, ctx); + if (kind === "vercel") { + captured = ctx.__vercelHandler as (req: unknown) => Promise; + } + assert.ok(captured, `${kind} worker did not register a handler`); + return captured(buildRequest()); +} + +describe("#6149 relay worker binds SSRF guard to a stable name", () => { + it("Vercel worker: emitted source embeds a stable `const resolveRelayTarget =` binding", () => { + const worker = __buildRelayFunctionForTest(RELAY_AUTH); + assert.match( + worker, + /const\s+resolveRelayTarget\s*=/, + "worker must bind the guard to a literal const name so minification cannot dangle the call" + ); + }); + + it("Deno worker: emitted source embeds a stable `const resolveRelayTarget =` binding", () => { + const worker = __buildRelayWorkerForTest(RELAY_AUTH); + assert.match(worker, /const\s+resolveRelayTarget\s*=/); + }); + + it("Vercel worker: resolves the guard after minification mangles the source fn name", async () => { + const worker = minify(__buildRelayFunctionForTest(RELAY_AUTH)); + const res = await runWorker(worker, "vercel"); + assert.equal(res.status, 200, "handler must reach the upstream fetch, not throw ReferenceError"); + }); + + it("Deno worker: resolves the guard after minification mangles the source fn name", async () => { + const worker = minify(__buildRelayWorkerForTest(RELAY_AUTH)); + const res = await runWorker(worker, "deno"); + assert.equal(res.status, 200); + }); +});