Skip to content

fix(token-registry): constant-time root-token comparison#1171

Closed
RagavRida wants to merge 1 commit into
garrytan:mainfrom
RagavRida:fix/root-token-timing-safe-compare
Closed

fix(token-registry): constant-time root-token comparison#1171
RagavRida wants to merge 1 commit into
garrytan:mainfrom
RagavRida:fix/root-token-timing-safe-compare

Conversation

@RagavRida

Copy link
Copy Markdown
Contributor

Summary

isRootToken() in browse/src/token-registry.ts compared 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 /command request (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:

  • Root token is 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.
  • ngrok jitter and network round-trip variance usually dominate the JS compare's timing signal in the real world.
  • Tunnel listener rejects root-token attempts at a higher layer, so success wouldn't even grant tunnel access — only local-listener access, which a local attacker has other ways to get.

But the fix is one-line-equivalent, crypto is already imported, and nothing else changes. No reason to leave a textbook side channel in the auth path.

Change

isRootToken() now uses crypto.timingSafeEqual() with a length short-circuit:

export function isRootToken(token: string): boolean {
  if (!rootToken) return false;
  if (token.length !== rootToken.length) return false;
  const a = Buffer.from(token);
  const b = Buffer.from(rootToken);
  return crypto.timingSafeEqual(a, b);
}

The length short-circuit is safe — UUID length is public (36 chars). timingSafeEqual requires 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 to tokens.get(token), which is Map.get. O(1) hash lookup; doesn't leak per-byte timing.
  • validateSseSessionToken() (sse-session-cookie.ts:55) — same pattern, sessions.get(token).

So isRootToken was the only strict-equality compare on the auth hot path.

Tests

Added three regression cases to the existing describe('root token', ...) block in browse/test/token-registry.test.ts:

  1. Different length, same prefix — exercises the length !== short-circuit. Previously === would also return false here, but now the path goes through the length check explicitly.
  2. Same length, differs in last byte only — exercises timingSafeEqual on inputs that would have passed a naive prefix-only compare. Guards against a future refactor that might re-introduce one.
  3. Empty string with root set — guards the 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 touches isRootToken directly.
  • No runtime verification of the timing-attack closure itself — that would need a controlled benchmark with statistical analysis. The fix is structural: timingSafeEqual is 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.

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.
@garrytan

Copy link
Copy Markdown
Owner

Thanks @RagavRida — closing. Codex caught a subtle bug during pre-landing review that the regression tests don't cover: Buffer.from(token) defaults to UTF-8 encoding, so a token whose JS string length matches rootToken but whose UTF-8 byte length differs (e.g. "é".repeat(36) is 36 chars but 72 bytes) passes the length check, then crypto.timingSafeEqual THROWS instead of returning false because the buffers are different sizes. Since isRootToken() is on the auth path, malformed multibyte input would now error out instead of cleanly 401/403'ing.

The intent of the patch is good (and welcome) — happy to accept a rebased version that either (a) compares using a fixed-byte representation (Buffer.from(token, 'utf8') with Buffer.byteLength checked first), or (b) catches the throw and returns false, plus regression tests with multibyte tokens.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants