diff --git a/src/app/api/pr-followup/webhook/route.test.ts b/src/app/api/pr-followup/webhook/route.test.ts index 3cdc8a5..62aecc8 100644 --- a/src/app/api/pr-followup/webhook/route.test.ts +++ b/src/app/api/pr-followup/webhook/route.test.ts @@ -45,6 +45,7 @@ describe("POST /api/pr-followup/webhook", () => { resetAuthCaches(); vi.clearAllMocks(); mocks.prFixQueueClient.mockReturnValue({}); + mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 }); }); it("returns 401 when no auth header is present", async () => { @@ -193,4 +194,45 @@ describe("POST /api/pr-followup/webhook", () => { const body = await res.json(); expect(body.error).toBe("Webhook processing failed"); }); + + it("preserves body integrity when authorizeRequest consumes the body stream", async () => { + // Regression test for #656: if authorizeRequest ever reads the request body, + // the webhook handler must still verify the HMAC against the original payload. + // This is ensured by reading request.arrayBuffer() before calling authorizeRequest. + + const prBody = { + review: { id: 1, body: "Looks good", state: "APPROVED" }, + pull_request: { + number: 42, + html_url: "https://github.com/org/repo/pull/42", + title: "Fix bug", + user: { login: "bot-user" }, + head: { ref: "fix/issue-1" }, + base: { repo: { full_name: "org/repo" } }, + }, + }; + + // Create a request where the body can only be consumed once. + const originalRequest = new Request("http://localhost/api/pr-followup/webhook", { + method: "POST", + headers: { + Authorization: `Bearer ${mockToken}`, + "x-github-event": "pull_request_review", + }, + body: JSON.stringify(prBody), + }); + + // Clone the request to verify body content independently. + const clonedRequest = originalRequest.clone(); + const bodyBeforeAuth = await clonedRequest.arrayBuffer(); + + const res = await POST(originalRequest); + + expect(res.status).toBe(200); + expect(mocks.processPrFollowupEvents).toHaveBeenCalled(); + + // Verify the body that was read matches what we sent (not empty). + const bodyStr = Buffer.from(bodyBeforeAuth).toString(); + expect(bodyStr).toContain("Looks good"); + }); }); diff --git a/src/app/api/pr-followup/webhook/route.ts b/src/app/api/pr-followup/webhook/route.ts index 59e4145..6867d23 100644 --- a/src/app/api/pr-followup/webhook/route.ts +++ b/src/app/api/pr-followup/webhook/route.ts @@ -185,14 +185,15 @@ export async function POST(request: Request) { return errorResponse("Missing x-github-event header", 400); } + // Read raw body before authorization so that if authorizeRequest ever + // consumes the body stream, HMAC verification still operates on the real payload. + const rawBody = await request.arrayBuffer(); + // Authenticate the request (Bearer token, Basic Auth, or OIDC session) const auth = await authorizeRequest(request); if (!auth.authorized) { return errorResponse("Unauthorized", 401); } - - // Read raw body once for signature verification and parsing - const rawBody = await request.arrayBuffer(); const payload = Buffer.from(rawBody); // Webhook signature verification: fail-closed by default.