Skip to content

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

Description

@cliffhall

Two findings from the v2.5.0 milestone-merge review (#2215). Both are in code that shipped on v2/main during the milestone, so neither belongs in the merge PR — that PR's tree is byte-identical to origin/v2/main and its whole verification argument rests on that identity. This is the same handling #2000 → #2092 got after the v2.2.0 merge review.

1. progressToastId collides across distinct progress streams

clients/web/src/utils/toasts/progressToasts.ts:

export function progressToastId(token: ProgressToken | undefined): string {
  return `progress-${String(token ?? "default")}`;
}

ProgressToken is string | number, so String(token) erases the type: the numeric token 7 and 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 no String(token) can produce. Add collision cases to progressToasts.test.ts covering 7 vs "7" and undefined vs "default".

Reported by Copilot on #2215.

2. newAttemptId's insecure-context fallback can use crypto.getRandomValues

clients/web/src/lib/oauthResume.ts:

function newAttemptId(): string {
  const uuid = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
  if (uuid) return uuid();
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}

CodeQL flags this as js/insecure-randomness (alert 72), tracing the value to its use as resumeSnapshot?.remoteSessionId in useOAuthRecovery.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.random branch is a fallback taken only where crypto.randomUUID is unavailable — a file:// page or a plain-HTTP non-loopback host.

But the fallback is improvable on its own merits, independent of the alert. randomUUID needs a secure context; crypto.getRandomValues does not, and is present in every browser that has crypto at all. So the exact situation the fallback exists for is one where a CSPRNG is still available and we decline to use it. Switching to getRandomValues costs a couple of lines, keeps the same id shape, and retires the alert honestly rather than by dismissing it.

Keep the Math.random branch as the last resort for a crypto-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-limiting on test-servers/src/test-server-oauth.ts's /oauth/revoke handler. 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.

Activity

  1. added this to the v2.6.0 milestone on Sep 1, 2026
  2. added
    bugSomething isn't working
    v2Issues and PRs for v2
    on Sep 1, 2026
  3. ump45nose commented on Sep 3, 2026

    @ump45nose

    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. progressToastId collision

    ProgressToken is string | number (the SDK schema is z.union([z.string(), z.number().int()])), so String(token) erases the type and the numeric 7 collides 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.ts calls progressToastId(...) rather than building ids itself, so no other call site changes — only the two test expectations that asserted the old progress-abc / progress-7 / progress-default strings.

    2. newAttemptId CSPRNG fallback

    crypto.randomUUID needs a secure context; crypto.getRandomValues does not. The exact situation the fallback exists for (a file:// page or plain-HTTP non-loopback host) is one where a CSPRNG is still available, so the current Math.random fallback declines to use it. Verified that getRandomValues is on the Crypto global independently of randomUUID.

    Fix — prefer getRandomValues before the Math.random last 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: 7 vs "7" and undefined vs "default" must not share an id.
    • Added a crypto-less test to oauthResume.test.ts driving Math.random via a spy, plus the existing "randomUUID unavailable" test now exercises the getRandomValues branch.
    • 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 0 and prettier --check pass 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.

  4. self-assigned this
    on Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions