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
57 changes: 57 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────
Expand Down
16 changes: 13 additions & 3 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down