From 28601b456eeaea93de3cbd6ebc876c695adb1b12 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:12:50 +0200 Subject: [PATCH] fix(relay): strip unsafe forwarded headers (#11533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../generated-relay-header-sanitization.md | 1 + .../api/settings/proxy/deno-deploy/route.ts | 6 ++- .../api/settings/proxy/vercel-deploy/route.ts | 6 ++- src/lib/proxyRelay/cloudflareWorkerScript.ts | 6 ++- .../generated-relay-header-denylist.test.ts | 45 +++++++++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/generated-relay-header-sanitization.md create mode 100644 tests/unit/generated-relay-header-denylist.test.ts diff --git a/changelog.d/fixes/generated-relay-header-sanitization.md b/changelog.d/fixes/generated-relay-header-sanitization.md new file mode 100644 index 0000000000..0dbbdafce2 --- /dev/null +++ b/changelog.d/fixes/generated-relay-header-sanitization.md @@ -0,0 +1 @@ +- Harden generated Cloudflare, Vercel, and Deno relays by stripping hop-by-hop, framing, and proxy authentication headers before forwarding requests upstream. diff --git a/src/app/api/settings/proxy/deno-deploy/route.ts b/src/app/api/settings/proxy/deno-deploy/route.ts index 9f55dc46e6..0c277e5d62 100644 --- a/src/app/api/settings/proxy/deno-deploy/route.ts +++ b/src/app/api/settings/proxy/deno-deploy/route.ts @@ -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; diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index fe83e35814..6debb6e7e6 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -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, diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts index c25c602122..1e22766f96 100644 --- a/src/lib/proxyRelay/cloudflareWorkerScript.ts +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -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, diff --git a/tests/unit/generated-relay-header-denylist.test.ts b/tests/unit/generated-relay-header-denylist.test.ts new file mode 100644 index 0000000000..0b91f6bc33 --- /dev/null +++ b/tests/unit/generated-relay-header-denylist.test.ts @@ -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); + }); + } +});