mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
fix(token-registry): UTF-8 byte-length short-circuit before timingSafeEqual
Constant-time compare on the root token now compares UTF-8 byte lengths before crypto.timingSafeEqual, which throws on length-mismatched buffers. A multibyte input whose JS string length matches but byte length differs no longer crashes on the auth path; isRootToken returns false instead. Tests cover the four interesting cases: multibyte byte-length mismatch, extra-prefix length mismatch, same-length last-byte flip, and empty input against a set root. Contributed by @RagavRida (#1416). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user