fix(relay): strip unsafe forwarded headers (#11533)

Validated in a combined 10-PR batch worktree off release/v3.8.51 tip.
- Focused test: tests/unit/generated-relay-header-denylist.test.ts — 3/3 contract pass plus 30/30 related SSRF tests
- typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity gates — all OK
- Full-repo lint: 503 pre-existing problems confirmed identical on the pure release/v3.8.51 tip — unrelated to this diff

⚠️ base-red inherited: #11449

Thanks for applying one canonical header denylist across the generated Cloudflare/Vercel/Deno relays so hop-by-hop, framing, and proxy-auth/relay-control headers stop leaking upstream.
This commit is contained in:
Paco Cartones
2026-08-25 18:12:50 +02:00
committed by GitHub
parent 436ce4869f
commit 28601b456e
5 changed files with 61 additions and 3 deletions

View File

@@ -0,0 +1 @@
- Harden generated Cloudflare, Vercel, and Deno relays by stripping hop-by-hop, framing, and proxy authentication headers before forwarding requests upstream.

View File

@@ -105,7 +105,11 @@ Deno.serve(async (request) => {
return new Response(resolved.reason, { status: resolved.status });
}
const headers = new Headers(request.headers);
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h));
[
"host", "connection", "content-length", "keep-alive", "proxy-connection",
"proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade",
"x-relay-target", "x-relay-path", "x-relay-auth",
].forEach(h => headers.delete(h));
const init = { method: request.method, headers };
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.body;

View File

@@ -53,7 +53,11 @@ export default async function handler(req) {
return new Response(resolved.reason, { status: resolved.status });
}
const headers = new Headers(req.headers);
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h));
[
"host", "connection", "content-length", "keep-alive", "proxy-connection",
"proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade",
"x-relay-target", "x-relay-path", "x-relay-auth",
].forEach(h => headers.delete(h));
const upstream = await fetch(resolved.url, {
method: req.method,
headers,

View File

@@ -109,7 +109,11 @@ async function handleRelay(request) {
}
const relayPath = request.headers.get("x-relay-path") || "/";
const headers = new Headers(request.headers);
["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach((h) => headers.delete(h));
[
"host", "connection", "content-length", "keep-alive", "proxy-connection",
"proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade",
"x-relay-target", "x-relay-path", "x-relay-auth",
].forEach((h) => headers.delete(h));
const init = {
method: request.method,
headers,

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { __buildRelayWorkerForTest } from "../../src/app/api/settings/proxy/deno-deploy/route";
import { __buildRelayFunctionForTest } from "../../src/app/api/settings/proxy/vercel-deploy/route";
import { buildCloudflareWorkerScript } from "../../src/lib/proxyRelay/cloudflareWorkerScript";
const FORBIDDEN_RELAY_HEADERS = [
"host",
"connection",
"content-length",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"x-relay-target",
"x-relay-path",
"x-relay-auth",
];
const relays = {
Cloudflare: buildCloudflareWorkerScript("relay-secret"),
Vercel: __buildRelayFunctionForTest("relay-secret"),
Deno: __buildRelayWorkerForTest("relay-secret"),
};
describe("generated relay request header sanitization", () => {
for (const [runtime, source] of Object.entries(relays)) {
it(`${runtime} strips relay controls, framing, and hop-by-hop headers`, () => {
const deletionBlock = source.match(
/const headers = new Headers\([^;]+\);\s*\[([\s\S]*?)\]\.forEach\(\(?h\)?\s*=>\s*headers\.delete\(h\)\);/
);
assert.ok(deletionBlock, `${runtime} generated source must sanitize forwarded headers`);
const deletedHeaders = [...deletionBlock[1].matchAll(/["']([^"']+)["']/g)].map(
(match) => match[1]
);
assert.deepEqual(deletedHeaders, FORBIDDEN_RELAY_HEADERS);
});
}
});