Skip to content

Commit 6deb687

Browse files
authored
feat: improve cookie chunk handling via base64url+length encoding (#90)
Improves cookie chunk handling by introducing a new cookie encoding scheme that includes the length of the encoded Base64 value. It will prevent reconstructing data from stale cookies. Due to bad uses of this package, some cookie chunks are not being properly deleted. Meaning that if a session was encoded in 3 chunks now suddenly goes down to 2 chunks, the last chunk is not being deleted. When it gets reconstructed, all the 3 chunks get concatenated and parsed. In some situations this leads to an invalid UTF-8 sequence (mainly because Base64 packs 6 bits into 8). This PR addresses this by implementing a different Base64 encoding of the chunks. Instead of just splitting up a Base64 string into chunks, the first chunk will now contain the length of the string that follows. This will prevent a leftover chunk from being parsed as valid. The encoding is as follows: ```base64l-<length of base64 encoded string as base 36>-<base64 encoding>``` The library now checks for these conditions and emits warnings to let the developer know that they have a bug in their integration.
1 parent ef429df commit 6deb687

File tree

9 files changed

+1007
-348
lines changed

9 files changed

+1007
-348
lines changed

src/__snapshots__/createServerClient.spec.ts.snap

Lines changed: 480 additions & 12 deletions
Large diffs are not rendered by default.

src/cookies.spec.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest";
33
import { isBrowser, DEFAULT_COOKIE_OPTIONS, MAX_CHUNK_SIZE } from "./utils";
44
import { CookieOptions } from "./types";
55

6-
import { createStorageFromOptions, applyServerStorage } from "./cookies";
6+
import {
7+
createStorageFromOptions,
8+
applyServerStorage,
9+
decodeCookie,
10+
} from "./cookies";
711

812
describe("createStorageFromOptions in browser without cookie methods", () => {
913
beforeEach(() => {
@@ -1070,3 +1074,81 @@ describe("applyServerStorage", () => {
10701074
]);
10711075
});
10721076
});
1077+
1078+
describe("decodeCookie", () => {
1079+
let warnings: any[][] = [];
1080+
let originalWarn: any;
1081+
1082+
beforeEach(() => {
1083+
warnings = [];
1084+
1085+
originalWarn = console.warn;
1086+
console.warn = (...args: any[]) => {
1087+
warnings.push(structuredClone(args));
1088+
};
1089+
});
1090+
1091+
afterEach(() => {
1092+
console.warn = originalWarn;
1093+
});
1094+
1095+
it("should decode base64url+length encoded value", () => {
1096+
const value = JSON.stringify({ a: "b" });
1097+
const valueB64 = Buffer.from(value).toString("base64url");
1098+
1099+
expect(
1100+
decodeCookie(`base64l-${valueB64.length.toString(36)}-${valueB64}`),
1101+
).toEqual(value);
1102+
expect(
1103+
decodeCookie(
1104+
`base64l-${valueB64.length.toString(36)}-${valueB64}padding_that_is_ignored`,
1105+
),
1106+
).toEqual(value);
1107+
expect(
1108+
decodeCookie(
1109+
`base64l-${valueB64.length.toString(36)}-${valueB64.substring(0, valueB64.length - 1)}`,
1110+
),
1111+
).toBeNull();
1112+
expect(decodeCookie(`base64l-0-${valueB64}`)).toBeNull();
1113+
expect(decodeCookie(`base64l-${valueB64}`)).toBeNull();
1114+
expect(warnings).toMatchInlineSnapshot(`
1115+
[
1116+
[
1117+
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
1118+
],
1119+
[
1120+
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
1121+
],
1122+
]
1123+
`);
1124+
});
1125+
1126+
it("should decode base64url encoded value", () => {
1127+
const value = JSON.stringify({ a: "b" });
1128+
const valueB64 = Buffer.from(value).toString("base64url");
1129+
1130+
expect(decodeCookie(`base64-${valueB64}`)).toEqual(value);
1131+
expect(warnings).toMatchInlineSnapshot(`[]`);
1132+
});
1133+
1134+
it("should not decode base64url encoded value with invalid UTF-8", () => {
1135+
const valueB64 = Buffer.from([0xff, 0xff, 0xff, 0xff]).toString(
1136+
"base64url",
1137+
);
1138+
1139+
expect(decodeCookie(`base64-${valueB64}`)).toBeNull();
1140+
expect(
1141+
decodeCookie(`base64l-${valueB64.length.toString(36)}-${valueB64}`),
1142+
).toBeNull();
1143+
expect(warnings).toMatchInlineSnapshot(`
1144+
[
1145+
[
1146+
"@supabase/ssr: Detected stale cookie data that does not decode to a UTF-8 string. Please check your integration with Supabase for bugs. This can cause your users to loose session access.",
1147+
],
1148+
[
1149+
"@supabase/ssr: Detected stale cookie data that does not decode to a UTF-8 string. Please check your integration with Supabase for bugs. This can cause your users to loose session access.",
1150+
],
1151+
]
1152+
`);
1153+
});
1154+
});

src/cookies.ts

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,55 @@ import type {
2222
} from "./types";
2323

2424
const BASE64_PREFIX = "base64-";
25+
const BASE64_LENGTH_PREFIX = "base64l-";
26+
const BASE64_LENGTH_PATTERN = /^base64l-([0-9a-z]+)-(.+)$/;
27+
28+
export function decodeBase64Cookie(value: string) {
29+
try {
30+
return stringFromBase64URL(value);
31+
} catch (e: any) {
32+
// if an invalid UTF-8 sequence is encountered, it means that reconstructing the chunkedCookie failed and the cookies don't contain useful information
33+
console.warn(
34+
"@supabase/ssr: Detected stale cookie data that does not decode to a UTF-8 string. Please check your integration with Supabase for bugs. This can cause your users to loose session access.",
35+
);
36+
return null;
37+
}
38+
}
39+
40+
export function decodeCookie(chunkedCookie: string) {
41+
let decoded = chunkedCookie;
42+
43+
if (chunkedCookie.startsWith(BASE64_PREFIX)) {
44+
return decodeBase64Cookie(decoded.substring(BASE64_PREFIX.length));
45+
} else if (chunkedCookie.startsWith(BASE64_LENGTH_PREFIX)) {
46+
const match = chunkedCookie.match(BASE64_LENGTH_PATTERN);
47+
48+
if (!match) {
49+
return null;
50+
}
51+
52+
const expectedLength = parseInt(match[1], 36);
53+
54+
if (expectedLength === 0) {
55+
return null;
56+
}
57+
58+
if (match[2].length !== expectedLength) {
59+
console.warn(
60+
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
61+
);
62+
}
63+
64+
if (expectedLength <= match[2].length) {
65+
return decodeBase64Cookie(match[2].substring(0, expectedLength));
66+
} else {
67+
// data is missing, cannot decode cookie
68+
return null;
69+
}
70+
}
71+
72+
return decoded;
73+
}
2574

2675
/**
2776
* Creates a storage client that handles cookies correctly for browser and
@@ -33,7 +82,7 @@ const BASE64_PREFIX = "base64-";
3382
*/
3483
export function createStorageFromOptions(
3584
options: {
36-
cookieEncoding: "raw" | "base64url";
85+
cookieEncoding: "raw" | "base64url" | "base64url+length";
3786
cookies?:
3887
| CookieMethodsBrowser
3988
| CookieMethodsBrowserDeprecated
@@ -203,15 +252,7 @@ export function createStorageFromOptions(
203252
return null;
204253
}
205254

206-
let decoded = chunkedCookie;
207-
208-
if (chunkedCookie.startsWith(BASE64_PREFIX)) {
209-
decoded = stringFromBase64URL(
210-
chunkedCookie.substring(BASE64_PREFIX.length),
211-
);
212-
}
213-
214-
return decoded;
255+
return decodeCookie(chunkedCookie);
215256
},
216257
setItem: async (key: string, value: string) => {
217258
const allCookies = await getAll([key]);
@@ -225,6 +266,13 @@ export function createStorageFromOptions(
225266

226267
if (cookieEncoding === "base64url") {
227268
encoded = BASE64_PREFIX + stringToBase64URL(value);
269+
} else if (cookieEncoding === "base64url+length") {
270+
encoded = [
271+
BASE64_LENGTH_PREFIX,
272+
value.length.toString(36),
273+
"-",
274+
value,
275+
].join("");
228276
}
229277

230278
const setCookies = createChunks(key, encoded);
@@ -342,18 +390,7 @@ export function createStorageFromOptions(
342390
return null;
343391
}
344392

345-
let decoded = chunkedCookie;
346-
347-
if (
348-
typeof chunkedCookie === "string" &&
349-
chunkedCookie.startsWith(BASE64_PREFIX)
350-
) {
351-
decoded = stringFromBase64URL(
352-
chunkedCookie.substring(BASE64_PREFIX.length),
353-
);
354-
}
355-
356-
return decoded;
393+
return decodeCookie(chunkedCookie);
357394
},
358395
setItem: async (key: string, value: string) => {
359396
// We don't have an `onAuthStateChange` event that can let us know that
@@ -411,7 +448,7 @@ export async function applyServerStorage(
411448
removedItems: { [name: string]: boolean };
412449
},
413450
options: {
414-
cookieEncoding: "raw" | "base64url";
451+
cookieEncoding: "raw" | "base64url" | "base64url+length";
415452
cookieOptions?: CookieOptions | null;
416453
},
417454
) {
@@ -439,6 +476,13 @@ export async function applyServerStorage(
439476

440477
if (cookieEncoding === "base64url") {
441478
encoded = BASE64_PREFIX + stringToBase64URL(encoded);
479+
} else if (cookieEncoding === "base64url+length") {
480+
encoded = [
481+
BASE64_LENGTH_PREFIX,
482+
encoded.length.toString(36),
483+
"-",
484+
stringToBase64URL(encoded),
485+
].join("");
442486
}
443487

444488
const chunks = createChunks(itemName, encoded);

src/createBrowserClient.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils";
44
import { CookieOptions } from "./types";
55
import { createBrowserClient } from "./createBrowserClient";
66

7-
describe("createServerClient", () => {
7+
describe("createBrowserClient", () => {
88
describe("validation", () => {
99
it("should throw an error on empty URL and anon key", async () => {
1010
expect(() => {

src/createBrowserClient.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function createBrowserClient<
4848
options?: SupabaseClientOptions<SchemaName> & {
4949
cookies?: CookieMethodsBrowser;
5050
cookieOptions?: CookieOptionsWithName;
51-
cookieEncoding?: "raw" | "base64url";
51+
cookieEncoding?: "raw" | "base64url" | "base64url+length";
5252
isSingleton?: boolean;
5353
},
5454
): SupabaseClient<Database, SchemaName, Schema>;
@@ -72,7 +72,7 @@ export function createBrowserClient<
7272
options?: SupabaseClientOptions<SchemaName> & {
7373
cookies: CookieMethodsBrowserDeprecated;
7474
cookieOptions?: CookieOptionsWithName;
75-
cookieEncoding?: "raw" | "base64url";
75+
cookieEncoding?: "raw" | "base64url" | "base64url+length";
7676
isSingleton?: boolean;
7777
},
7878
): SupabaseClient<Database, SchemaName, Schema>;
@@ -91,7 +91,7 @@ export function createBrowserClient<
9191
options?: SupabaseClientOptions<SchemaName> & {
9292
cookies?: CookieMethodsBrowser | CookieMethodsBrowserDeprecated;
9393
cookieOptions?: CookieOptionsWithName;
94-
cookieEncoding?: "raw" | "base64url";
94+
cookieEncoding?: "raw" | "base64url" | "base64url+length";
9595
isSingleton?: boolean;
9696
},
9797
): SupabaseClient<Database, SchemaName, Schema> {
@@ -113,7 +113,7 @@ export function createBrowserClient<
113113
const { storage } = createStorageFromOptions(
114114
{
115115
...options,
116-
cookieEncoding: options?.cookieEncoding ?? "base64url",
116+
cookieEncoding: options?.cookieEncoding ?? "base64url+length",
117117
},
118118
false,
119119
);

0 commit comments

Comments
 (0)