Skip to content

Commit

Permalink
feat: improve cookie chunk handling via base64url+length encoding
Browse files Browse the repository at this point in the history
  • Loading branch information
hf committed Jan 31, 2025
1 parent ef429df commit af6e190
Show file tree
Hide file tree
Showing 9 changed files with 773 additions and 348 deletions.
258 changes: 246 additions & 12 deletions src/__snapshots__/createServerClient.spec.ts.snap

Large diffs are not rendered by default.

84 changes: 83 additions & 1 deletion src/cookies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { isBrowser, DEFAULT_COOKIE_OPTIONS, MAX_CHUNK_SIZE } from "./utils";
import { CookieOptions } from "./types";

import { createStorageFromOptions, applyServerStorage } from "./cookies";
import {
createStorageFromOptions,
applyServerStorage,
decodeCookie,
} from "./cookies";

describe("createStorageFromOptions in browser without cookie methods", () => {
beforeEach(() => {
Expand Down Expand Up @@ -1070,3 +1074,81 @@ describe("applyServerStorage", () => {
]);
});
});

describe("decodeCookie", () => {
let warnings: any[][] = [];
let originalWarn: any;

beforeEach(() => {
warnings = [];

originalWarn = console.warn;
console.warn = (...args: any[]) => {
warnings.push(structuredClone(args));
};
});

afterEach(() => {
console.warn = originalWarn;
});

it("should decode base64url+length encoded value", () => {
const value = JSON.stringify({ a: "b" });
const valueB64 = Buffer.from(value).toString("base64url");

expect(
decodeCookie(`base64l-${valueB64.length.toString(36)}-${valueB64}`),
).toEqual(value);
expect(
decodeCookie(
`base64l-${valueB64.length.toString(36)}-${valueB64}padding_that_is_ignored`,
),
).toEqual(value);
expect(
decodeCookie(
`base64l-${valueB64.length.toString(36)}-${valueB64.substring(0, valueB64.length - 1)}`,
),
).toBeNull();
expect(decodeCookie(`base64l-0-${valueB64}`)).toBeNull();
expect(decodeCookie(`base64l-${valueB64}`)).toBeNull();
expect(warnings).toMatchInlineSnapshot(`
[
[
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
],
[
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
],
]
`);
});

it("should decode base64url encoded value", () => {
const value = JSON.stringify({ a: "b" });
const valueB64 = Buffer.from(value).toString("base64url");

expect(decodeCookie(`base64-${valueB64}`)).toEqual(value);
expect(warnings).toMatchInlineSnapshot(`[]`);
});

it("should not decode base64url encoded value with invalid UTF-8", () => {
const valueB64 = Buffer.from([0xff, 0xff, 0xff, 0xff]).toString(
"base64url",
);

expect(decodeCookie(`base64-${valueB64}`)).toBeNull();
expect(
decodeCookie(`base64l-${valueB64.length.toString(36)}-${valueB64}`),
).toBeNull();
expect(warnings).toMatchInlineSnapshot(`
[
[
"@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.",
],
[
"@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.",
],
]
`);
});
});
90 changes: 67 additions & 23 deletions src/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,55 @@ import type {
} from "./types";

const BASE64_PREFIX = "base64-";
const BASE64_LENGTH_PREFIX = "base64l-";
const BASE64_LENGTH_PATTERN = /^base64l-([0-9a-z]+)-(.+)$/;

export function decodeBase64Cookie(value: string) {
try {
return stringFromBase64URL(value);
} catch (e: any) {
// if an invalid UTF-8 sequence is encountered, it means that reconstructing the chunkedCookie failed and the cookies don't contain useful information
console.warn(
"@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.",
);
return null;
}
}

export function decodeCookie(chunkedCookie: string) {
let decoded = chunkedCookie;

if (chunkedCookie.startsWith(BASE64_PREFIX)) {
return decodeBase64Cookie(decoded.substring(BASE64_PREFIX.length));
} else if (chunkedCookie.startsWith(BASE64_LENGTH_PREFIX)) {
const match = chunkedCookie.match(BASE64_LENGTH_PATTERN);

if (!match) {
return null;
}

const expectedLength = parseInt(match[1], 36);

if (expectedLength === 0) {
return null;
}

if (match[2].length !== expectedLength) {
console.warn(
"@supabase/ssr: Detected stale cookie data. Please check your integration with Supabase for bugs. This can cause your users to loose the session.",
);
}

if (expectedLength <= match[2].length) {
return decodeBase64Cookie(match[2].substring(0, expectedLength));
} else {
// data is missing, cannot decode cookie
return null;
}
}

return decoded;
}

/**
* Creates a storage client that handles cookies correctly for browser and
Expand All @@ -33,7 +82,7 @@ const BASE64_PREFIX = "base64-";
*/
export function createStorageFromOptions(
options: {
cookieEncoding: "raw" | "base64url";
cookieEncoding: "raw" | "base64url" | "base64url+length";
cookies?:
| CookieMethodsBrowser
| CookieMethodsBrowserDeprecated
Expand Down Expand Up @@ -203,15 +252,7 @@ export function createStorageFromOptions(
return null;
}

let decoded = chunkedCookie;

if (chunkedCookie.startsWith(BASE64_PREFIX)) {
decoded = stringFromBase64URL(
chunkedCookie.substring(BASE64_PREFIX.length),
);
}

return decoded;
return decodeCookie(chunkedCookie);
},
setItem: async (key: string, value: string) => {
const allCookies = await getAll([key]);
Expand All @@ -225,6 +266,13 @@ export function createStorageFromOptions(

if (cookieEncoding === "base64url") {
encoded = BASE64_PREFIX + stringToBase64URL(value);
} else if (cookieEncoding === "base64url+length") {
encoded = [
BASE64_LENGTH_PREFIX,
value.length.toString(36),
"-",
value,
].join("");
}

const setCookies = createChunks(key, encoded);
Expand Down Expand Up @@ -342,18 +390,7 @@ export function createStorageFromOptions(
return null;
}

let decoded = chunkedCookie;

if (
typeof chunkedCookie === "string" &&
chunkedCookie.startsWith(BASE64_PREFIX)
) {
decoded = stringFromBase64URL(
chunkedCookie.substring(BASE64_PREFIX.length),
);
}

return decoded;
return decodeCookie(chunkedCookie);
},
setItem: async (key: string, value: string) => {
// We don't have an `onAuthStateChange` event that can let us know that
Expand Down Expand Up @@ -411,7 +448,7 @@ export async function applyServerStorage(
removedItems: { [name: string]: boolean };
},
options: {
cookieEncoding: "raw" | "base64url";
cookieEncoding: "raw" | "base64url" | "base64url+length";
cookieOptions?: CookieOptions | null;
},
) {
Expand Down Expand Up @@ -439,6 +476,13 @@ export async function applyServerStorage(

if (cookieEncoding === "base64url") {
encoded = BASE64_PREFIX + stringToBase64URL(encoded);
} else if (cookieEncoding === "base64url+length") {
encoded = [
BASE64_LENGTH_PREFIX,
encoded.length.toString(36),
"-",
stringToBase64URL(encoded),
].join("");
}

const chunks = createChunks(itemName, encoded);
Expand Down
2 changes: 1 addition & 1 deletion src/createBrowserClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MAX_CHUNK_SIZE, stringToBase64URL } from "./utils";
import { CookieOptions } from "./types";
import { createBrowserClient } from "./createBrowserClient";

describe("createServerClient", () => {
describe("createBrowserClient", () => {
describe("validation", () => {
it("should throw an error on empty URL and anon key", async () => {
expect(() => {
Expand Down
8 changes: 4 additions & 4 deletions src/createBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function createBrowserClient<
options?: SupabaseClientOptions<SchemaName> & {
cookies?: CookieMethodsBrowser;
cookieOptions?: CookieOptionsWithName;
cookieEncoding?: "raw" | "base64url";
cookieEncoding?: "raw" | "base64url" | "base64url+length";
isSingleton?: boolean;
},
): SupabaseClient<Database, SchemaName, Schema>;
Expand All @@ -72,7 +72,7 @@ export function createBrowserClient<
options?: SupabaseClientOptions<SchemaName> & {
cookies: CookieMethodsBrowserDeprecated;
cookieOptions?: CookieOptionsWithName;
cookieEncoding?: "raw" | "base64url";
cookieEncoding?: "raw" | "base64url" | "base64url+length";
isSingleton?: boolean;
},
): SupabaseClient<Database, SchemaName, Schema>;
Expand All @@ -91,7 +91,7 @@ export function createBrowserClient<
options?: SupabaseClientOptions<SchemaName> & {
cookies?: CookieMethodsBrowser | CookieMethodsBrowserDeprecated;
cookieOptions?: CookieOptionsWithName;
cookieEncoding?: "raw" | "base64url";
cookieEncoding?: "raw" | "base64url" | "base64url+length";
isSingleton?: boolean;
},
): SupabaseClient<Database, SchemaName, Schema> {
Expand All @@ -113,7 +113,7 @@ export function createBrowserClient<
const { storage } = createStorageFromOptions(
{
...options,
cookieEncoding: options?.cookieEncoding ?? "base64url",
cookieEncoding: options?.cookieEncoding ?? "base64url+length",
},
false,
);
Expand Down
Loading

0 comments on commit af6e190

Please sign in to comment.