105 lines
4.3 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|