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
42 changes: 42 additions & 0 deletions src/app/api/pr-followup/webhook/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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");
});
});
7 changes: 4 additions & 3 deletions src/app/api/pr-followup/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down