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
52 changes: 52 additions & 0 deletions src/app/api/webhooks/github/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 28 additions & 1 deletion src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
Expand Down
Loading