fix(token-registry): constant-time root-token comparison#1171
Conversation
isRootToken() compared the incoming token against the stored root
token with JS strict equality (token === rootToken). String === in V8
short-circuits on the first differing byte, which makes the call a
timing oracle. A caller who can provoke an isRootToken() check —
including the tunnel surface, which runs the check to return a 403
for root-over-tunnel — can in principle recover the token byte-by-byte
by measuring response time deltas across many requests.
Practical exploitability is low: crypto.randomUUID() gives 122 bits,
the tunnel rejects the token anyway, and ngrok jitter usually swamps
sub-microsecond compare differences. But the fix is trivial (one
import is already present), doesn't change any caller, and closes the
side channel.
Use crypto.timingSafeEqual with a length short-circuit. The length
check is fine — UUID length is public. Added regression tests for:
1. token that differs only in length (exercises the short-circuit)
2. same-length token that differs only in the last byte (exercises
timingSafeEqual on inputs that would have falsely passed a naive
prefix-only compare)
3. empty string when root is set (guards against the buffer edge)
No change to other comparison paths: validateToken() and
validateSseSessionToken() both resolve tokens via Map.get, which is
O(1) hash-lookup and does not leak per-byte timing.
|
Thanks @RagavRida — closing. Codex caught a subtle bug during pre-landing review that the regression tests don't cover: The intent of the patch is good (and welcome) — happy to accept a rebased version that either (a) compares using a fixed-byte representation ( We're not going to accept maintainer-edits-on-top under your attribution, so the cleanest path is a fresh PR from you with the fix + multibyte test cases. |
Summary
isRootToken()inbrowse/src/token-registry.tscompared the incoming token against the stored root token with strict equality (token === rootToken). V8's string===short-circuits on the first differing byte, turning the call into a timing oracle.A tunnel-reachable caller can provoke
isRootToken()on every/commandrequest (the check runs to produce the 403 "root over tunnel" rejection) and, with enough samples, recover the token byte-by-byte from response-time deltas.Severity
MEDIUM, not CRITICAL. Practical exploitation is hard:
crypto.randomUUID()— 122 bits of entropy. Byte-by-byte timing attack narrows this to ~36 × 16 = 576 probes, but each probe needs sub-microsecond precision.But the fix is one-line-equivalent,
cryptois already imported, and nothing else changes. No reason to leave a textbook side channel in the auth path.Change
isRootToken()now usescrypto.timingSafeEqual()with a length short-circuit:The length short-circuit is safe — UUID length is public (36 chars).
timingSafeEqualrequires equal-length buffers so the length check is also necessary, not just an optimization.Other comparison paths — checked, no change needed
validateToken()(token-registry.ts:301) — falls through totokens.get(token), which isMap.get. O(1) hash lookup; doesn't leak per-byte timing.validateSseSessionToken()(sse-session-cookie.ts:55) — same pattern,sessions.get(token).So
isRootTokenwas the only strict-equality compare on the auth hot path.Tests
Added three regression cases to the existing
describe('root token', ...)block inbrowse/test/token-registry.test.ts:length !==short-circuit. Previously===would also return false here, but now the path goes through the length check explicitly.timingSafeEqualon inputs that would have passed a naive prefix-only compare. Guards against a future refactor that might re-introduce one.Buffer.from('')edge before the length check runs.The existing positive-case assertion (
isRootToken('root-token-for-tests') === true) still passes — behavior is unchanged on the accept path.Test plan
bun test browse/test/token-registry.test.ts— 4 test cases in the root-token describe block should all pass.bun test— full suite. No other test touchesisRootTokendirectly.timingSafeEqualis what you're supposed to use, and the length check is public information.Scope
This is a one-function fix with three new test cases. No new dependencies. No behavior change on any accept path. Separate PR from #1169 on purpose — different concern, easier to review and revert.
Happy to split the test additions into a follow-up commit if you'd prefer the fix and tests to land as two commits. As-is, they're one commit because the tests document why the fix was needed.