From 53d386050b6244b4c0d8865625cca178c757400d Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 29 Jun 2026 11:34:24 -0700 Subject: [PATCH] fix(crypto): reject invalid hex operands in timingSafeEqualHex --- src/utils/crypto.ts | 5 +++-- test/unit/crypto.test.ts | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 2236b6ec33..aaf47fb5cc 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -24,6 +24,7 @@ export async function verifyGitHubSignature(rawBody: string, signatureHeader: st export function timingSafeEqualHex(left: string, right: string): boolean { const leftBytes = hexToBytes(left); const rightBytes = hexToBytes(right); + if (!leftBytes || !rightBytes) return false; if (leftBytes.length !== rightBytes.length) return false; let result = 0; for (let index = 0; index < leftBytes.length; index += 1) { @@ -32,8 +33,8 @@ export function timingSafeEqualHex(left: string, right: string): boolean { return result === 0; } -function hexToBytes(hex: string): Uint8Array { - if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) return new Uint8Array(); +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index 76ea5d8129..90ef7f836a 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createOpaqueToken, hashToken, timingSafeEqual } from "../../src/auth/security"; -import { verifyGitHubSignature } from "../../src/utils/crypto"; +import { verifyGitHubSignature, timingSafeEqualHex } from "../../src/utils/crypto"; describe("webhook signature verification", () => { it("accepts valid GitHub HMAC signatures and rejects tampering", async () => { @@ -17,6 +17,16 @@ describe("webhook signature verification", () => { await expect(verifyGitHubSignature(body, null, secret)).resolves.toBe(false); await expect(verifyGitHubSignature(body, "bad-prefix", secret)).resolves.toBe(false); await expect(verifyGitHubSignature(body, `sha256=${signature}`, "")).resolves.toBe(false); + await expect(verifyGitHubSignature(body, "sha256=not-valid-hex", secret)).resolves.toBe(false); + }); + + it("rejects invalid hex operands in timingSafeEqualHex", () => { + expect(timingSafeEqualHex("zz", "yy")).toBe(false); + expect(timingSafeEqualHex("not-hex-a", "not-hex-b")).toBe(false); + expect(timingSafeEqualHex("abc", "abcd")).toBe(false); + expect(timingSafeEqualHex("", "00")).toBe(false); + expect(timingSafeEqualHex("00", "01")).toBe(false); + expect(timingSafeEqualHex("00", "00")).toBe(true); }); it("uses timing-safe token comparisons and one-way token hashes", async () => {