mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
1e59d14143
commit
2d5bd41261
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
144
tests/unit/relay-minified-fn-6149.test.ts
Normal file
144
tests/unit/relay-minified-fn-6149.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user