From 09bfef2822bfb8fd0b232fb1476fa6c120ff09f9 Mon Sep 17 00:00:00 2001 From: Theo Zourzouvillys Date: Thu, 6 Aug 2026 17:30:36 -0800 Subject: [PATCH] fix(clerk-js): discard a stored session token that was never a mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateToken` documented that a value which could not have come from a mint of ours is discarded, but only checked type, length and expiry — so a corrupt or truncated store entry counted as fresh and suppressed acquisition until it expired, up to the lifetime ceiling. This is hygiene, not a security boundary, and is deliberately not framed as one: only the backend can tell a real token from a well-formed forgery, and anything that can write the store can send the same values to the API directly. What it buys is that a broken entry starts a fresh run immediately. The shape is matched version-agnostically. Pinning it to the current version would mean an SDK rejecting a token the backend had minted ahead of it, and re-running the loader on every page load until the SDK caught up — a test guards against that tightening. --- .../src/core/__tests__/protectSession.test.ts | 33 +++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 12 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 5b8bd466785..8076e9c95a3 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -383,6 +383,39 @@ describe('ProtectSession inline token', () => { await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); }); + it('ignores a planted value that could never have been a mint', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'not-a-token', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }), + ); + + const { session: created, injected } = session([loader()]); + // Shape alone proves nothing — only the server can tell a mint from a well-formed forgery — + // but a corrupt entry must start a fresh run rather than suppress acquisition until it expires. + expect(created?.hasFreshToken()).toBe(false); + + created?.start(); + serveInline(await injected(), { cid: created?.placeholders().cid }); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' }); + }); + + it('reuses a mint whose version this build predates', async () => { + localStorage.setItem( + '__clerk_protect_st', + JSON.stringify({ token: 'v9.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }), + ); + + // The shape check must not pin a version. The server may mint ahead of this build, and + // rejecting that here would re-run the loader on every page load until the SDK caught up. + const { session: created, elements } = session([loader()]); + expect(created?.hasFreshToken()).toBe(true); + created?.start(); + + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v9.cached.mac' }); + expect(elements).toHaveLength(0); + }); + it('reports nothing at all for a loader that carries no correlation id', async () => { const { session: created, elements } = session([loader({ attributes: { 'data-pid': '{pid}' } })]); diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 60248d70c97..8f547a8e391 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -38,6 +38,11 @@ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000; /** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */ const MAX_TOKEN_LENGTH = 4_096; +/** + * The shape of a mint: `v..`, base64url. Version-agnostic on purpose — the server + * may mint a version this build predates, and only the server can judge a token either way. + */ +const TOKEN_SHAPE = /^v\d+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; /** How long a settled, tokenless acquisition is reused before a fresh run is allowed. */ const REACQUIRE_COOLDOWN_MS = 30 * 1_000; /** Bounds we hold the server-supplied `retry_in_ms` to. */ @@ -245,9 +250,14 @@ function readStoredToken(key: string, marginMs: number): StoredToken | null { /** * The store is writable by anything running on the origin, so a value that could not have come * from a mint of ours is discarded rather than trusted to suppress the loaders. + * + * The shape check is hygiene, not a security boundary: only the server can tell a real token from a + * well-formed forgery, and anything that can write the store can send the same values to the API + * directly. What it buys is that a corrupt or truncated entry starts a fresh run immediately + * instead of suppressing acquisition until it expires. */ function validateToken(token: unknown, exp: unknown, marginMs: number): { token: string; exp: number } | null { - if (typeof token !== 'string' || !token || token.length > MAX_TOKEN_LENGTH) { + if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) { return null; } if (typeof exp !== 'number' || !Number.isFinite(exp)) {