diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6447450453..76d76d6909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,7 @@ jobs: NEXTAUTH_SECRET: test-secret JWT_SECRET: test-jwt-secret DATABASE_URL: postgresql://localhost:5432/hive_test_unit + DIRECT_URL: postgresql://localhost:5432/hive_test_unit run: npm run test:unit integration-tests: diff --git a/src/__tests__/integration/api/github-app-webhook.test.ts b/src/__tests__/integration/api/github-app-webhook.test.ts index 00bfd00f4e..9f983dfec1 100644 --- a/src/__tests__/integration/api/github-app-webhook.test.ts +++ b/src/__tests__/integration/api/github-app-webhook.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { POST } from "@/app/api/github/app/webhook/route"; +import * as encryptionModule from "@/lib/encryption"; import { db } from "@/lib/db"; import { NextRequest } from "next/server"; import { @@ -137,6 +138,8 @@ describe("GitHub App Webhook - POST /api/github/app/webhook", () => { const body = JSON.stringify(payload); const validSignature = computeValidWebhookSignature(mockWebhookSecret, body); + const timingSafeSpy = vi.spyOn(encryptionModule, "timingSafeEqual"); + const request = createGitHubAppWebhookRequest( webhookUrl, payload, @@ -146,6 +149,13 @@ describe("GitHub App Webhook - POST /api/github/app/webhook", () => { const response = await POST(request as NextRequest); expect(response.status).toBe(200); + expect(timingSafeSpy).toHaveBeenCalledOnce(); + // First arg is computed digest, second is the incoming signature header + const [calledDigest, calledSignature] = timingSafeSpy.mock.calls[0]; + expect(calledDigest).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(calledSignature).toBe(validSignature); + + timingSafeSpy.mockRestore(); }); }); @@ -520,7 +530,7 @@ describe("GitHub App Webhook - POST /api/github/app/webhook", () => { db.user.findUnique = originalFindUnique; }); - test.skip("should handle missing GITHUB_WEBHOOK_SECRET environment variable", async () => { + test("should handle missing GITHUB_WEBHOOK_SECRET environment variable", async () => { // Remove env var delete process.env.GITHUB_WEBHOOK_SECRET; @@ -536,7 +546,7 @@ describe("GitHub App Webhook - POST /api/github/app/webhook", () => { const response = await POST(request as NextRequest); - // Should fail without secret + // Should return controlled 500, not throw an unhandled exception expect(response.status).toBe(500); // Restore env var diff --git a/src/app/api/github/app/webhook/route.ts b/src/app/api/github/app/webhook/route.ts index 6777b99277..7490074a48 100644 --- a/src/app/api/github/app/webhook/route.ts +++ b/src/app/api/github/app/webhook/route.ts @@ -1,15 +1,13 @@ import crypto from "crypto"; import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "@/lib/encryption"; export async function POST(req: NextRequest) { + const secret = process.env.GITHUB_WEBHOOK_SECRET; + if (!secret) { + return new Response("Webhook secret not configured", { status: 500 }); + } - console.log("🔴 Github app webhook received"); - - console.log("🔴 Github app webhook headers", req.headers); - - console.log("🔴 Github app webhook body", req); - - const secret = process.env.GITHUB_WEBHOOK_SECRET!; const signature = req.headers.get("x-hub-signature-256") || ""; const body = await req.text(); @@ -17,10 +15,12 @@ export async function POST(req: NextRequest) { const hmac = crypto.createHmac("sha256", secret); const digest = `sha256=${hmac.update(body).digest("hex")}`; - if (signature !== digest) { + if (!timingSafeEqual(digest, signature)) { return NextResponse.json({ message: "Invalid signature" }, { status: 401 }); } + console.log("🔴 Github app webhook received"); + const event = req.headers.get("x-github-event"); const payload = JSON.parse(body); @@ -30,4 +30,4 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ success: true }); -} \ No newline at end of file +}