diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9788f72 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2026-03-26 + +### Changed + +- Webhook handling now uses `@octokit/webhooks` `verify()` and `receive()` separately for clearer control flow. +- HTTP responses: `400` for invalid JSON body; `500` when a registered handler throws or rejects; `401` remains for invalid signature. + +### Documentation + +- Documented why runtime payload schema validation is not included, and how consumers can validate in their own handlers if desired. +- Expanded the **Responses** table in the README. + +[1.1.0]: https://github.com/nbaglivo/nextjs-github-webhooks/compare/v1.0.2...v1.1.0 diff --git a/README.md b/README.md index e8a810e..1a41f61 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,14 @@ Add your GitHub webhook secret to `.env.local`: GITHUB_WEBHOOK_SECRET=your_webhook_secret_here ``` +## Payload validation + +This library verifies the **HMAC signature** (`X-Hub-Signature-256`) against the raw request body so only requests that match your GitHub webhook secret are accepted. It does **not** run runtime schema validation on the parsed JSON (for example with Zod or similar). + +**Why:** A schema layer would add dependencies and ongoing maintenance beyond what `@octokit/webhooks` already provides as TypeScript types. For signed webhooks, the practical trust boundary is authenticity: the payload is what GitHub sent for that delivery. + +**If you want to be extra defensive**—for example strict checks before branching on nested fields, compliance requirements, or guarding against unexpected shapes—validate `ctx.payload` inside your own handlers using whatever fits your project (Zod, manual guards, etc.). That stays optional and avoids pulling validation libraries into every consumer. + ## API ### `createGitHubWebhookHandler(options)` @@ -97,11 +105,12 @@ handlers: { ## Responses -| Status | Condition | -| ------ | --------------------------------- | -| `400` | Missing headers (`x-hub-signature-256`, `x-github-event`, or `x-github-delivery`) | -| `401` | Invalid signature | -| `200` | Webhook processed successfully | +| Status | Condition | +| ------ | --------- | +| `400` | Missing required headers (`x-hub-signature-256`, `x-github-event`, or `x-github-delivery`), or body is not valid JSON | +| `401` | Invalid signature | +| `500` | A registered handler threw or rejected | +| `200` | Webhook processed successfully | ## License diff --git a/package.json b/package.json index b71aa02..42b3058 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextjs-github-webhooks", - "version": "1.0.2", + "version": "1.1.0", "description": "Lightweight integration for GitHub webhooks in Next.js", "license": "MIT", "author": "Nicolas Baglivo (https://nbaglivo.dev)", @@ -16,7 +16,8 @@ "files": [ "dist", "README.md", - "LICENSE" + "LICENSE", + "CHANGELOG.md" ], "scripts": { "build": "tsc", diff --git a/src/index.test.ts b/src/index.test.ts index 3c7f8a7..5c05349 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createGitHubWebhookHandler } from "./index"; -const { mockVerifyAndReceive, mockHandlers } = vi.hoisted(() => ({ - mockVerifyAndReceive: vi.fn(), +const { mockVerify, mockReceive, mockHandlers } = vi.hoisted(() => ({ + mockVerify: vi.fn(), + mockReceive: vi.fn(), mockHandlers: {} as Record void>, })); @@ -11,7 +12,8 @@ vi.mock("@octokit/webhooks", () => ({ on: vi.fn((event: string, fn: (evt: { id: string; payload: unknown }) => void) => { mockHandlers[event] = fn; }), - verifyAndReceive: mockVerifyAndReceive, + verify: mockVerify, + receive: mockReceive, })), })); @@ -41,7 +43,8 @@ describe("createGitHubWebhookHandler", () => { beforeEach(() => { vi.clearAllMocks(); Object.keys(mockHandlers).forEach((k) => delete mockHandlers[k]); - mockVerifyAndReceive.mockResolvedValue(undefined); + mockVerify.mockResolvedValue(true); + mockReceive.mockResolvedValue(undefined); }); it("returns 400 when x-hub-signature-256 header is missing", async () => { @@ -56,7 +59,7 @@ describe("createGitHubWebhookHandler", () => { expect(res.status).toBe(400); expect(data).toEqual({ error: "Missing headers" }); - expect(mockVerifyAndReceive).not.toHaveBeenCalled(); + expect(mockVerify).not.toHaveBeenCalled(); }); it("returns 400 when x-github-event header is missing", async () => { @@ -71,7 +74,7 @@ describe("createGitHubWebhookHandler", () => { expect(res.status).toBe(400); expect(data).toEqual({ error: "Missing headers" }); - expect(mockVerifyAndReceive).not.toHaveBeenCalled(); + expect(mockVerify).not.toHaveBeenCalled(); }); it("returns 400 when x-github-delivery header is missing", async () => { @@ -86,11 +89,11 @@ describe("createGitHubWebhookHandler", () => { expect(res.status).toBe(400); expect(data).toEqual({ error: "Missing headers" }); - expect(mockVerifyAndReceive).not.toHaveBeenCalled(); + expect(mockVerify).not.toHaveBeenCalled(); }); - it("returns 401 when verifyAndReceive throws (invalid signature)", async () => { - mockVerifyAndReceive.mockRejectedValueOnce(new Error("Invalid signature")); + it("returns 401 when verify returns false (signature does not match)", async () => { + mockVerify.mockResolvedValueOnce(false); const handler = createGitHubWebhookHandler({ secret: "test-secret", handlers: {}, @@ -101,12 +104,36 @@ describe("createGitHubWebhookHandler", () => { expect(res.status).toBe(401); expect(data).toEqual({ error: "Invalid signature" }); - expect(mockVerifyAndReceive).toHaveBeenCalledWith({ - id: "delivery-123", - name: "push", - signature: "sha256=abc", - payload: '{"ref":"refs/heads/main"}', + expect(mockVerify).toHaveBeenCalledWith('{"ref":"refs/heads/main"}', "sha256=abc"); + expect(mockReceive).not.toHaveBeenCalled(); + }); + + it("returns 400 when body is not valid JSON", async () => { + const handler = createGitHubWebhookHandler({ + secret: "test-secret", + handlers: {}, }); + + const res = await handler(createRequest({ body: "{not-json" })); + const data = await res.json(); + + expect(res.status).toBe(400); + expect(data).toEqual({ error: "Invalid JSON payload" }); + expect(mockReceive).not.toHaveBeenCalled(); + }); + + it("returns 500 when receive rejects because a handler failed", async () => { + mockReceive.mockRejectedValueOnce(new Error("database unavailable")); + const handler = createGitHubWebhookHandler({ + secret: "test-secret", + handlers: {}, + }); + + const res = await handler(createRequest()); + const data = await res.json(); + + expect(res.status).toBe(500); + expect(data).toEqual({ error: "Webhook handler failed" }); }); it("returns 200 with received: true when signature is valid", async () => { @@ -124,9 +151,9 @@ describe("createGitHubWebhookHandler", () => { it("invokes the registered handler with id and payload when event is received", async () => { const pushHandler = vi.fn().mockResolvedValue(undefined); - mockVerifyAndReceive.mockImplementation(async (opts: { id: string; name: string; payload: string }) => { - const handler = mockHandlers[opts.name]; - if (handler) await handler({ id: opts.id, payload: JSON.parse(opts.payload) }); + mockReceive.mockImplementation(async (evt: { id: string; name: string; payload: unknown }) => { + const fn = mockHandlers[evt.name]; + if (fn) await fn({ id: evt.id, payload: evt.payload }); }); const handler = createGitHubWebhookHandler({ diff --git a/src/index.ts b/src/index.ts index 41d8785..ad04911 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,17 +29,32 @@ export function createGitHubWebhookHandler(options: Options) { const body = await req.text(); + // https://github.com/octokit/webhooks.js#webhooksverify + if (!(await webhooks.verify(body, signature))) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + let payload: unknown; try { - await webhooks.verifyAndReceive({ + payload = JSON.parse(body); + } catch { + return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + // https://github.com/octokit/webhooks.js#webhooksreceive + try { + await webhooks.receive({ id: delivery, - name: event, - signature, - payload: body, + name: event as any, + payload: payload as any, }); } catch { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + return NextResponse.json( + { error: "Webhook handler failed" }, + { status: 500 } + ); } return NextResponse.json({ received: true }); - } + }; }