mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
Validado no worktree combinado do lote: typecheck:core, changelog-integrity, complexity, cognitive-complexity, file-size e 163 testes focados (incluindo cloudflare-relay-path-ssrf) todos verdes. Fix de segurança real e bem documentado (SSRF via concatenação pós-validação no Cloudflare relay worker). CI vermelho é o base-red já rastreado em #9985. Obrigado!
106 lines
4.8 KiB
TypeScript
106 lines
4.8 KiB
TypeScript
// SSRF regression guard for the Cloudflare relay worker — the third worker,
|
|
// which missed the PR #4643 relay-path fix that the Deno and Vercel workers
|
|
// already carry.
|
|
//
|
|
// The generated Worker read the attacker-controlled `x-relay-path` header and
|
|
// appended it to the (validated) `x-relay-target` origin by string
|
|
// concatenation: `fetch(targetBase + relayPath)`. Validating the target and
|
|
// then concatenating is not enough — the path can re-point the request past the
|
|
// host that was checked, via userinfo (`/x@evil.com`), a backslash
|
|
// (`\evil.com`), or a protocol-relative path (`//evil.com/x`).
|
|
//
|
|
// The fix reuses the SAME pure `resolveRelayTarget()` guard the other two
|
|
// workers embed. This mirrors `vercel-deploy-relay-path-ssrf.test.ts`: the pure
|
|
// function's own behaviour is covered once in the deno test, so here the focus
|
|
// is the Cloudflare worker's wiring — plus the #6149 name-stability property,
|
|
// which a bare `function` declaration would silently lose under minification.
|
|
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 { buildCloudflareWorkerScript } from "../../src/lib/proxyRelay/cloudflareWorkerScript";
|
|
|
|
const RELAY_AUTH = "deadbeefcafe";
|
|
|
|
describe("cloudflare relay worker — no string-concat SSRF hole", () => {
|
|
it("does not append relayPath to the target by string concatenation", () => {
|
|
const worker = buildCloudflareWorkerScript(RELAY_AUTH);
|
|
assert.ok(
|
|
!worker.includes("+ relayPath"),
|
|
"Cloudflare worker must not contain `+ relayPath` string concatenation"
|
|
);
|
|
assert.ok(!worker.includes("targetBase"), "the concatenated targetBase form must be gone");
|
|
});
|
|
|
|
it("embeds the shared guard and fetches the resolved url", () => {
|
|
const worker = buildCloudflareWorkerScript(RELAY_AUTH);
|
|
assert.ok(
|
|
worker.includes("resolveRelayTarget"),
|
|
"the Cloudflare worker must call the shared resolveRelayTarget guard"
|
|
);
|
|
assert.ok(
|
|
worker.includes("resolved.url"),
|
|
"the Cloudflare worker must fetch the SSRF-validated resolved url"
|
|
);
|
|
});
|
|
|
|
it("keeps the auth check and the private-host guard", () => {
|
|
const worker = buildCloudflareWorkerScript(RELAY_AUTH);
|
|
assert.ok(worker.includes(RELAY_AUTH), "relayAuth secret must still be embedded");
|
|
assert.ok(
|
|
worker.includes("isPrivateHostname"),
|
|
"the private/loopback target guard must be preserved"
|
|
);
|
|
});
|
|
|
|
// #6149: the guard is embedded via Function#toString, so a bare `function`
|
|
// declaration would emit the MANGLED name on a minified standalone build
|
|
// while the template still calls the literal `resolveRelayTarget`.
|
|
it("binds the guard to a literal const name so minification cannot break the call", () => {
|
|
const worker = buildCloudflareWorkerScript(RELAY_AUTH);
|
|
assert.ok(
|
|
/const\s+resolveRelayTarget\s*=/.test(worker),
|
|
"the guard must be bound to a literal `const resolveRelayTarget =` name"
|
|
);
|
|
|
|
// Simulate the minifier by renaming ONLY the embedded function's own
|
|
// declared name, then check the binding still resolves.
|
|
const emitted = resolveRelayTarget.toString();
|
|
const mangled = emitted.replace("resolveRelayTarget", "a");
|
|
const minifiedWorker = worker.replace(emitted, mangled);
|
|
// Evaluate ONLY the binding, located by its own literal name. Slicing the
|
|
// worker at some other declaration would tie this test to unrelated parts
|
|
// of the emitted source still being present.
|
|
const binding = minifiedWorker.match(/const resolveRelayTarget = [\s\S]*?;\s/);
|
|
assert.ok(binding, "the emitted worker must contain the const binding");
|
|
const context: Record<string, unknown> = {};
|
|
vm.createContext(context);
|
|
vm.runInContext(`${binding[0]} globalThis.__resolve = resolveRelayTarget;`, context);
|
|
const resolveFn = (context as { __resolve?: typeof resolveRelayTarget }).__resolve;
|
|
assert.equal(typeof resolveFn, "function", "guard must still be reachable after mangling");
|
|
});
|
|
});
|
|
|
|
describe("cloudflare relay worker — host-confusion vectors are rejected", () => {
|
|
const TARGET = "https://api.anthropic.com";
|
|
|
|
it("accepts a legitimate absolute path", () => {
|
|
const r = resolveRelayTarget(TARGET, "/v1/messages");
|
|
assert.equal(r.ok, true);
|
|
if (r.ok) assert.equal(r.url, "https://api.anthropic.com/v1/messages");
|
|
});
|
|
|
|
for (const [label, path] of [
|
|
["//evil.com/x (host swap)", "//evil.com/x"],
|
|
["/x@evil.com (userinfo)", "/x@evil.com"],
|
|
["\\evil.com (backslash)", "\\evil.com"],
|
|
["evil.com/x (no leading slash)", "evil.com/x"],
|
|
] as const) {
|
|
it(`rejects ${label}`, () => {
|
|
const r = resolveRelayTarget(TARGET, path);
|
|
assert.equal(r.ok, false);
|
|
if (!r.ok) assert.equal(r.status, 403);
|
|
});
|
|
}
|
|
});
|