fix: type-discriminate progress toast ids, and a CSPRNG fallback for newAttemptId - #2259
Merged
Merged
Conversation
Both findings from the v2.5.0 milestone-merge review (#2215). 1. `progressToastId` collided across distinct progress streams. `ProgressToken` is `string | number`, so `String(token)` mapped the numeric token 7 and the string token "7" onto one id — and because notifications keyed by the same id are *replaced* rather than stacked, two concurrent streams overwrote each other's toast. The absent case was worse: it hardcoded the sentinel "default", which a server is free to send as a genuine string token. The id now carries the token's type (`progress-n:7` / `progress-s:7`) and gives the no-token case a prefix of its own (`progress-none`) that no token value can produce. 2. `newAttemptId`'s fallback now prefers `crypto.getRandomValues`. `randomUUID` needs a secure context; `getRandomValues` does not and exists in every browser that has `crypto` at all — so the exact situation the fallback exists for (a `file://` page, a plain-HTTP non-loopback host) still has a CSPRNG on hand. `Math.random` stays as the last resort for a `crypto`-less global, and the "never a security token" comment stays too. Retires CodeQL alert 72 (`js/insecure-randomness`) honestly rather than by dismissing it. Tests: collision cases for 7 vs "7" and the absent token vs "default" / "none", a distinctness sweep over the whole id space; and the attempt-id fallback test split into a getRandomValues arm (asserting the CSPRNG is used and `Math.random` is not) and a no-crypto-at-all arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn Signed-off-by: cliffhall <cliff@futurescale.com>
`Record<string, unknown>` does not overlap `Crypto` (TS2352 under `tsc -b`). `Partial<Pick<Crypto, …>>` says the same thing in one legal cast and keeps `delete` operating on a known-optional property. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
The OAuth fallback implementation and no-crypto test have unresolved moderate issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes progress-toast ID collisions and improves OAuth attempt-ID randomness.
Changes:
- Type-discriminates progress-token toast IDs.
- Adds a
crypto.getRandomValuesfallback. - Expands regression coverage.
File summaries
| File | Review |
|---|---|
clients/web/src/utils/toasts/progressToasts.ts |
Generates collision-resistant toast IDs. No issues found. |
clients/web/src/utils/toasts/progressToasts.test.ts |
Covers token-type and sentinel collisions. No issues found. |
clients/web/src/lib/oauthResume.ts |
Adds CSPRNG fallback. Moderate (1 vote): Remaining Math.random() may prevent retiring CodeQL alert 72. Moderate (1 vote): Calling a bound getRandomValues alias may not satisfy CodeQL; use a direct guarded call. |
clients/web/src/lib/oauthResume.test.ts |
Tests randomness fallbacks. Moderate (2 votes): The no-crypto test only hides methods; stub globalThis.crypto as undefined to test the actual absence path. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Balanced
Copilot review round 1. - `newAttemptId` calls `globalThis.crypto.getRandomValues(...)` directly rather than through a bound alias. CodeQL's `js/insecure-randomness` browser model recognizes a secure RNG by that literal method call, so the indirection would have left the `Math.random` last resort classified as an unmitigated source. - The no-crypto test arm now removes `globalThis.crypto` entirely instead of hiding its two methods, matching the crypto-absence test in `src/test/core/auth/utils.test.ts`. Hiding only the methods left the global truthy, so the arm could not have caught a regression that read `globalThis.crypto` without the optional guard. `withoutCryptoMember` narrows to `randomUUID`, its only remaining caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn Signed-off-by: cliffhall <cliff@futurescale.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2216
Both findings from the v2.5.0 milestone-merge review (#2215), landed here rather than in that merge PR because its tree must stay byte-identical to
origin/v2/main— the same handling #2000 → #2092 got after the v2.2.0 merge.1.
progressToastIdcollided across distinct progress streamsProgressTokenisstring | number, soprogress-${String(token ?? "default")}erased the type: the numeric token7and the string token"7"produced one id. Notifications keyed by the same id are replaced rather than stacked — that is the point of the id — so two concurrent progress streams overwrote each other's toast: one stream's ticks silently retitled the other's, and the first to finish took the survivor with it on auto-close. The absent case was worse still: it hardcoded the sentinel"default", which a server is free to send as a genuine string token.The id now carries the token's type, and the no-token case gets a prefix of its own:
7progress-7progress-n:7"7"progress-7progress-s:7undefinedprogress-defaultprogress-none"default"progress-defaultprogress-s:defaultprogress-noneis unreachable from any token value, since every present token's id carries a:after itsn/sdiscriminator.2.
newAttemptId's fallback now preferscrypto.getRandomValuesrandomUUIDneeds a secure context;getRandomValuesdoes not, and exists in every browser that hascryptoat all. So the exact situation the fallback existed for — afile://page, a plain-HTTP non-loopback host — still had a CSPRNG on hand and we declined to use it. It now emits 16 hex-encoded random bytes there, keeping the same opaque-string id shape.The call is written as a literal
globalThis.crypto.getRandomValues(...)rather than through a bound alias, because CodeQL'sjs/insecure-randomnessbrowser model recognizes a secure RNG by that method call shape.Math.randomsurvives as the last resort for a global with nocryptoobject at all, and the "never a security token" doc comment stays — it is why this is Medium rather than urgent. A counter-based last resort was considered and rejected: the id must be unique across page loads (it is written tosessionStorageand matched by a callback in a fresh document), so a per-page counter would collide by construction and undo #2165.On CodeQL alert 72: this removes the reachable insecure path — the situation the alert was raised about (
randomUUIDunavailable on a plain-HTTP host) now takes a CSPRNG. Whether the query then closes the alert is not something this PR can assert, sinceMath.randomremains a source in the same function. If it survives, it should be dismissed as a documented non-security use rather than argued down.Not addressed, deliberately: CodeQL alert 73 (
js/missing-rate-limitingon the test server's/oauth/revoke). Per the issue, the test servers are local single-user fixtures driven on loopback; rate-limiting them would slow and flake several smokes and defend nothing. That one gets dismissed on the alert.Tests
progressToasts.test.ts: the updated id expectations, plus a7vs"7"collision case, an absent-token case sweeping the values a server could send to impersonate the sentinel ("default","none","","0",0), and a distinctness sweep asserting seven distinct tokens yield seven distinct ids.oauthResume.test.ts: the single fallback test split into two arms — agetRandomValuesarm asserting the CSPRNG is called, thatMath.randomis not, and that the id is 32 hex chars; and a no-crypto-at-all arm asserting theMath.randomlast resort. Thecrypto-member hiding is factored into awithoutCryptoMemberhelper (both members are inherited fromCrypto.prototype, so the restore has to delete an own property rather than put a descriptor back). Avi.restoreAllMocks()was added to the file'safterEach— this project does not setrestoreMocksglobally, so the new spies would otherwise leak.No UI change, so no screenshots.
npm run local:gategreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn