Files
diegosouzapw--omniroute/tests/unit/deno-deploy-relay-path-ssrf.test.ts
2026-07-13 13:39:12 +08:00

105 lines
4.3 KiB
TypeScript

// SSRF regression guard for the Deno Deploy relay worker (PR #4643 follow-up).
//
// The generated edge worker reads an attacker-controlled `x-relay-path` header
// and previously appended it to the (validated) `x-relay-target` origin via
// string concatenation: `fetch(target.replace(/\/$/, "") + relayPath)`. That
// lets the caller smuggle a NEW host past the allow/SSRF check using userinfo
// (`@`), a backslash, or a protocol-relative path:
// - x-relay-target: https://api.anthropic.com (passes the host guard)
// - x-relay-path: //evil.com/x → fetches https://evil.com/x
// - x-relay-path: /x@evil.com → userinfo confusion
// - x-relay-path: \evil.com → backslash host confusion
//
// The fix resolves the path against the validated target with `new URL()` and
// re-checks that the resolved host/credentials still match the target. The
// path-resolution decision is exported as the pure `resolveRelayTarget()` so it
// can be unit-tested directly (the worker body — a Deno-runtime string — embeds
// the SAME function source, asserted below by diffing the generated worker).
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
resolveRelayTarget,
__buildRelayWorkerForTest,
} from "../../src/app/api/settings/proxy/deno-deploy/route";
const TARGET = "https://api.anthropic.com";
describe("resolveRelayTarget — SSRF-safe path join", () => {
it("accepts a legitimate absolute path and keeps the validated host", () => {
const r = resolveRelayTarget(TARGET, "/v1/foo");
assert.equal(r.ok, true, "a normal path must pass");
if (r.ok) {
assert.equal(r.url, "https://api.anthropic.com/v1/foo");
}
});
it("accepts a path with a query string", () => {
const r = resolveRelayTarget(TARGET, "/v1/messages?beta=true");
assert.equal(r.ok, true);
if (r.ok) {
assert.equal(r.url, "https://api.anthropic.com/v1/messages?beta=true");
}
});
it("rejects a protocol-relative path that swaps the host (//evil.com/x)", () => {
const r = resolveRelayTarget(TARGET, "//evil.com/x");
assert.equal(r.ok, false, "//evil.com must NOT be allowed to change the host");
if (!r.ok) assert.equal(r.status, 403);
});
it("rejects a path containing userinfo (/x@evil.com)", () => {
const r = resolveRelayTarget(TARGET, "/x@evil.com");
assert.equal(r.ok, false, "@ in the path must be rejected");
if (!r.ok) assert.equal(r.status, 403);
});
it("rejects a backslash host-confusion path (\\evil.com)", () => {
const r = resolveRelayTarget(TARGET, "\\evil.com");
assert.equal(r.ok, false, "backslash must be rejected");
if (!r.ok) assert.equal(r.status, 403);
});
it("rejects a value that does not start with '/'", () => {
const r = resolveRelayTarget(TARGET, "evil.com/x");
assert.equal(r.ok, false, "a path must start with '/'");
if (!r.ok) assert.equal(r.status, 403);
});
it("rejects an embedded-credentials path that resolves a different host", () => {
// Classic userinfo trick: everything before '@' becomes userinfo, the real
// host is after it.
const r = resolveRelayTarget(TARGET, "/@evil.com/path");
assert.equal(r.ok, false);
if (!r.ok) assert.equal(r.status, 403);
});
it("rejects backslash-prefixed protocol-relative (\\\\evil.com)", () => {
const r = resolveRelayTarget(TARGET, "\\\\evil.com/x");
assert.equal(r.ok, false);
if (!r.ok) assert.equal(r.status, 403);
});
});
describe("buildRelayWorker — generated worker has no string-concat SSRF hole", () => {
it("does not concatenate the target with the raw relay path", () => {
const worker = __buildRelayWorkerForTest("deadbeefcafe");
// The vulnerable pattern was: target.replace(/\/$/, "") + relayPath
assert.ok(
!/\.replace\(\s*\/\\?\/\$\/\s*,\s*["']{2}\s*\)\s*\+\s*relayPath/.test(worker),
"worker must not append relayPath to target by string concatenation"
);
assert.ok(
!worker.includes('+ relayPath'),
"worker must not contain `+ relayPath` string concatenation"
);
});
it("embeds the resolveRelayTarget guard into the worker body", () => {
const worker = __buildRelayWorkerForTest("deadbeefcafe");
assert.ok(
worker.includes("resolveRelayTarget"),
"the worker must call the shared resolveRelayTarget guard"
);
});
});