From 03ce47ccb7de5bfeaea29719e98f4b62603cb720 Mon Sep 17 00:00:00 2001 From: "James D. Nurmi" Date: Thu, 25 Jun 2026 11:22:46 -0700 Subject: [PATCH 1/2] fix: use getSetCookie() to preserve multiple Set-Cookie headers in middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headers.forEach folds same-name headers per WHATWG. Both harvest sites accumulate set-cookie through forEach, which yields one pre-joined string on runtimes where folding occurs — breaking any terminal-return response that sets multiple cookies (e.g. chunked auth sessions). Replace with getSetCookie() at: - core/routing/middleware.ts (terminal-return and rewrite paths) - adapters/edge-adapter.ts Adds regression tests using a simulated folding Headers object, confirmed to fail on unpatched code and pass with the fix. --- .../open-next/src/adapters/edge-adapter.ts | 14 ++-- .../open-next/src/core/routing/middleware.ts | 12 +-- .../tests/adapters/edge-adapter.test.ts | 80 +++++++++++++++++++ .../tests/core/routing/middleware.test.ts | 70 ++++++++++++++++ 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 packages/tests-unit/tests/adapters/edge-adapter.test.ts diff --git a/packages/open-next/src/adapters/edge-adapter.ts b/packages/open-next/src/adapters/edge-adapter.ts index fa16ccad1..28976e8ed 100644 --- a/packages/open-next/src/adapters/edge-adapter.ts +++ b/packages/open-next/src/adapters/edge-adapter.ts @@ -46,14 +46,14 @@ const defaultHandler = async ( }); const responseHeaders: Record = {}; response.headers.forEach((value, key) => { - if (key.toLowerCase() === "set-cookie") { - responseHeaders[key] = responseHeaders[key] - ? [...responseHeaders[key], value] - : [value]; - } else { - responseHeaders[key] = value; - } + // Headers.forEach folds same-name headers; use getSetCookie() below instead. + if (key.toLowerCase() === "set-cookie") return; + responseHeaders[key] = value; }); + const setCookies = response.headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders["set-cookie"] = setCookies; + } const body = (response.body as ReadableStream) ?? emptyReadableStream(); diff --git a/packages/open-next/src/core/routing/middleware.ts b/packages/open-next/src/core/routing/middleware.ts index 5c0eb9390..7a9166547 100644 --- a/packages/open-next/src/core/routing/middleware.ts +++ b/packages/open-next/src/core/routing/middleware.ts @@ -130,11 +130,9 @@ export async function handleMiddleware( reqHeaders[k] = value; } else { if (filteredHeaders.includes(key.toLowerCase())) return; - if (key.toLowerCase() === "set-cookie") { - resHeaders[key] = resHeaders[key] - ? [...resHeaders[key], value] - : [value]; - } else if ( + // Headers.forEach folds same-name headers; use getSetCookie() below instead. + if (key.toLowerCase() === "set-cookie") return; + if ( REDIRECTS.has(statusCode) && key.toLowerCase() === "location" ) { @@ -144,6 +142,10 @@ export async function handleMiddleware( } } }); + const setCookies = responseHeaders.getSetCookie(); + if (setCookies.length > 0) { + resHeaders["set-cookie"] = setCookies; + } // If the middleware returned a Rewrite, set the `url` to the pathname of the rewrite // NOTE: the header was added to `req` from above diff --git a/packages/tests-unit/tests/adapters/edge-adapter.test.ts b/packages/tests-unit/tests/adapters/edge-adapter.test.ts new file mode 100644 index 000000000..059bb77da --- /dev/null +++ b/packages/tests-unit/tests/adapters/edge-adapter.test.ts @@ -0,0 +1,80 @@ +// edge-adapter imports globalThis.isEdgeRuntime and createGenericHandler which +// require heavy bundled context. Unit-test the header harvest logic directly. + +function harvestHeaders( + headers: Pick, +): Record { + const responseHeaders: Record = {}; + headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") return; + responseHeaders[key] = value; + }); + const setCookies = headers.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders["set-cookie"] = setCookies; + } + return responseHeaders; +} + +// Simulates Headers.forEach folding same-name headers (WHATWG-compliant behavior +// on runtimes where this occurs), while getSetCookie() still returns them split. +function makeFoldingHeaders( + cookies: string[], + extra: Record = {}, +): Pick { + return { + forEach(fn: (value: string, key: string) => void) { + if (cookies.length > 0) fn(cookies.join(", "), "set-cookie"); + for (const [k, v] of Object.entries(extra)) fn(v, k); + }, + getSetCookie() { + return [...cookies]; + }, + } as unknown as Pick; +} + +describe("edge-adapter header harvest", () => { + it("emits a single set-cookie as a one-element array", () => { + const headers = makeFoldingHeaders(["session=abc; Path=/; HttpOnly"]); + const out = harvestHeaders(headers); + expect(out["set-cookie"]).toEqual(["session=abc; Path=/; HttpOnly"]); + }); + + it("preserves multiple set-cookie headers when forEach folds them", () => { + const cookies = [ + "appSession.0=AAA; HttpOnly; SameSite=Lax; Path=/", + "appSession.1=BBB; HttpOnly; SameSite=Lax; Path=/", + "appSession.2=CCC; HttpOnly; SameSite=Lax; Path=/", + ]; + const out = harvestHeaders(makeFoldingHeaders(cookies, { "x-custom": "value" })); + expect(out["set-cookie"]).toEqual(cookies); + }); + + it("each set-cookie entry is discrete, not comma-joined", () => { + const cookies = [ + "appSession.0=AAA; HttpOnly; Path=/", + "appSession.1=BBB; HttpOnly; Path=/", + ]; + const out = harvestHeaders(makeFoldingHeaders(cookies)); + for (const entry of out["set-cookie"] as string[]) { + expect(entry).not.toContain(", appSession"); + } + }); + + it("passes non-set-cookie headers through unchanged", () => { + const out = harvestHeaders( + makeFoldingHeaders(["tok=x; Path=/"], { + "content-type": "application/json", + "x-request-id": "abc-123", + }), + ); + expect(out["content-type"]).toBe("application/json"); + expect(out["x-request-id"]).toBe("abc-123"); + }); + + it("omits set-cookie key when there are no cookies", () => { + const out = harvestHeaders(makeFoldingHeaders([], { "x-foo": "bar" })); + expect("set-cookie" in out).toBe(false); + expect(out["x-foo"]).toBe("bar"); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/middleware.test.ts b/packages/tests-unit/tests/core/routing/middleware.test.ts index 414d6dda8..9130b21a6 100644 --- a/packages/tests-unit/tests/core/routing/middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/middleware.test.ts @@ -363,4 +363,74 @@ describe("handleMiddleware", () => { }), ); }); + + it("should preserve multiple set-cookie headers from a terminal middleware response", async () => { + // Headers.forEach folds same-name headers; simulate that to keep the test + // runtime-independent while getSetCookie() still returns them split. + const foldingHeaders = { + forEach(fn: (value: string, key: string) => void) { + fn( + "appSession.0=AAA; HttpOnly; Path=/, appSession.1=BBB; HttpOnly; Path=/, appSession.2=CCC; HttpOnly; Path=/", + "set-cookie", + ); + fn("https://example.com/", "location"); + }, + get(name: string) { + if (name.toLowerCase() === "location") return "https://example.com/"; + return null; + }, + getSetCookie() { + return [ + "appSession.0=AAA; HttpOnly; Path=/", + "appSession.1=BBB; HttpOnly; Path=/", + "appSession.2=CCC; HttpOnly; Path=/", + ]; + }, + }; + + const event = createEvent({}); + middleware.mockResolvedValue({ status: 302, headers: foldingHeaders, body: null }); + + const result = await handleMiddleware(event, "", middlewareLoader); + + expect(result.headers["set-cookie"]).toEqual([ + "appSession.0=AAA; HttpOnly; Path=/", + "appSession.1=BBB; HttpOnly; Path=/", + "appSession.2=CCC; HttpOnly; Path=/", + ]); + expect(result.headers["location"]).toEqual("https://example.com/"); + }); + + it("should preserve multiple set-cookie headers when middleware returns next() with a rewrite", async () => { + const foldingHeaders = { + forEach(fn: (value: string, key: string) => void) { + fn( + "appSession.0=AAA; HttpOnly; Path=/, appSession.1=BBB; HttpOnly; Path=/", + "set-cookie", + ); + fn("http://localhost/rewrite", "x-middleware-rewrite"); + }, + get(name: string) { + if (name.toLowerCase() === "x-middleware-rewrite") + return "http://localhost/rewrite"; + return null; + }, + getSetCookie() { + return [ + "appSession.0=AAA; HttpOnly; Path=/", + "appSession.1=BBB; HttpOnly; Path=/", + ]; + }, + }; + + const event = createEvent({ headers: { host: "localhost" } }); + middleware.mockResolvedValue({ status: 200, headers: foldingHeaders, body: null }); + + const result = await handleMiddleware(event, "", middlewareLoader); + + expect(result.responseHeaders?.["set-cookie"]).toEqual([ + "appSession.0=AAA; HttpOnly; Path=/", + "appSession.1=BBB; HttpOnly; Path=/", + ]); + }); }); From 70a7119666fe7b4ad3a9c9ea75067ac15be0f751 Mon Sep 17 00:00:00 2001 From: "James D. Nurmi" Date: Fri, 26 Jun 2026 11:15:50 -0700 Subject: [PATCH 2/2] style: fix biome formatting and useLiteralKeys lint errors Co-Authored-By: Claude Sonnet 4.6 --- packages/open-next/src/core/routing/middleware.ts | 5 +---- .../tests-unit/tests/adapters/edge-adapter.test.ts | 4 +++- .../tests/core/routing/middleware.test.ts | 14 +++++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/open-next/src/core/routing/middleware.ts b/packages/open-next/src/core/routing/middleware.ts index 7a9166547..9719c67ca 100644 --- a/packages/open-next/src/core/routing/middleware.ts +++ b/packages/open-next/src/core/routing/middleware.ts @@ -132,10 +132,7 @@ export async function handleMiddleware( if (filteredHeaders.includes(key.toLowerCase())) return; // Headers.forEach folds same-name headers; use getSetCookie() below instead. if (key.toLowerCase() === "set-cookie") return; - if ( - REDIRECTS.has(statusCode) && - key.toLowerCase() === "location" - ) { + if (REDIRECTS.has(statusCode) && key.toLowerCase() === "location") { resHeaders[key] = normalizeLocationHeader(value, internalEvent.url); } else { resHeaders[key] = value; diff --git a/packages/tests-unit/tests/adapters/edge-adapter.test.ts b/packages/tests-unit/tests/adapters/edge-adapter.test.ts index 059bb77da..d04390b74 100644 --- a/packages/tests-unit/tests/adapters/edge-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/edge-adapter.test.ts @@ -46,7 +46,9 @@ describe("edge-adapter header harvest", () => { "appSession.1=BBB; HttpOnly; SameSite=Lax; Path=/", "appSession.2=CCC; HttpOnly; SameSite=Lax; Path=/", ]; - const out = harvestHeaders(makeFoldingHeaders(cookies, { "x-custom": "value" })); + const out = harvestHeaders( + makeFoldingHeaders(cookies, { "x-custom": "value" }), + ); expect(out["set-cookie"]).toEqual(cookies); }); diff --git a/packages/tests-unit/tests/core/routing/middleware.test.ts b/packages/tests-unit/tests/core/routing/middleware.test.ts index 9130b21a6..d1cf714a1 100644 --- a/packages/tests-unit/tests/core/routing/middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/middleware.test.ts @@ -389,7 +389,11 @@ describe("handleMiddleware", () => { }; const event = createEvent({}); - middleware.mockResolvedValue({ status: 302, headers: foldingHeaders, body: null }); + middleware.mockResolvedValue({ + status: 302, + headers: foldingHeaders, + body: null, + }); const result = await handleMiddleware(event, "", middlewareLoader); @@ -398,7 +402,7 @@ describe("handleMiddleware", () => { "appSession.1=BBB; HttpOnly; Path=/", "appSession.2=CCC; HttpOnly; Path=/", ]); - expect(result.headers["location"]).toEqual("https://example.com/"); + expect(result.headers.location).toEqual("https://example.com/"); }); it("should preserve multiple set-cookie headers when middleware returns next() with a rewrite", async () => { @@ -424,7 +428,11 @@ describe("handleMiddleware", () => { }; const event = createEvent({ headers: { host: "localhost" } }); - middleware.mockResolvedValue({ status: 200, headers: foldingHeaders, body: null }); + middleware.mockResolvedValue({ + status: 200, + headers: foldingHeaders, + body: null, + }); const result = await handleMiddleware(event, "", middlewareLoader);