From 2459cdc4dab33339962e1abc5d7599ec38c23c5b Mon Sep 17 00:00:00 2001 From: smartcalsystems-sketch Date: Tue, 13 Oct 2026 23:12:29 +0530 Subject: [PATCH] fix: use crypto.getRandomValues on non-secure origins (closes #706) crypto.randomUUID requires a secure context (HTTPS or localhost), but that's not the only non-secure case in practice -- a plain-http staging deploy hits it too. When randomUUID is missing, generateIdempotencyKey fell all the way back to Math.random, which is predictable and collision-prone. Since idempotency keys are scoped per caller on the backend, a collision replays someone's cached response instead of executing the request. crypto.getRandomValues does not require a secure context and is still available in that case, so prefer it: draw 16 random bytes, fix the RFC 4122 v4 version/variant bits, and format as a standard UUID. Only fall back to Math.random if the Web Crypto API is entirely absent. --- src/lib/utils.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/lib/utils.ts | 20 ++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 812d8f3..407b686 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -401,6 +401,43 @@ describe("generateIdempotencyKey", () => { const keys = new Set(Array.from({ length: 50 }, () => generateIdempotencyKey())); expect(keys.size).toBe(50); }); + + describe("without crypto.randomUUID (e.g. a non-secure origin) (#706)", () => { + let originalRandomUUID: typeof crypto.randomUUID; + + beforeEach(() => { + originalRandomUUID = crypto.randomUUID; + // @ts-expect-error - simulating an insecure-context environment where + // crypto.randomUUID is unavailable but crypto.getRandomValues still is. + crypto.randomUUID = undefined; + }); + + afterEach(() => { + crypto.randomUUID = originalRandomUUID; + }); + + it("still returns a valid UUID v4 string via crypto.getRandomValues", () => { + const key = generateIdempotencyKey(); + expect(key).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it("still generates unique keys on successive calls", () => { + const keys = new Set(Array.from({ length: 50 }, () => generateIdempotencyKey())); + expect(keys.size).toBe(50); + }); + + it("does not fall back to Math.random when getRandomValues is available", () => { + const mathRandomSpy = jest.spyOn(Math, "random"); + try { + generateIdempotencyKey(); + expect(mathRandomSpy).not.toHaveBeenCalled(); + } finally { + mathRandomSpy.mockRestore(); + } + }); + }); }); // ─── toCents / toMajorUnits ──────────────────────────────────────────────── diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 83abf87..a434e4b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -319,8 +319,24 @@ export function generateIdempotencyKey(): string { if (typeof crypto !== "undefined" && crypto.randomUUID) { return crypto.randomUUID(); } - // Fallback for environments without crypto.randomUUID (very unlikely in - // modern browsers, but defensive). + + // `crypto.randomUUID` requires a secure context, but `crypto.getRandomValues` + // does not — it's available on a plain-http origin too (e.g. a staging + // deploy), so prefer it over Math.random, which is predictable and + // collision-prone. Idempotency keys are scoped per caller on the backend, + // so a collision replays someone's cached response instead of executing + // the request (#706). + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + // RFC 4122 v4: fix the version nibble to 4 and the variant bits to 10xx. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + // Last-resort fallback for environments with no Web Crypto API at all + // (very unlikely in modern browsers, but defensive). return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);