Skip to content

Commit 115cf98

Browse files
authored
fix(nextjs): Use constant-time comparison in assertTokenSignature (#8411)
1 parent 00f9ff9 commit 115cf98

3 files changed

Lines changed: 59 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
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.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { HmacSHA1 } from '../../vendor/crypto-es';
4+
import { assertTokenSignature } from '../utils';
5+
6+
describe('assertTokenSignature(token, key, signature)', () => {
7+
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkIn0.0u5CllULtDVD9DUUmUMdJLbBCSNcnv4j3hCaPz4dNr8';
8+
const key = 'sk_test_mock';
9+
const validSignature = HmacSHA1(token, key).toString();
10+
11+
it('passes when the signature matches', () => {
12+
expect(() => assertTokenSignature(token, key, validSignature)).not.toThrow();
13+
});
14+
15+
it('throws when the signature is missing', () => {
16+
expect(() => assertTokenSignature(token, key, undefined)).toThrowError();
17+
expect(() => assertTokenSignature(token, key, null)).toThrowError();
18+
expect(() => assertTokenSignature(token, key, '')).toThrowError();
19+
});
20+
21+
it('throws when the signature differs at the last character', () => {
22+
const tampered = validSignature.slice(0, -1) + (validSignature.endsWith('0') ? '1' : '0');
23+
expect(() => assertTokenSignature(token, key, tampered)).toThrowError();
24+
});
25+
26+
it('throws when the signature differs in length', () => {
27+
expect(() => assertTokenSignature(token, key, validSignature.slice(0, -1))).toThrowError();
28+
expect(() => assertTokenSignature(token, key, validSignature + '0')).toThrowError();
29+
});
30+
});

packages/nextjs/src/server/utils.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ function createTokenSignature(token: string, key: string): string {
163163
return HmacSHA1(token, key).toString();
164164
}
165165

166+
/**
167+
* Constant-time string equality. Used to compare HMAC signatures without leaking
168+
* timing information about how many leading characters matched — `===` and `!==`
169+
* on strings short-circuit on the first mismatching character, which would let an
170+
* attacker reconstruct the expected signature byte-by-byte across many timed
171+
* requests against the Next.js origin.
172+
*
173+
* Synchronous and runtime-agnostic so it works in Edge Runtime, where
174+
* `node:crypto.timingSafeEqual` isn't reliably available. The early length check
175+
* leaks length, but is safe here because the only caller compares HMAC-SHA1 hex
176+
* digests of fixed length (40 chars).
177+
*/
178+
function constantTimeEqual(a: string, b: string): boolean {
179+
if (a.length !== b.length) {
180+
return false;
181+
}
182+
let mismatch = 0;
183+
for (let i = 0; i < a.length; i++) {
184+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
185+
}
186+
return mismatch === 0;
187+
}
188+
166189
/**
167190
* Assert that the provided token generates a matching signature.
168191
*/
@@ -172,7 +195,7 @@ export function assertTokenSignature(token: string, key: string, signature?: str
172195
}
173196

174197
const expectedSignature = createTokenSignature(token, key);
175-
if (expectedSignature !== signature) {
198+
if (!constantTimeEqual(expectedSignature, signature)) {
176199
throw new Error(authSignatureInvalid);
177200
}
178201
}

0 commit comments

Comments
 (0)