Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion browse/src/token-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions browse/test/token-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down