Skip to content

fix: type-discriminate progress toast ids, and a CSPRNG fallback for newAttemptId - #2259

Merged
cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2216-progress-toast-id-and-csprng
Sep 5, 2026
Merged

fix: type-discriminate progress toast ids, and a CSPRNG fallback for newAttemptId#2259
cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2216-progress-toast-id-and-csprng

Conversation

@cliffhall

@cliffhall cliffhall commented Sep 5, 2026

Copy link
Copy Markdown
Member

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. progressToastId collided across distinct progress streams

ProgressToken is string | number, so progress-${String(token ?? "default")} erased the type: the numeric token 7 and 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:

input before after
7 progress-7 progress-n:7
"7" progress-7 ⚠️ progress-s:7
undefined progress-default progress-none
"default" progress-default ⚠️ progress-s:default

progress-none is unreachable from any token value, since every present token's id carries a : after its n/s discriminator.

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 existed for — a file:// 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's js/insecure-randomness browser model recognizes a secure RNG by that method call shape.

Math.random survives as the last resort for a global with no crypto object 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 to sessionStorage and 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 (randomUUID unavailable on a plain-HTTP host) now takes a CSPRNG. Whether the query then closes the alert is not something this PR can assert, since Math.random remains 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-limiting on 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 a 7 vs "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 — a getRandomValues arm asserting the CSPRNG is called, that Math.random is not, and that the id is 32 hex chars; and a no-crypto-at-all arm asserting the Math.random last resort. The crypto-member hiding is factored into a withoutCryptoMember helper (both members are inherited from Crypto.prototype, so the restore has to delete an own property rather than put a descriptor back). A vi.restoreAllMocks() was added to the file's afterEach — this project does not set restoreMocks globally, so the new spies would otherwise leak.

No UI change, so no screenshots. npm run local:gate green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn

cliffhall and others added 2 commits September 5, 2026 13:36
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>

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.

🟡 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.getRandomValues fallback.
  • 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

Comment thread clients/web/src/lib/oauthResume.test.ts Outdated
Comment thread clients/web/src/lib/oauthResume.ts Outdated
Comment thread clients/web/src/lib/oauthResume.ts Outdated
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>

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.

🟢 Approval recommended

All reviewed changes are covered by focused tests, with no unresolved issues.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

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.

🟢 Approval recommended

All reviewed changes are covered by focused tests, with no unresolved issues.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@cliffhall
cliffhall merged commit 7abc825 into v2/main Sep 5, 2026
4 checks passed
@cliffhall
cliffhall deleted the v2/fix/2216-progress-toast-id-and-csprng branch September 5, 2026 22:03
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.

v2.5.0 merge review: progress-toast id collisions, and a CSPRNG-capable fallback for newAttemptId

2 participants