Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────
Expand Down
20 changes: 18 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down