diff --git a/.changeset/famous-donkeys-poke.md b/.changeset/famous-donkeys-poke.md new file mode 100644 index 0000000000..ce2996e902 --- /dev/null +++ b/.changeset/famous-donkeys-poke.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +fix(react-router): Ensure custom cookie transformers are used exclusively diff --git a/packages/react-router/__tests__/server-runtime/cookies-test.ts b/packages/react-router/__tests__/server-runtime/cookies-test.ts index 18edc90bc4..6127282765 100644 --- a/packages/react-router/__tests__/server-runtime/cookies-test.ts +++ b/packages/react-router/__tests__/server-runtime/cookies-test.ts @@ -196,6 +196,81 @@ describe("cookies", () => { ); }); }); + + describe("custom encoding/decoding", () => { + it("uses default base64 encoding when no functions are provided", async () => { + let rawCookieValue = "hello world"; + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize(rawCookieValue); + expect(setCookie).toContain("my-cookie=ImhlbGxvIHdvcmxkIg%3D%3D;"); + let parsed = await cookie.parse(getCookieFromSetCookie(setCookie)); + expect(parsed).toBe(rawCookieValue); + }); + + it("uses custom implementations when provided at initialization", async () => { + let rawCookieValue = "hello world"; + let cookie = createCookie("my-cookie", { + encode(str: string) { + expect(str).toBe(rawCookieValue); // not encoded yet + return encodeURIComponent(str.toUpperCase()); + }, + decode(str: string) { + expect(str).toBe("HELLO%20WORLD"); + return decodeURIComponent(str.toLowerCase()); + }, + }); + let setCookie = await cookie.serialize(rawCookieValue); + expect(setCookie).toContain("my-cookie=HELLO%20WORLD;"); + let parsed = await cookie.parse(getCookieFromSetCookie(setCookie)); + expect(parsed).toBe(rawCookieValue); + }); + + it("uses custom implementations when provided at usage time", async () => { + let rawCookieValue = "hello world"; + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize(rawCookieValue, { + encode(str: string) { + expect(str).toBe(rawCookieValue); // not encoded yet + return encodeURIComponent(str.toUpperCase()); + }, + }); + expect(setCookie).toContain("my-cookie=HELLO%20WORLD;"); + let parsed = await cookie.parse(getCookieFromSetCookie(setCookie), { + decode(str: string) { + expect(str).toBe("HELLO%20WORLD"); + return decodeURIComponent(str.toLowerCase()); + }, + }); + expect(parsed).toBe(rawCookieValue); + }); + + it("uses custom implementations when using signed cookies", async () => { + let rawCookieValue = "hello world"; + let cookie = createCookie("my-cookie", { + secrets: ["s3cr3t"], + encode(str: string) { + expect(str).toBe(rawCookieValue); // not encoded yet + return encodeURIComponent(str.toUpperCase()); + }, + decode(str: string) { + expect(str).toBe("HELLO%20WORLD"); + return decodeURIComponent(str.toLowerCase()); + }, + }); + let setCookie = await cookie.serialize(rawCookieValue); + expect(setCookie).toContain( + "my-cookie=HELLO%20WORLD.4bKWgOIqYxcP3KMCHWBmoKEQth3NPQ9yrTRurGMgS40;", + ); + let parsed = await cookie.parse(getCookieFromSetCookie(setCookie)); + expect(parsed).toBe(rawCookieValue); + + // Fails if the cookie value is tampered with + parsed = await cookie.parse( + "my-cookie=HELLO%20MARS.4bKWgOIqYxcP3KMCHWBmoKEQth3NPQ9yrTRurGMgS40", + ); + expect(parsed).toBe(null); + }); + }); }); function spyConsole() { diff --git a/packages/react-router/lib/server-runtime/cookies.ts b/packages/react-router/lib/server-runtime/cookies.ts index 42ffeab6d6..b76fbe0738 100644 --- a/packages/react-router/lib/server-runtime/cookies.ts +++ b/packages/react-router/lib/server-runtime/cookies.ts @@ -97,11 +97,17 @@ export const createCookie = ( }, async parse(cookieHeader, parseOptions) { if (!cookieHeader) return null; - let cookies = parse(cookieHeader, { ...options, ...parseOptions }); + let opts = { ...options, ...parseOptions }; + let cookies = parse(cookieHeader, { + ...opts, + // If they provided a custom decode function, skip internal decoding by + // passing an identity function here + decode: opts.decode ? (v) => v : undefined, + }); if (name in cookies) { let value = cookies[name]; if (typeof value === "string" && value !== "") { - let decoded = await decodeCookieValue(value, secrets); + let decoded = await decodeCookieValue(value, secrets, opts.decode); return decoded; } else { return ""; @@ -111,14 +117,17 @@ export const createCookie = ( } }, async serialize(value, serializeOptions) { - return serialize( - name, - value === "" ? "" : await encodeCookieValue(value, secrets), - { - ...options, - ...serializeOptions, - }, - ); + let opts = { ...options, ...serializeOptions }; + let encoded = + value === "" + ? "" + : await encodeCookieValue(value, secrets, opts.encode); + return serialize(name, encoded, { + ...opts, + // If they provided a custom encode function, skip internal encoding by + // passing an identity function here + encode: opts.encode ? (v) => v : undefined, + }); }, }; }; @@ -143,8 +152,10 @@ export const isCookie: IsCookieFunction = (object): object is Cookie => { async function encodeCookieValue( value: any, secrets: string[], + encode: ((value: string) => string) | undefined, ): Promise { - let encoded = encodeData(value); + let encodeFn = encode || encodeData; + let encoded = encodeFn(value); if (secrets.length > 0) { encoded = await sign(encoded, secrets[0]); @@ -156,19 +167,21 @@ async function encodeCookieValue( async function decodeCookieValue( value: string, secrets: string[], + decode: ((value: string) => any) | undefined, ): Promise { + let decodeFn = decode ?? decodeData; if (secrets.length > 0) { for (let secret of secrets) { let unsignedValue = await unsign(value, secret); if (unsignedValue !== false) { - return decodeData(unsignedValue); + return decodeFn(unsignedValue); } } return null; } - return decodeData(value); + return decodeFn(value); } function encodeData(value: any): string {