diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 09e45c82e..bc6f11cdb 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -155,7 +155,20 @@ export function getRootToken(): string { } export function isRootToken(token: string): boolean { - return token === rootToken; + // Constant-time compare so a tunnel-reachable caller who can provoke an + // isRootToken() call (e.g., via the 403 "root over tunnel" rejection path) + // can't measure byte-by-byte string-compare timing to recover the token. + // Compare UTF-8 byte lengths (not JS string length) before timingSafeEqual, + // which throws on length-mismatched buffers. A multibyte input whose JS + // string length matches rootToken but whose UTF-8 byte length differs must + // return false on the auth path, not error out. + if (!rootToken) return false; + const tokenBytes = Buffer.byteLength(token, 'utf8'); + const rootBytes = Buffer.byteLength(rootToken, 'utf8'); + if (tokenBytes !== rootBytes) return false; + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(rootToken, 'utf8'); + return crypto.timingSafeEqual(a, b); } function generateToken(prefix: string): string { diff --git a/browse/test/token-registry.test.ts b/browse/test/token-registry.test.ts index 07c46a63f..b7e761266 100644 --- a/browse/test/token-registry.test.ts +++ b/browse/test/token-registry.test.ts @@ -28,6 +28,39 @@ describe('token-registry', () => { expect(info!.scopes).toEqual(['read', 'write', 'admin', 'meta', 'control']); expect(info!.rateLimit).toBe(0); }); + + // Regression: the previous fix did a JS string-length short-circuit before + // crypto.timingSafeEqual, but the buffers passed in are UTF-8. A multibyte + // input with matching string length but mismatched byte length would slip + // past the check and crash inside timingSafeEqual. Auth path must return + // false, not error. + it('returns false for a multibyte token whose string length matches but UTF-8 byte length differs', () => { + // 'root-token-for-tests' is 20 ASCII chars (20 bytes). + // 'é'.repeat(20) is 20 chars but 40 UTF-8 bytes. + const multibyte = 'é'.repeat(20); + expect(multibyte.length).toBe('root-token-for-tests'.length); + expect(Buffer.byteLength(multibyte, 'utf8')).not.toBe( + Buffer.byteLength('root-token-for-tests', 'utf8'), + ); + expect(() => isRootToken(multibyte)).not.toThrow(); + expect(isRootToken(multibyte)).toBe(false); + }); + + it('returns false for a token that differs only in length (same prefix)', () => { + expect(isRootToken('root-token-for-tests-extra')).toBe(false); + expect(isRootToken('root-token-for-test')).toBe(false); + }); + + it('returns false for a same-length token that differs only in the last byte', () => { + const expected = 'root-token-for-tests'; + const wrong = expected.slice(0, -1) + (expected.endsWith('x') ? 'y' : 'x'); + expect(wrong.length).toBe(expected.length); + expect(isRootToken(wrong)).toBe(false); + }); + + it('returns false for the empty string even when root is set', () => { + expect(isRootToken('')).toBe(false); + }); }); describe('createToken', () => {