diff --git a/src/app/api/webhooks/github/route.test.ts b/src/app/api/webhooks/github/route.test.ts new file mode 100644 index 00000000..7dabed81 --- /dev/null +++ b/src/app/api/webhooks/github/route.test.ts @@ -0,0 +1,52 @@ +import crypto from "crypto"; +import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"; +import { safeCompare } from "./route"; + +describe("safeCompare", () => { + let timingSafeEqualSpy: MockInstance; + + beforeEach(() => { + // Vitest uses vi.spyOn to monitor module methods + timingSafeEqualSpy = vi.spyOn(crypto, "timingSafeEqual"); + }); + + afterEach(() => { + // Clear mock data and restore original implementation + timingSafeEqualSpy.mockRestore(); + }); + + // Test Case 1: Early return optimization on length mismatch + it("should return false immediately and not call timingSafeEqual when lengths differ", () => { + const result = safeCompare("short", "muchlongerstring"); + + expect(result).toBe(false); + expect(timingSafeEqualSpy).not.toHaveBeenCalled(); + }); + + // Test Case 2: Standard identical buffers + it("should return true and call timingSafeEqual when buffers are identical", () => { + const stringA = "secure_token_123"; + const stringB = "secure_token_123"; + + const result = safeCompare(stringA, stringB); + + expect(result).toBe(true); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); + + // Test Case 3: Handles empty strings safely + it("should handle empty strings correctly without throwing exceptions", () => { + const result = safeCompare("", ""); + + expect(result).toBe(true); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); + + // Test Case 4: Identical length but different content (Should trigger full timing check) + it("should return false and call timingSafeEqual when lengths match but content differs", () => { + const result = safeCompare("abc", "xyz"); + + expect(result).toBe(false); + expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index 41a8d104..ff49a184 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -1,8 +1,8 @@ +import { createHmac, timingSafeEqual } from "crypto"; import { revalidatePath } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase"; import { logError } from "@/lib/error-handler"; -import { verifyGitHubSignature } from "@/lib/crypto"; export const dynamic = "force-dynamic"; @@ -23,6 +23,33 @@ interface GitHubPushPayload { }; } +function getExpectedSignature(secret: string, body: string): string { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +export function safeCompare(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + + if (left.length !== right.length) { + return false; + } + + return timingSafeEqual(left, right); // timingSafeEqual prevents timing attack vulnerabilities +} + +function verifyGitHubSignature( + body: string, + signature: string | null, + secret: string +): boolean { + if (!signature?.startsWith("sha256=")) { + return false; + } + + return safeCompare(signature, getExpectedSignature(secret, body)); +} + function getPushActor(payload: GitHubPushPayload): string | null { return payload.sender?.login ?? payload.pusher?.name ?? null; }