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); + }); + } +});