Repository navigation
v2.5.0 merge review: progress-toast id collisions, and a CSPRNG-capable fallback for newAttemptId #2216
Description
Activity
- addedbugSomething isn't workingSomething isn't workingv2Issues and PRs for v2Issues and PRs for v2
on Sep 1, 2026 Reproduced both findings locally and verified the fix. Per this repo's issues-only policy I'm not opening a PR — here is the prompt, the before/after, and how I verified it.
1.
progressToastIdcollisionProgressTokenisstring | number(the SDK schema isz.union([z.string(), z.number().int()])), soString(token)erases the type and the numeric7collides with the string"7"; the?? "default"sentinel also collides with a genuine string token"default". Confirmed against the shipped source.Fix — encode the type (and absence) into the id so no two distinct streams can share one:
export function progressToastId(token: ProgressToken | undefined): string { if (token === undefined) { return "progress-u-undefined"; } return typeof token === "number" ? `progress-n-${token}` : `progress-s-${token}`; }
useProgressToasts.tscallsprogressToastId(...)rather than building ids itself, so no other call site changes — only the two test expectations that asserted the oldprogress-abc/progress-7/progress-defaultstrings.2.
newAttemptIdCSPRNG fallbackcrypto.randomUUIDneeds a secure context;crypto.getRandomValuesdoes not. The exact situation the fallback exists for (afile://page or plain-HTTP non-loopback host) is one where a CSPRNG is still available, so the currentMath.randomfallback declines to use it. Verified thatgetRandomValuesis on theCryptoglobal independently ofrandomUUID.Fix — prefer
getRandomValuesbefore theMath.randomlast resort:function newAttemptId(): string { const uuid = globalThis.crypto?.randomUUID?.bind(globalThis.crypto); if (uuid) { return uuid(); } const getRandomValues = globalThis.crypto?.getRandomValues?.bind( globalThis.crypto, ); if (getRandomValues) { const bytes = new Uint32Array(2); getRandomValues(bytes); return `${bytes[0].toString(36)}${bytes[1].toString(36)}`; } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; }
The "never a security token" doc comment is kept and expanded — it is still the reason this is a Medium, not urgent.
Verification
- Added collision tests to
progressToasts.test.ts:7vs"7"andundefinedvs"default"must not share an id. - Added a crypto-less test to
oauthResume.test.tsdrivingMath.randomvia a spy, plus the existing "randomUUID unavailable" test now exercises thegetRandomValuesbranch. npx vitest run --project=unit src/utils/toasts/progressToasts.test.ts src/hooks/useProgressToasts.test.tsx src/lib/oauthResume.test.ts→ 55 passed.eslint --max-warnings 0andprettier --checkpass on all touched files.
AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.
- Added collision tests to
- linked a pull request that will close this issuefix: type-discriminate progress toast ids, and a CSPRNG fallback for newAttemptId #2259
on Sep 5, 2026
Two findings from the v2.5.0 milestone-merge review (#2215). Both are in code that shipped on
v2/mainduring the milestone, so neither belongs in the merge PR — that PR's tree is byte-identical toorigin/v2/mainand its whole verification argument rests on that identity. This is the same handling #2000 → #2092 got after the v2.2.0 merge review.1.
progressToastIdcollides across distinct progress streamsclients/web/src/utils/toasts/progressToasts.ts:ProgressTokenisstring | number, soString(token)erases the type: the numeric token7and the string token"7"produce the same id. The absent case is worse — it hardcodes the sentinel"default", which a server is free to send as a genuine string token.Because notifications keyed by the same id are replaced rather than stacked (that is the point of the id), a collision means two concurrent progress streams overwrite each other's toast: one stream's ticks silently retitle the other's, and when the first finishes its auto-close takes the survivor with it.
Fix: encode the token's type and its absence in the id — e.g.
progress-n-7/progress-s-7/ a sentinel that noString(token)can produce. Add collision cases toprogressToasts.test.tscovering7vs"7"andundefinedvs"default".Reported by Copilot on #2215.
2.
newAttemptId's insecure-context fallback can usecrypto.getRandomValuesclients/web/src/lib/oauthResume.ts:CodeQL flags this as
js/insecure-randomness(alert 72), tracing the value to its use asresumeSnapshot?.remoteSessionIdinuseOAuthRecovery.ts.The alert overstates it. The doc comment above the function is accurate: an attempt id only has to be unique among the handful of redirect attempts one page can have in flight, it is never presented as a bearer credential, and the
Math.randombranch is a fallback taken only wherecrypto.randomUUIDis unavailable — afile://page or a plain-HTTP non-loopback host.But the fallback is improvable on its own merits, independent of the alert.
randomUUIDneeds a secure context;crypto.getRandomValuesdoes not, and is present in every browser that hascryptoat all. So the exact situation the fallback exists for is one where a CSPRNG is still available and we decline to use it. Switching togetRandomValuescosts a couple of lines, keeps the same id shape, and retires the alert honestly rather than by dismissing it.Keep the
Math.randombranch as the last resort for acrypto-less global, and keep the "never a security token" comment — it is the reason this is Medium and not urgent.Not actionable
CodeQL alert 73,
js/missing-rate-limitingontest-servers/src/test-server-oauth.ts's/oauth/revokehandler. The test servers are local, single-user fixtures whose entire purpose is to be driven by the Inspector on loopback; rate-limiting them would make several smokes slower and some of them flaky, and would defend nothing. Dismiss it on the alert rather than tracking it here.