From b5b313ee073f7c088afcb58b1d85eb7eb092507b Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Mon, 6 Jul 2026 17:05:42 -0300 Subject: [PATCH] fix(sdk/abTesting): preserve original Host when proxying to fallback origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxyToFallback rewrote the target URL to the fallback origin and dropped the incoming Host header, so shared fallback origins that route by Host (e.g. the EKS ingress LB behind decoserverless.deco.site) received Host: and answered 404 for every proxied request. Forward Host: explicitly instead — same behavior as the original cf-dispatch gateway, which forwarded incoming headers untouched and is production-proven against the same LB. x-forwarded-host is still set and hop-by-hop stripping is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sdk/abTesting.test.ts | 6 ++++-- src/sdk/abTesting.ts | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/sdk/abTesting.test.ts b/src/sdk/abTesting.test.ts index 6c141287..afe4479a 100644 --- a/src/sdk/abTesting.test.ts +++ b/src/sdk/abTesting.test.ts @@ -62,7 +62,7 @@ describe("proxyToFallback", () => { expect(init.redirect).toBe("manual"); }); - it("strips hop-by-hop headers + host before forwarding", async () => { + it("strips hop-by-hop headers, preserving the original host", async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); const request = new Request(`https://${REAL_HOST}/foo`, { @@ -82,7 +82,9 @@ describe("proxyToFallback", () => { const [, init] = fetchSpy.mock.calls[0]; const fwd = init.headers as Headers; - expect(fwd.get("host")).toBeNull(); + // Host stays on the site's hostname — shared fallback origins (e.g. the + // EKS ingress LB) route by Host; sending the LB's own host 404s. + expect(fwd.get("host")).toBe(REAL_HOST); expect(fwd.get("connection")).toBeNull(); expect(fwd.get("keep-alive")).toBeNull(); expect(fwd.get("transfer-encoding")).toBeNull(); diff --git a/src/sdk/abTesting.ts b/src/sdk/abTesting.ts index 71848b53..56a57696 100644 --- a/src/sdk/abTesting.ts +++ b/src/sdk/abTesting.ts @@ -123,7 +123,7 @@ function fnv1a(str: string): number { /** * Hop-by-hop headers per RFC 7230 §6.1 — must not be forwarded through - * proxies. Plus `host`, which we always rewrite to the target origin. + * proxies. */ const HOP_BY_HOP_HEADERS = new Set([ "connection", @@ -276,11 +276,18 @@ export function tagBucket( * Proxy a request to the fallback origin with full hostname rewriting. * * Rewrites: - * 1. URL hostname → fallback origin + * 1. URL hostname → fallback origin (the connection target) * 2. Set-Cookie Domain → real hostname * 3. Body text: fallback hostname → real hostname (for Fresh partial URLs) * 4. Location header → real hostname * + * **The original `Host` header is preserved.** Fallback origins are shared + * entry points (e.g. the EKS ingress load balancer) that route to the site + * by `Host` — sending `Host: ` instead returns the origin's + * 404 page. This mirrors the original cf-dispatch gateway, which forwarded + * the incoming headers (Host included) untouched. `x-forwarded-host` is + * still set for upstreams that read it. + * * **`redirect: "manual"` is critical.** The request body is forwarded as a * stream (`duplex: "half"`) and is consumed by this first fetch. If the * upstream returns a 301/302 and we let CF auto-follow, the runtime would @@ -297,16 +304,19 @@ export async function proxyToFallback( const target = new URL(url.toString()); target.hostname = fallbackOrigin; - // Strip hop-by-hop headers + Host before forwarding. Without this the + // Strip hop-by-hop headers before forwarding. Without this the // upstream may close the connection (Connection: close) or get confused // by Transfer-Encoding/Upgrade meant for the original CF↔client hop. const headers = new Headers(); for (const [key, value] of request.headers.entries()) { const lk = key.toLowerCase(); - if (lk === "host") continue; if (HOP_BY_HOP_HEADERS.has(lk)) continue; headers.set(key, value); } + // Keep the site's hostname as Host — the fallback origin routes by it + // (see docstring). Set explicitly rather than trusting the incoming + // value so a mangled upstream Host can't leak through. + headers.set("host", url.hostname); headers.set("x-forwarded-host", url.hostname); const init: RequestInit = {