fix(api): stabilize relay SSRF-guard binding for minified builds (#6149) (#6224)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-04 22:24:47 -03:00
committed by GitHub
parent 1e59d14143
commit 2d5bd41261
4 changed files with 157 additions and 3 deletions

View File

@@ -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 `<mangled>` 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).

View File

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

View File

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

View File

@@ -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 <mangled>(...)` — the worker defines `<mangled>`
// 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<string, string>;
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<FakeResponse> {
const ctx: Record<string, unknown> = {
URL,
Headers: FakeHeaders,
Response: FakeResponse,
console,
fetch: async () => ({ body: "ok", status: 200, headers: new FakeHeaders() }),
};
let captured: ((req: unknown) => Promise<FakeResponse>) | undefined;
ctx.Deno = { serve: (h: (req: unknown) => Promise<FakeResponse>) => (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<FakeResponse>;
}
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);
});
});