Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/famous-donkeys-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

fix(react-router): Ensure custom cookie transformers are used exclusively
75 changes: 75 additions & 0 deletions packages/react-router/__tests__/server-runtime/cookies-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
39 changes: 26 additions & 13 deletions packages/react-router/lib/server-runtime/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand All @@ -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,
});
},
};
};
Expand All @@ -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<string> {
let encoded = encodeData(value);
let encodeFn = encode || encodeData;
let encoded = encodeFn(value);

if (secrets.length > 0) {
encoded = await sign(encoded, secrets[0]);
Expand All @@ -156,19 +167,21 @@ async function encodeCookieValue(
async function decodeCookieValue(
value: string,
secrets: string[],
decode: ((value: string) => any) | undefined,
): Promise<any> {
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 {
Expand Down