Conversation
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>
There was a problem hiding this comment.
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
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
autoConnectandautoOpen. - 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.
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>
|
Copilot round 1, 1 finding:
No suppressed comments. Requesting round 2. |
| 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); | ||
| } |
There was a problem hiding this comment.
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:
parseDeepLinkis a synchronous parse feeding render-time state inApp.tsx, andcrypto.subtleis missing on non-secure origins (a LAN-hosthttp: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.
|
Copilot round 2, 1 finding:
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). |


Closes #2429
What changed
parseDeepLink()(clients/web/src/utils/deepLink.ts) compared the deep link'sautoConnectandautoOpenparams against the session API token with plain!==/===, which returns at the first mismatched character.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.parseDeepLink()now go through it.autoOpenkeeps its exact semantics (absent or wrong meansfalse).Judgment calls
crypto.timingSafeEqualis Node-only (the backend already uses it forx-mcp-remote-auth).crypto.subtleis async, and it is missing on non-secure origins, so it can't serve this synchronous parse path without changing the API ofparseDeepLink.deepLink.tsinstead of a new module. It is pure (no I/O), so it belongs undersrc/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.Tests
deepLink.test.tsadds:autoConnectandautoOpen;constantTimeEqualcases: 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:gatewas 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")insecret-store-selection.test.tsfails because of the container's mount layout. Integration tests that reach loopback through the forcedNODE_USE_ENV_PROXYsandbox proxy fail with a proxy 502, and pass once that variable is unset.coverage:cli: therelogin-revocation/clear-stored-auth-for-relogintests fail with the sandbox'sHTTPS_PROXYset and pass with it unset (21/21).validate:*,verify:skills:cli,coverage:tui,coverage:launcher,verify:build-gate,verify:bundle-externals,smoke,smoke:web:firefoxand Storybook all passed. CI will run the suites in a clean environment.No UI change, so no screenshots.
🤖 Generated with Claude Code