From 6d3918efe722c946e91395e13a61f26eb6f34bf0 Mon Sep 17 00:00:00 2001 From: dezzy-music Date: Tue, 13 Oct 2026 10:16:45 -0600 Subject: [PATCH] fix(security): fallback to crypto.getRandomValues for idempotency keys on insecure origins (#706) --- src/lib/utils.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++ src/lib/utils.ts | 16 +++++++++--- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 812d8f3..118eb42 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -401,6 +401,63 @@ describe("generateIdempotencyKey", () => { const keys = new Set(Array.from({ length: 50 }, () => generateIdempotencyKey())); expect(keys.size).toBe(50); }); + + it("falls back to crypto.getRandomValues when crypto.randomUUID is undefined (#706)", () => { + const originalCrypto = globalThis.crypto; + const getRandomValuesSpy = jest.fn((bytes: Uint8Array) => { + // fill with dummy non-zero bytes to verify version & variant formatting + for (let i = 0; i < bytes.length; i++) { + bytes[i] = (i * 17 + 5) & 0xff; + } + return bytes; + }); + + try { + Object.defineProperty(globalThis, "crypto", { + value: { + getRandomValues: getRandomValuesSpy, + randomUUID: undefined, + }, + configurable: true, + writable: true, + }); + + const key = generateIdempotencyKey(); + expect(getRandomValuesSpy).toHaveBeenCalled(); + 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, + ); + // Verify version 4 character at position 14 + expect(key.charAt(14)).toBe("4"); + // Verify variant [89ab] character at position 19 + expect(["8", "9", "a", "b"]).toContain(key.charAt(19).toLowerCase()); + } finally { + Object.defineProperty(globalThis, "crypto", { + value: originalCrypto, + configurable: true, + writable: true, + }); + } + }); + + it("falls back to pseudo-random string when crypto is undefined", () => { + const originalCrypto = globalThis.crypto; + try { + // @ts-expect-error simulating legacy/unsupported environment + delete globalThis.crypto; + + 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, + ); + } finally { + Object.defineProperty(globalThis, "crypto", { + value: originalCrypto, + configurable: true, + writable: true, + }); + } + }); }); // ─── toCents / toMajorUnits ──────────────────────────────────────────────── diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 83abf87..ffc4536 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -316,11 +316,21 @@ export function validateTeamSplits( * popup timeout. */ export function generateIdempotencyKey(): string { - if (typeof crypto !== "undefined" && crypto.randomUUID) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } - // Fallback for environments without crypto.randomUUID (very unlikely in - // modern browsers, but defensive). + // crypto.randomUUID is only exposed in secure contexts (HTTPS/localhost). + // In non-secure contexts (e.g. plain-HTTP staging), fall back to + // crypto.getRandomValues to construct an RFC 4122 v4 UUID securely (#706). + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // RFC 4122 version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant 1 + 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, 32)}`; + } + // Fallback for environments without Web Crypto API (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);