From 6e34ab0bdb5b1983e30f83d252ed8501fccb9b71 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 2 Jun 2025 18:58:05 +0200 Subject: [PATCH 1/4] fix: cookie decoding for Node and Cloudflare --- .changeset/hot-chicken-ring.md | 5 +++++ packages/open-next/package.json | 1 + .../open-next/src/http/openNextResponse.ts | 4 ++-- packages/open-next/src/http/util.ts | 8 +++++++- .../src/overrides/converters/aws-apigw-v2.ts | 4 ++-- .../overrides/converters/aws-cloudfront.ts | 12 ++++++----- .../src/overrides/converters/edge.ts | 15 +++++++------- .../src/overrides/converters/node.ts | 15 +++++++------- .../tests-unit/tests/converters/node.test.ts | 4 ++-- packages/tests-unit/tests/http/utils.test.ts | 20 +++++++++---------- pnpm-lock.yaml | 9 +++++++++ 11 files changed, 61 insertions(+), 36 deletions(-) create mode 100644 .changeset/hot-chicken-ring.md diff --git a/.changeset/hot-chicken-ring.md b/.changeset/hot-chicken-ring.md new file mode 100644 index 000000000..b08266350 --- /dev/null +++ b/.changeset/hot-chicken-ring.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +fix cookie decoding for Node and Cloudflare diff --git a/packages/open-next/package.json b/packages/open-next/package.json index efcf3edf5..7bc065f65 100644 --- a/packages/open-next/package.json +++ b/packages/open-next/package.json @@ -48,6 +48,7 @@ "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", + "cookie": "^1.0.2", "esbuild": "catalog:", "express": "5.0.1", "path-to-regexp": "^6.3.0", diff --git a/packages/open-next/src/http/openNextResponse.ts b/packages/open-next/src/http/openNextResponse.ts index 5db512ebf..5f2b7a54d 100644 --- a/packages/open-next/src/http/openNextResponse.ts +++ b/packages/open-next/src/http/openNextResponse.ts @@ -10,7 +10,7 @@ import { Transform } from "node:stream"; import type { StreamCreator } from "types/open-next"; import { debug } from "../adapters/logger"; -import { parseCookies, parseHeaders } from "./util"; +import { parseHeaders, parseSetCookieHeader } from "./util"; const SET_COOKIE_HEADER = "set-cookie"; const CANNOT_BE_USED = "This cannot be used in OpenNext"; @@ -152,7 +152,7 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse { ...this.initialHeaders, ...this.headers, }; - const initialCookies = parseCookies( + const initialCookies = parseSetCookieHeader( this.initialHeaders[SET_COOKIE_HEADER]?.toString(), ); this._cookies = diff --git a/packages/open-next/src/http/util.ts b/packages/open-next/src/http/util.ts index af5bb2738..950837b0e 100644 --- a/packages/open-next/src/http/util.ts +++ b/packages/open-next/src/http/util.ts @@ -28,7 +28,13 @@ export const convertHeader = (header: http.OutgoingHttpHeader) => { return String(header); }; -export function parseCookies( +/** + * Parses a (coma-separated) list of Set-Cookie headers + * + * @param cookies A coma-separated list of Set-Cookie headers or a list of Set-Cookie header + * @returns A list of Set-Cookie header + */ +export function parseSetCookieHeader( cookies: string | string[] | null | undefined, ): string[] { if (!cookies) { diff --git a/packages/open-next/src/overrides/converters/aws-apigw-v2.ts b/packages/open-next/src/overrides/converters/aws-apigw-v2.ts index 61362a08f..15cf8199d 100644 --- a/packages/open-next/src/overrides/converters/aws-apigw-v2.ts +++ b/packages/open-next/src/overrides/converters/aws-apigw-v2.ts @@ -2,7 +2,7 @@ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, } from "aws-lambda"; -import { parseCookies } from "http/util"; +import { parseSetCookieHeader } from "http/util"; import type { InternalEvent, InternalResult } from "types/open-next"; import type { Converter } from "types/overrides"; import { fromReadableStream } from "utils/stream"; @@ -122,7 +122,7 @@ async function convertToApiGatewayProxyResultV2( const response: APIGatewayProxyResultV2 = { statusCode: result.statusCode, headers, - cookies: parseCookies(result.headers["set-cookie"]), + cookies: parseSetCookieHeader(result.headers["set-cookie"]), body, isBase64Encoded: result.isBase64Encoded, }; diff --git a/packages/open-next/src/overrides/converters/aws-cloudfront.ts b/packages/open-next/src/overrides/converters/aws-cloudfront.ts index 35079d627..322f0071e 100644 --- a/packages/open-next/src/overrides/converters/aws-cloudfront.ts +++ b/packages/open-next/src/overrides/converters/aws-cloudfront.ts @@ -7,7 +7,7 @@ import type { CloudFrontRequestEvent, CloudFrontRequestResult, } from "aws-lambda"; -import { parseCookies } from "http/util"; +import { parseSetCookieHeader } from "http/util"; import type { InternalEvent, InternalResult, @@ -133,10 +133,12 @@ function convertToCloudfrontHeaders( ) .forEach(([key, value]) => { if (key === "set-cookie") { - cloudfrontHeaders[key] = parseCookies(`${value}`).map((cookie) => ({ - key, - value: cookie, - })); + cloudfrontHeaders[key] = parseSetCookieHeader(`${value}`).map( + (cookie) => ({ + key, + value: cookie, + }), + ); return; } diff --git a/packages/open-next/src/overrides/converters/edge.ts b/packages/open-next/src/overrides/converters/edge.ts index 1dc531a81..7cd6d8aed 100644 --- a/packages/open-next/src/overrides/converters/edge.ts +++ b/packages/open-next/src/overrides/converters/edge.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; -import { parseCookies } from "http/util"; +import cookieParser from "cookie"; +import { parseSetCookieHeader } from "http/util"; import type { InternalEvent, InternalResult, @@ -29,11 +30,11 @@ const converter: Converter = { const rawPath = url.pathname; const method = event.method; const shouldHaveBody = method !== "GET" && method !== "HEAD"; - const cookies: Record = Object.fromEntries( - parseCookies(event.headers.get("cookie")).map((cookie) => - cookie.split("="), - ), - ); + + const cookieHeader = event.headers.get("cookie"); + const cookies = cookieHeader + ? (cookieParser.parse(cookieHeader) as Record) + : {}; return { type: "core", @@ -81,7 +82,7 @@ const converter: Converter = { if (key === "set-cookie" && typeof value === "string") { // If the value is a string, we need to parse it into an array // This is the case for middleware direct result - const cookies = parseCookies(value); + const cookies = parseSetCookieHeader(value); for (const cookie of cookies) { headers.append(key, cookie); } diff --git a/packages/open-next/src/overrides/converters/node.ts b/packages/open-next/src/overrides/converters/node.ts index 6271a5a9e..82f784191 100644 --- a/packages/open-next/src/overrides/converters/node.ts +++ b/packages/open-next/src/overrides/converters/node.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from "node:http"; -import { parseCookies } from "http/util"; +import cookieParser from "cookie"; import type { InternalResult } from "types/open-next"; import type { Converter } from "types/overrides"; import { extractHostFromHeaders, getQueryFromSearchParams } from "./utils.js"; @@ -30,6 +30,12 @@ const converter: Converter = { `${req.protocol ? req.protocol : "http"}://${extractHostFromHeaders(headers)}${req.url}`, ); const query = getQueryFromSearchParams(url.searchParams); + + const cookieHeader = req.headers.cookie; + const cookies = cookieHeader + ? (cookieParser.parse(cookieHeader) as Record) + : {}; + return { type: "core", method: req.method ?? "GET", @@ -42,12 +48,7 @@ const converter: Converter = { req.socket.remoteAddress ?? "::1", query, - cookies: Object.fromEntries( - parseCookies(req.headers.cookie)?.map((cookie) => { - const [key, value] = cookie.split("="); - return [key, value]; - }) ?? [], - ), + cookies, }; }, // Nothing to do here, it's streaming diff --git a/packages/tests-unit/tests/converters/node.test.ts b/packages/tests-unit/tests/converters/node.test.ts index c4c35d0f1..3f82057b6 100644 --- a/packages/tests-unit/tests/converters/node.test.ts +++ b/packages/tests-unit/tests/converters/node.test.ts @@ -186,7 +186,7 @@ describe("convertFrom", () => { headers: { "content-length": "2", "content-type": "application/json", - cookie: ["foo=bar", "hello=world"], + cookie: "foo=bar; hello=world", }, remoteAddress: "::1", body: Buffer.from("{}"), @@ -201,7 +201,7 @@ describe("convertFrom", () => { headers: { "content-length": "2", "content-type": "application/json", - cookie: "foo=bar,hello=world", + cookie: "foo=bar; hello=world", }, remoteAddress: "::1", body: Buffer.from("{}"), diff --git a/packages/tests-unit/tests/http/utils.test.ts b/packages/tests-unit/tests/http/utils.test.ts index 3377269cd..635f1d742 100644 --- a/packages/tests-unit/tests/http/utils.test.ts +++ b/packages/tests-unit/tests/http/utils.test.ts @@ -1,14 +1,14 @@ -import { parseCookies } from "@opennextjs/aws/http/util.js"; +import { parseSetCookieHeader } from "@opennextjs/aws/http/util.js"; -describe("parseCookies", () => { +describe("parseSetCookieHeader", () => { it("returns an empty list if cookies is emptyish", () => { - expect(parseCookies("")).toEqual([]); - expect(parseCookies(null)).toEqual([]); - expect(parseCookies(undefined)).toEqual([]); - expect(parseCookies([])).toEqual([]); + expect(parseSetCookieHeader("")).toEqual([]); + expect(parseSetCookieHeader(null)).toEqual([]); + expect(parseSetCookieHeader(undefined)).toEqual([]); + expect(parseSetCookieHeader([])).toEqual([]); }); it("parse single cookie", () => { - const cookies = parseCookies( + const cookies = parseSetCookieHeader( "cookie1=value1; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; Path=/", ); expect(cookies).toEqual([ @@ -17,7 +17,7 @@ describe("parseCookies", () => { }); it("parse multiple cookies", () => { // NOTE: expires is lower case but still works - const cookies = parseCookies( + const cookies = parseSetCookieHeader( "cookie1=value1; expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; Path=/, cookie2=value2; HttpOnly; Secure", ); expect(cookies).toEqual([ @@ -26,7 +26,7 @@ describe("parseCookies", () => { ]); }); it("return if cookies is already an array", () => { - const cookies = parseCookies([ + const cookies = parseSetCookieHeader([ "cookie1=value1; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; Path=/", ]); expect(cookies).toEqual([ @@ -34,7 +34,7 @@ describe("parseCookies", () => { ]); }); it("parses w/o Expire", () => { - const cookies = parseCookies( + const cookies = parseSetCookieHeader( "cookie1=value1; HttpOnly; Secure; Path=/, cookie2=value2; HttpOnly=false; Secure=True; Domain=example.com; Path=/api", ); expect(cookies).toEqual([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba7bc9201..8c9f6da07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -265,6 +265,9 @@ importers: chalk: specifier: ^5.3.0 version: 5.3.0 + cookie: + specifier: ^1.0.2 + version: 1.0.2 esbuild: specifier: 'catalog:' version: 0.25.4 @@ -3290,6 +3293,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -10012,6 +10019,8 @@ snapshots: cookie@0.7.1: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} From 2eff78b2a5ea56593f48680734b9a025f57579b9 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 2 Jun 2025 19:31:42 +0200 Subject: [PATCH 2/4] Update packages/open-next/src/http/util.ts --- packages/open-next/src/http/util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/open-next/src/http/util.ts b/packages/open-next/src/http/util.ts index 950837b0e..6a382feb8 100644 --- a/packages/open-next/src/http/util.ts +++ b/packages/open-next/src/http/util.ts @@ -29,9 +29,9 @@ export const convertHeader = (header: http.OutgoingHttpHeader) => { }; /** - * Parses a (coma-separated) list of Set-Cookie headers + * Parses a (comma-separated) list of Set-Cookie headers * - * @param cookies A coma-separated list of Set-Cookie headers or a list of Set-Cookie header + * @param cookies A comma-separated list of Set-Cookie headers or a list of Set-Cookie header * @returns A list of Set-Cookie header */ export function parseSetCookieHeader( From f70da90cfbffdebe1988d116f4fb9c27c077579c Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 2 Jun 2025 19:32:05 +0200 Subject: [PATCH 3/4] Update packages/open-next/src/http/util.ts --- packages/open-next/src/http/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/open-next/src/http/util.ts b/packages/open-next/src/http/util.ts index 6a382feb8..f98a484b6 100644 --- a/packages/open-next/src/http/util.ts +++ b/packages/open-next/src/http/util.ts @@ -31,7 +31,7 @@ export const convertHeader = (header: http.OutgoingHttpHeader) => { /** * Parses a (comma-separated) list of Set-Cookie headers * - * @param cookies A comma-separated list of Set-Cookie headers or a list of Set-Cookie header + * @param cookies A comma-separated list of Set-Cookie headers or a list of Set-Cookie headers * @returns A list of Set-Cookie header */ export function parseSetCookieHeader( From b2464a7d92bd6481892244e3e893aebc4f3abc89 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 2 Jun 2025 20:44:44 +0200 Subject: [PATCH 4/4] fixup! comment --- packages/open-next/src/http/util.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/open-next/src/http/util.ts b/packages/open-next/src/http/util.ts index f98a484b6..d5b29768d 100644 --- a/packages/open-next/src/http/util.ts +++ b/packages/open-next/src/http/util.ts @@ -41,9 +41,14 @@ export function parseSetCookieHeader( return []; } - return typeof cookies === "string" - ? cookies.split(/(? c.trim()) - : cookies; + if (typeof cookies === "string") { + // Split the cookie string on ",". + // Note that "," can also appear in the Expires value (i.e. `Expires=Thu, 01 June`) + // so we have to skip it with a negative lookbehind. + return cookies.split(/(? c.trim()); + } + + return cookies; } /**