Skip to content

fix(web): compare deep-link auth tokens in constant time - #2577

Open
cliffhall wants to merge 2 commits into
v2/mainfrom
v2/fix/2429-deeplink-constant-time-compare
Open

cliffhall wants to merge 2 commits into
v2/mainfrom
v2/fix/2429-deeplink-constant-time-compare

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2429

What changed

parseDeepLink() (clients/web/src/utils/deepLink.ts) compared the deep link's autoConnect and autoOpen params against the session API token with plain !== / ===, which returns at the first mismatched character.

  • Adds constantTimeEqual(candidate, secret) to the same module: a synchronous XOR accumulator that visits every UTF-16 code unit of the secret whatever the candidate holds, and folds a length mismatch into the result rather than returning early. Only the secret's length drives the loop.
  • Both token checks in parseDeepLink() now go through it. autoOpen keeps its exact semantics (absent or wrong means false).

Judgment calls

  • Why not a platform primitive? crypto.timingSafeEqual is Node-only (the backend already uses it for x-mcp-remote-auth). crypto.subtle is async, and it is missing on non-secure origins, so it can't serve this synchronous parse path without changing the API of parseDeepLink.
  • Placement: the helper lives in deepLink.ts instead of a new module. It is pure (no I/O), so it belongs under src/utils/ per the web source layout rule. Keeping it beside its only caller keeps the diff to two files. If a second browser-side caller turns up, moving it into its own utils module is a one-line import change.
  • Length is not hidden: the secret's length is a fixed property of the launch-time token format, not of its value. The function's doc comment says so.

Tests

deepLink.test.ts adds:

  • prefix, extension and first-character-mismatch rejections for both autoConnect and autoOpen;
  • direct constantTimeEqual cases: equality, a length mismatch in either direction, a NUL code unit versus a missing one past the candidate's end, and code-unit (not normalized) comparison.

Per-file coverage of deepLink.ts: 98.0 statements / 95.3 branches / 100 functions / 97.7 lines.

Gate

npm run local:gate was run in a sandboxed container. Every stage passed except two, and both failed for container reasons unrelated to this diff, which touches only the two web files above:

  • coverage:web: isOnMountPoint("bad\0path") in secret-store-selection.test.ts fails because of the container's mount layout. Integration tests that reach loopback through the forced NODE_USE_ENV_PROXY sandbox proxy fail with a proxy 502, and pass once that variable is unset.
  • coverage:cli: the relogin-revocation / clear-stored-auth-for-relogin tests fail with the sandbox's HTTPS_PROXY set and pass with it unset (21/21).

validate:*, verify:skills:cli, coverage:tui, coverage:launcher, verify:build-gate, verify:bundle-externals, smoke, smoke:web:firefox and Storybook all passed. CI will run the suites in a clean environment.

No UI change, so no screenshots.

🤖 Generated with Claude Code

parseDeepLink() checked the autoConnect and autoOpen params against the
session API token with plain ===/!==, which returns at the first
mismatched character and so leaks, in principle, how long a correct
prefix a guessed token has.

Add constantTimeEqual() beside parseDeepLink in the pure utils module: a
synchronous XOR accumulator that visits every code unit of the secret
and folds a length mismatch into the result instead of returning early.
The browser has no crypto.timingSafeEqual, and crypto.subtle is async
and unavailable on non-secure origins, so neither fits this sync path.

Closes #2429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Oct 5, 2026
@cliffhall cliffhall linked an issue Oct 5, 2026 that may be closed by this pull request
2 tasks done
@cliffhall
cliffhall requested a balanced review from Copilot October 5, 2026 06:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The security rationale incorrectly claims token lengths are always fixed despite supported user-configured tokens.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
What changed in this PR

Hardens web deep-link authentication against prefix timing leakage.

Changes:

  • Adds a synchronous XOR-based token comparator.
  • Applies it to autoConnect and autoOpen.
  • Adds edge-case coverage for mismatches and Unicode.
File Description
clients/​web/​src/​utils/​deepLink.ts Adds and uses constant-time comparison.
clients/​web/​src/​utils/​deepLink.test.ts Tests comparison and deep-link rejection behavior.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/web/src/utils/deepLink.ts Outdated
constantTimeEqual's comment claimed the secret's length is fixed by the
launch-time token format, but MCP_INSPECTOR_API_TOKEN / --auth-token
accept a user-supplied value of any length. Say so, and name what the
helper does remove: the prefix leak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1, 1 finding:

  • deepLink.ts:99, doc comment claimed a fixed token length despite user-supplied tokens: fixed in 679c5ce (comment-only). Reworded to say a custom token's length stays observable, which is the accepted limit; the prefix leak is what is removed.

No suppressed comments. Requesting round 2.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Browser JavaScript cannot guarantee the new loop executes in constant time, leaving the security requirement unresolved.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment on lines +108 to +112
for (let i = 0; i < secret.length; i++) {
// Past the candidate's end charCodeAt is NaN, which `| 0` maps to 0; the
// length term above has already recorded that mismatch.
diff |= (candidate.charCodeAt(i) | 0) ^ secret.charCodeAt(i);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining as scope expansion beyond #2429, which asks for a constant-time compare and explicitly accepts "a timing-safe-equal utility". Its triage scored it Low hardening, since both operands are same-origin browser values. You are right that ECMAScript makes no constant-time promise. But a branch-free XOR accumulator with a trip count fixed by the secret is the standard userland shape for this (it is what libraries like tsscmp do), and it removes the early exit that === has. That early exit is the leak the issue describes.

The alternatives both reshape more than this issue covers:

  • Async Web Crypto digests: parseDeepLink is a synchronous parse feeding render-time state in App.tsx, and crypto.subtle is missing on non-secure origins (a LAN-host http: launch).
  • Moving the gate to the backend: a new API round trip on page load.

Either one deserves its own issue if a maintainer wants that stronger guarantee. The helper's doc comment already explains why no platform primitive fits this path.

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 2, 1 finding:

  • deepLink.ts:112, "a browser JS loop is not guaranteed constant-time; use async Web Crypto or move the gate to the backend": declined as scope expansion beyond Deep-link auth-token comparison in deepLink.ts isn't constant-time #2429. The issue accepts a timing-safe-equal utility, and both alternatives reshape the synchronous parse path or add a backend round trip. Reasoning is in the thread.

Round 1's finding shows as resolved. No suppressed comments.

Review loop closed: round 2 held only an out-of-scope finding, so nothing changed and another round would only re-argue scope (pr-flow 7c).

This branch has not been deployed

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

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deep-link auth-token comparison in deepLink.ts isn't constant-time

2 participants