diff --git a/.changeset/aisec-8-constant-time-token-signature-compare.md b/.changeset/aisec-8-constant-time-token-signature-compare.md new file mode 100644 index 00000000000..99cb42a88a2 --- /dev/null +++ b/.changeset/aisec-8-constant-time-token-signature-compare.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Use a constant-time comparison when validating the integrity signature on the middleware-to-origin auth header handoff (`assertTokenSignature`). The previous `!==` compare was timing-variable; the new helper is synchronous and runtime-agnostic so it works in both Node and Edge Runtime. diff --git a/packages/nextjs/src/server/__tests__/utils.test.ts b/packages/nextjs/src/server/__tests__/utils.test.ts new file mode 100644 index 00000000000..5b6b6368149 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/utils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { HmacSHA1 } from '../../vendor/crypto-es'; +import { assertTokenSignature } from '../utils'; + +describe('assertTokenSignature(token, key, signature)', () => { + const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkIn0.0u5CllULtDVD9DUUmUMdJLbBCSNcnv4j3hCaPz4dNr8'; + const key = 'sk_test_mock'; + const validSignature = HmacSHA1(token, key).toString(); + + it('passes when the signature matches', () => { + expect(() => assertTokenSignature(token, key, validSignature)).not.toThrow(); + }); + + it('throws when the signature is missing', () => { + expect(() => assertTokenSignature(token, key, undefined)).toThrowError(); + expect(() => assertTokenSignature(token, key, null)).toThrowError(); + expect(() => assertTokenSignature(token, key, '')).toThrowError(); + }); + + it('throws when the signature differs at the last character', () => { + const tampered = validSignature.slice(0, -1) + (validSignature.endsWith('0') ? '1' : '0'); + expect(() => assertTokenSignature(token, key, tampered)).toThrowError(); + }); + + it('throws when the signature differs in length', () => { + expect(() => assertTokenSignature(token, key, validSignature.slice(0, -1))).toThrowError(); + expect(() => assertTokenSignature(token, key, validSignature + '0')).toThrowError(); + }); +}); diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index d062891e0bf..e6504478823 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -163,6 +163,29 @@ function createTokenSignature(token: string, key: string): string { return HmacSHA1(token, key).toString(); } +/** + * Constant-time string equality. Used to compare HMAC signatures without leaking + * timing information about how many leading characters matched — `===` and `!==` + * on strings short-circuit on the first mismatching character, which would let an + * attacker reconstruct the expected signature byte-by-byte across many timed + * requests against the Next.js origin. + * + * Synchronous and runtime-agnostic so it works in Edge Runtime, where + * `node:crypto.timingSafeEqual` isn't reliably available. The early length check + * leaks length, but is safe here because the only caller compares HMAC-SHA1 hex + * digests of fixed length (40 chars). + */ +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let mismatch = 0; + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +} + /** * Assert that the provided token generates a matching signature. */ @@ -172,7 +195,7 @@ export function assertTokenSignature(token: string, key: string, signature?: str } const expectedSignature = createTokenSignature(token, key); - if (expectedSignature !== signature) { + if (!constantTimeEqual(expectedSignature, signature)) { throw new Error(authSignatureInvalid); } }