From ce53a08bfaf58b81df60e935f42b548415ae18fa Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 16:31:53 -0400 Subject: [PATCH 1/7] feat: add new `verifyAndParse()` method This method is almost identical to `verifyAndReceive()`, the difference being that this function won't trigger the event handler, so it can be executed at a later time. Fixes #379 --- src/verify-and-parse.ts | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/verify-and-parse.ts diff --git a/src/verify-and-parse.ts b/src/verify-and-parse.ts new file mode 100644 index 00000000..9bd2aab2 --- /dev/null +++ b/src/verify-and-parse.ts @@ -0,0 +1,44 @@ +import { verifyWithFallback } from "@octokit/webhooks-methods"; + +import type { + EmitterWebhookEvent, + EmitterWebhookEventWithStringPayloadAndSignature, + WebhookError, +} from "./types.ts"; + +export async function verifyAndParse( + secret: string, + event: EmitterWebhookEventWithStringPayloadAndSignature, + additionalSecrets?: string[] | undefined, +): Promise { + // verify will validate that the secret is not undefined + const matchesSignature = await verifyWithFallback( + secret, + event.payload, + event.signature, + additionalSecrets, + ).catch(() => false); + + if (!matchesSignature) { + const error = new Error( + "[@octokit/webhooks] signature does not match event payload and secret", + ); + + return Object.assign(error, { event, status: 400 }) as WebhookError; + } + + let payload: EmitterWebhookEvent["payload"]; + try { + payload = JSON.parse(event.payload); + } catch (error: any) { + error.message = "Invalid JSON"; + error.status = 400; + throw new AggregateError([error], error.message); + } + + return { + id: event.id, + name: event.name, + payload, + } as EmitterWebhookEvent; +} From 83e88507138ea1395bde5e7ad7cce443fcf8076e Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 16:54:04 -0400 Subject: [PATCH 2/7] add test + integration --- src/index.ts | 13 +++++++++++++ test/integration/smoke.test.ts | 1 + test/integration/webhooks.test.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/index.ts b/src/index.ts index f5aaf4b9..b50d4809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import type { WebhookEventHandlerError, EmitterWebhookEventWithStringPayloadAndSignature, } from "./types.ts"; +import { verifyAndParse } from "./verify-and-parse.ts"; export { createNodeMiddleware } from "./middleware/node/index.ts"; export { createWebMiddleware } from "./middleware/web/index.ts"; @@ -47,6 +48,9 @@ class Webhooks { public verifyAndReceive: ( options: EmitterWebhookEventWithStringPayloadAndSignature, ) => Promise; + verifyAndParse: ( + event: EmitterWebhookEventWithStringPayloadAndSignature, + ) => Promise; constructor(options: Options & { secret: string }) { if (!options || !options.secret) { @@ -73,6 +77,15 @@ class Webhooks { this.removeListener = state.eventHandler.removeListener; this.receive = state.eventHandler.receive; this.verifyAndReceive = verifyAndReceive.bind(null, state); + this.verifyAndParse = async ( + event: EmitterWebhookEventWithStringPayloadAndSignature, + ) => { + return verifyAndParse( + state.secret, + event, + state.additionalSecrets, + ) as Promise; + }; } } diff --git a/test/integration/smoke.test.ts b/test/integration/smoke.test.ts index 116bc0e3..040000c4 100644 --- a/test/integration/smoke.test.ts +++ b/test/integration/smoke.test.ts @@ -24,6 +24,7 @@ it("check exports of @octokit/webhooks", () => { assert(typeof api.removeListener === "function"); assert(typeof api.receive === "function"); assert(typeof api.verifyAndReceive === "function"); + assert(typeof api.verifyAndParse === "function"); assert(typeof api.onAny === "function"); assert(warned === false); diff --git a/test/integration/webhooks.test.ts b/test/integration/webhooks.test.ts index 2d8b45ee..2d5e4c8f 100644 --- a/test/integration/webhooks.test.ts +++ b/test/integration/webhooks.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { sign } from "@octokit/webhooks-methods"; import { Webhooks } from "../../src/index.ts"; +import { expect } from "vitest"; const pushEventPayloadString = readFileSync( "test/fixtures/push-payload.json", @@ -82,6 +83,24 @@ describe("Webhooks", () => { } }); + it("webhooks.verifyAndParse(event) with correct signature", async () => { + const secret = "mysecret"; + const webhooks = new Webhooks({ secret }); + + const event = await webhooks.verifyAndParse({ + id: "1", + name: "push", + payload: pushEventPayloadString, + signature: await sign(secret, pushEventPayloadString), + }); + assert(typeof event === "object"); + expect(event).toEqual({ + name: "push", + id: "1", + payload: JSON.parse(pushEventPayloadString), + }); + }); + it("webhooks.receive(error)", async () => { const webhooks = new Webhooks({ secret: "mysecret" }); From 23e044335c779de9c58a4299887f0d2e73ac7f37 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 17:22:25 -0400 Subject: [PATCH 3/7] refactor: make verifyAndReceive extend verifyAndParse under the hood --- src/verify-and-receive.ts | 40 ++++----------------------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/src/verify-and-receive.ts b/src/verify-and-receive.ts index f89ae19e..0206560d 100644 --- a/src/verify-and-receive.ts +++ b/src/verify-and-receive.ts @@ -1,10 +1,7 @@ -import { verifyWithFallback } from "@octokit/webhooks-methods"; - +import { verifyAndParse } from "./verify-and-parse.ts"; import type { - EmitterWebhookEvent, EmitterWebhookEventWithStringPayloadAndSignature, State, - WebhookError, } from "./types.ts"; import type { EventHandler } from "./event-handler/index.ts"; @@ -12,36 +9,7 @@ export async function verifyAndReceive( state: State & { secret: string; eventHandler: EventHandler }, event: EmitterWebhookEventWithStringPayloadAndSignature, ): Promise { - // verify will validate that the secret is not undefined - const matchesSignature = await verifyWithFallback( - state.secret, - event.payload, - event.signature, - state.additionalSecrets, - ).catch(() => false); - - if (!matchesSignature) { - const error = new Error( - "[@octokit/webhooks] signature does not match event payload and secret", - ); - - return state.eventHandler.receive( - Object.assign(error, { event, status: 400 }) as WebhookError, - ); - } - - let payload: EmitterWebhookEvent["payload"]; - try { - payload = JSON.parse(event.payload); - } catch (error: any) { - error.message = "Invalid JSON"; - error.status = 400; - throw new AggregateError([error], error.message); - } - - return state.eventHandler.receive({ - id: event.id, - name: event.name, - payload, - } as EmitterWebhookEvent); + return state.eventHandler.receive( + await verifyAndParse(state.secret, event, state.additionalSecrets), + ); } From d35dcffd4b71550bc8b12ab6e9283de23d8e9064 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 17:23:34 -0400 Subject: [PATCH 4/7] remove un-needed type casting --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b50d4809..d9378ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,7 @@ class Webhooks { state.secret, event, state.additionalSecrets, - ) as Promise; + ); }; } } From 3f83fde009f8d3f5300e7fa33086268102c0ff47 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 17:30:33 -0400 Subject: [PATCH 5/7] add deepEqual to test runner --- test/integration/webhooks.test.ts | 5 ++--- test/testrunner.ts | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/integration/webhooks.test.ts b/test/integration/webhooks.test.ts index 2d5e4c8f..53fef052 100644 --- a/test/integration/webhooks.test.ts +++ b/test/integration/webhooks.test.ts @@ -1,9 +1,8 @@ -import { describe, it, assert } from "../testrunner.ts"; +import { describe, it, assert, deepEqual } from "../testrunner.ts"; import { readFileSync } from "node:fs"; import { sign } from "@octokit/webhooks-methods"; import { Webhooks } from "../../src/index.ts"; -import { expect } from "vitest"; const pushEventPayloadString = readFileSync( "test/fixtures/push-payload.json", @@ -94,7 +93,7 @@ describe("Webhooks", () => { signature: await sign(secret, pushEventPayloadString), }); assert(typeof event === "object"); - expect(event).toEqual({ + deepEqual(event,{ name: "push", id: "1", payload: JSON.parse(pushEventPayloadString), diff --git a/test/testrunner.ts b/test/testrunner.ts index 8650a767..72742ea0 100644 --- a/test/testrunner.ts +++ b/test/testrunner.ts @@ -2,7 +2,7 @@ declare global { var Bun: any; } -let describe: Function, it: Function, assert: Function; +let describe: Function, it: Function, assert: Function, deepEqual: Function; if ("Bun" in globalThis) { describe = function describe(name: string, fn: Function) { return globalThis.Bun.jest(caller()).describe(name, fn); @@ -35,10 +35,12 @@ if ("Bun" in globalThis) { describe = nodeTest.describe; it = nodeTest.it; assert = nodeAssert.strict; + deepEqual = nodeAssert.deepStrictEqual; } else if (process.env.VITEST_WORKER_ID) { describe = await import("vitest").then((module) => module.describe); it = await import("vitest").then((module) => module.it); assert = await import("vitest").then((module) => module.assert); + deepEqual = await import("vitest").then((module) => (expected: any, actual: any) => module.expect(actual).toEqual(expected)); } else { const nodeTest = await import("node:test"); const nodeAssert = await import("node:assert"); @@ -46,6 +48,7 @@ if ("Bun" in globalThis) { describe = nodeTest.describe; it = nodeTest.it; assert = nodeAssert.strict; + deepEqual = nodeAssert.deepStrictEqual; } -export { describe, it, assert }; +export { describe, it, assert, deepEqual }; From a0b40c54d52784c37acb820cbcfe67ca0f069bd5 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 17:31:24 -0400 Subject: [PATCH 6/7] style: prettier --- src/index.ts | 6 +----- test/integration/webhooks.test.ts | 2 +- test/testrunner.ts | 5 ++++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index d9378ad3..438b4fca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,11 +80,7 @@ class Webhooks { this.verifyAndParse = async ( event: EmitterWebhookEventWithStringPayloadAndSignature, ) => { - return verifyAndParse( - state.secret, - event, - state.additionalSecrets, - ); + return verifyAndParse(state.secret, event, state.additionalSecrets); }; } } diff --git a/test/integration/webhooks.test.ts b/test/integration/webhooks.test.ts index 53fef052..7825efd7 100644 --- a/test/integration/webhooks.test.ts +++ b/test/integration/webhooks.test.ts @@ -93,7 +93,7 @@ describe("Webhooks", () => { signature: await sign(secret, pushEventPayloadString), }); assert(typeof event === "object"); - deepEqual(event,{ + deepEqual(event, { name: "push", id: "1", payload: JSON.parse(pushEventPayloadString), diff --git a/test/testrunner.ts b/test/testrunner.ts index 72742ea0..f9dec664 100644 --- a/test/testrunner.ts +++ b/test/testrunner.ts @@ -40,7 +40,10 @@ if ("Bun" in globalThis) { describe = await import("vitest").then((module) => module.describe); it = await import("vitest").then((module) => module.it); assert = await import("vitest").then((module) => module.assert); - deepEqual = await import("vitest").then((module) => (expected: any, actual: any) => module.expect(actual).toEqual(expected)); + deepEqual = await import("vitest").then( + (module) => (expected: any, actual: any) => + module.expect(actual).toEqual(expected), + ); } else { const nodeTest = await import("node:test"); const nodeAssert = await import("node:assert"); From da3fd1fc28944afcffd12a23174a5567e38cec3a Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Wed, 4 Jun 2025 17:34:35 -0400 Subject: [PATCH 7/7] add deepEqual for bun --- test/testrunner.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/testrunner.ts b/test/testrunner.ts index f9dec664..69e93596 100644 --- a/test/testrunner.ts +++ b/test/testrunner.ts @@ -13,6 +13,11 @@ if ("Bun" in globalThis) { assert = function assert(value: unknown, message?: string) { return globalThis.Bun.jest(caller()).expect(value, message); }; + deepEqual = function deepEqual(expected: any, actual: any, message?: string) { + return globalThis.Bun.jest(caller()) + .expect(actual) + .toEqual(expected, message); + }; /** Retrieve caller test file. */ function caller() { const Trace = Error as unknown as {