diff --git a/AGENTS.md b/AGENTS.md index c26d08e..4971232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Purpose `@corbits/webhooks` is signed webhook ingress for Interchange workflows. It -owns the hook routes, the Slack, Standard Webhooks and bearer verifiers, hook +owns the hook routes, the Slack, Standard Webhooks, X-Hub-Signature and bearer verifiers, hook resolution, and delivery of the body as trigger mail to a live run. It does not own credentials (they are Interchange credentials with `metadata.webhook`), storage of its own, or scheduling (`@corbits/cron`). @@ -11,7 +11,7 @@ storage of its own, or scheduling (`@corbits/cron`). ## Layout - `src/routes.ts`: `createHookRoutes`, the Hono sub-app for `/api/hooks`. -- `src/verify.ts`: the three signature verifiers. +- `src/verify.ts`: the four signature verifiers. - `src/resolve.ts`: credential lookup and target run resolution. - `src/hooks.ts`: the request handler that ties verify, resolve and deliver. - `src/deliver.ts`: `createRunTriggerDeliverer` and the unroutable errors. diff --git a/README.md b/README.md index 4ecad21..bfa48af 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # @corbits/webhooks -Signed webhook ingress for Interchange workflows: verifies a Slack, Standard Webhooks or bearer request against a hub credential, then delivers the body as trigger mail to a live workflow run. A Corbits hub module, mounted as Hono routes on the Interchange hub (Interchange's multi-tenant control plane, where a principal is an identity and a grant is a permission it holds) and backed by its Postgres. +Signed webhook ingress for Interchange workflows: verifies a Slack, Standard Webhooks, X-Hub-Signature or bearer request against a hub credential, then delivers the body as trigger mail to a live workflow run. A Corbits hub module, mounted as Hono routes on the Interchange hub (Interchange's multi-tenant control plane, where a principal is an identity and a grant is a permission it holds) and backed by its Postgres. ## Why @corbits/webhooks? 1. **Secrets stay in the hub.** Each hook is an ordinary Interchange credential with `metadata.webhook`. The only thing this package stores is a short-lived table of seen delivery ids. 2. **Runs never borrow a person's authority.** The workflow runs as its own run principal (the identity Interchange derives for each run) through the hub's mail-triggered grant path, never as the credential owner. -3. **Three verifiers, no unsigned mode.** Slack signatures, Standard Webhooks HMAC and bearer tokens. A credential without a verifier matches no hook. +3. **Four verifiers, no unsigned mode.** Slack signatures, Standard Webhooks HMAC, X-Hub-Signature HMAC (GitHub, Bitbucket Data Center) and bearer tokens. A credential without a verifier matches no hook. Use it to start workflows from third-party events. It does not fire on a schedule; `@corbits/cron` does, through a deliverer of the same shape. @@ -35,21 +35,38 @@ The `@intx/*` peers are `^0.4.0`, `drizzle-orm` is `^0.45.1`, `hono` is `^4.11.9 | `POST /api/hooks/:tenantId/:name` | Credential name, scoped to the tenant. | | `POST /api/hooks` | `x-webhook-hook` header or `?hook=`; tenant from `x-tenant-id` or `?tenant=`. | -Responses: `202` delivered, `200 { challenge }` for Slack `url_verification`, `401` bad signature, `404` unknown hook (misses and name collisions look the same), `409` more than one live run matches, `503` no live run or delivery failed, `500` credential lookup failed. +Responses: `202` delivered, `200 { challenge }` for Slack `url_verification`, `400` an X-Hub-Signature delivery without its event or id, or not JSON, `401` bad signature, `404` unknown hook (misses and name collisions look the same), `409` more than one live run matches, `503` no live run or delivery failed, `500` credential lookup failed. ### `metadata.webhook` -| Field | Value | -| ---------- | -------------------------------------------------------------- | -| `verify` | `"bearer"`, `"standard-webhooks"` or `"slack"`. Required. | -| `workflow` | Name of a live deployment's definition or asset in the tenant. | -| `to` | A live run address in the tenant, instead of `workflow`. | +| Field | Value | +| ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| `verify` | `"bearer"`, `"standard-webhooks"`, `"slack"` or `"x-hub-signature"`. Required. | +| `headers` | `{ signature, event, delivery }`: the request headers carrying each. Required with `x-hub-signature`, ignored otherwise. | +| `workflow` | Name of a live deployment's definition or asset in the tenant. | +| `to` | A live run address in the tenant, instead of `workflow`. | With neither set, the credential name is matched against definition and asset names, then the tenant's only live run. A live run has status `deployed` or `running`. With `standard-webhooks`, the secret is base64-decoded after stripping an optional `whsec_` prefix, as the spec requires. An unprefixed secret that is valid base64 is tried decoded first, then as raw bytes, which is how 0.1 read it; one that is not base64 is only tried raw. An empty secret never verifies, under any scheme. -A replayed delivery gets `409`. Standard Webhooks deliveries are keyed on the credential and `webhook-id`, Slack on the credential and signature, each kept until its timestamp leaves the ±300s window. The seen-set is the `replay` table in the `webhooks` schema, created by `runWebhookMigrations` (see [Using with Interchange](#using-with-interchange)), so every replica sharing the database rejects the replay. A failed delivery releases its key so the sender's retry goes through. If the hub crashes between claiming a key and forwarding the delivery, the key stays held until it expires with its timestamp window, so the sender's retries get `409` until then. Bearer requests carry no id and are not deduplicated. +With `x-hub-signature`, the `signature` header must be `sha256=` and the hex HMAC-SHA256 of the raw body under the secret. A delivery without its `event` or `delivery` header, or whose body is not JSON, gets `400`. The mail is `{"event":…,"deliveryId":…,"payload":}`, since the body alone does not say which event it is. GitHub sends `x-hub-signature-256`, `x-github-event` and `x-github-delivery`; Bitbucket Data Center sends `x-hub-signature`, `x-event-key` and `x-request-id`: + +```json +{ + "webhook": { + "verify": "x-hub-signature", + "workflow": "my-workflow", + "headers": { + "signature": "x-hub-signature-256", + "event": "x-github-event", + "delivery": "x-github-delivery" + } + } +} +``` + +A replayed delivery gets `409`. Standard Webhooks deliveries are keyed on the credential and `webhook-id`, Slack on the credential and signature, each kept until its timestamp leaves the ±300s window; X-Hub-Signature deliveries on the credential and delivery id, kept for 24 hours. The seen-set is the `replay` table in the `webhooks` schema, created by `runWebhookMigrations` (see [Using with Interchange](#using-with-interchange)), so every replica sharing the database rejects the replay. A failed delivery releases its key so the sender's retry goes through. If the hub crashes between claiming a key and forwarding the delivery, the key stays held until it expires, so the sender's retries get `409` until then; for Standard Webhooks and Slack that is the timestamp window, for X-Hub-Signature the full 24 hours, since its delivery id carries no timestamp. Bearer requests carry no id and are not deduplicated. Signatures are checked over the raw request bytes, and the body is forwarded unchanged. Bodies over 1 MiB get `413`; a verified body that is not UTF-8 gets `415`. Bot tokens for chat integrations belong in the workflow's `credentialBindings`, not in the signing secret. diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 0d101ad..26e1c8f 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; import { Hono } from "hono"; import { createHookApp, MAX_BODY_BYTES } from "./hooks.js"; @@ -57,6 +58,24 @@ async function slackRequest(secret: string, body: string) { }; } +const GITHUB_HEADERS = { + signature: "x-hub-signature-256", + event: "x-github-event", + delivery: "x-github-delivery", +}; + +function githubRequest(secret: string, body: string, delivery = "del-1") { + return { + method: "POST", + body, + headers: { + "x-hub-signature-256": `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`, + "x-github-event": "pull_request", + "x-github-delivery": delivery, + }, + }; +} + function mount(opts?: { loaded?: LoadedHook | "ambiguous" | undefined; runs?: LiveRun[]; @@ -322,6 +341,64 @@ describe("createHookApp", () => { expect((await app.request("/api/hooks/slack", init)).status).toBe(202); }); + test("mails an x-hub-signature delivery with its event and id, once", async () => { + const { app, delivered } = mount({ + loaded: hook({ + meta: { + verify: "x-hub-signature", + headers: GITHUB_HEADERS, + to: JIMMY.address, + }, + }), + runs: [JIMMY], + }); + const init = githubRequest("s3cret", `{"action":"opened"}`); + expect((await app.request("/api/hooks/crd_1", init)).status).toBe(202); + expect((await app.request("/api/hooks/crd_1", init)).status).toBe(409); + expect(delivered).toEqual([ + { + to: JIMMY.address, + content: `{"event":"pull_request","deliveryId":"del-1","payload":{"action":"opened"}}`, + tenantId: "tnt_1", + }, + ]); + }); + + test("401s an x-hub-signature delivery signed with another secret", async () => { + const { app, delivered } = mount({ + loaded: hook({ + meta: { verify: "x-hub-signature", headers: GITHUB_HEADERS }, + }), + runs: [JIMMY], + }); + const res = await app.request( + "/api/hooks/crd_1", + githubRequest("other", "{}"), + ); + expect(res.status).toBe(401); + expect(delivered).toHaveLength(0); + }); + + test("400s an x-hub-signature delivery without its id or with a body that is not JSON", async () => { + const { app, delivered } = mount({ + loaded: hook({ + meta: { verify: "x-hub-signature", headers: GITHUB_HEADERS }, + }), + runs: [JIMMY], + }); + const missing = await app.request( + "/api/hooks/crd_1", + githubRequest("s3cret", "{}", ""), + ); + const notJson = await app.request( + "/api/hooks/crd_1", + githubRequest("s3cret", "not json"), + ); + expect(missing.status).toBe(400); + expect(notJson.status).toBe(400); + expect(delivered).toHaveLength(0); + }); + test("404 when the hook is ambiguous", async () => { const { app } = mount({ loaded: "ambiguous" }); const res = await app.request("/api/hooks/slack", { diff --git a/src/hooks.ts b/src/hooks.ts index 2edf933..6d9442e 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -2,7 +2,7 @@ import { Hono, type Context } from "hono"; import { bodyLimit } from "hono/body-limit"; import type { MailDeliverer } from "./deliver.js"; -import type { LoadedHook, LiveRun } from "./resolve.js"; +import type { HookHeaders, LoadedHook, LiveRun } from "./resolve.js"; import { pickDestination } from "./resolve.js"; import type { ReplayStore } from "./replay.js"; import { @@ -10,6 +10,7 @@ import { verifyBearer, verifySlack, verifyStandardWebhooks, + verifyXHubSignature, } from "./verify.js"; export type LoadHook = ( @@ -29,6 +30,9 @@ type HookAppOptions = { /** Largest request body accepted, checked before anything is hashed. */ export const MAX_BODY_BYTES = 1024 * 1024; +/** How long an `x-hub-signature` delivery id is held; senders redeliver within hours. */ +export const DELIVERY_ID_TTL_S = 24 * 60 * 60; + export function createHookApp(opts: HookAppOptions): Hono { const app = new Hono(); app.use( @@ -79,6 +83,12 @@ async function handle(c: Context, opts: HookAppOptions) { ok = await verifyStandardWebhooks(loaded.secret, c.req.raw.headers, raw); } else if (loaded.meta.verify === "slack") { ok = await verifySlack(loaded.secret, c.req.raw.headers, raw); + } else if (loaded.meta.verify === "x-hub-signature") { + ok = await verifyXHubSignature( + loaded.secret, + c.req.raw.headers.get(loaded.meta.headers.signature), + raw, + ); } if (!ok) return c.json({ error: "unauthorized" }, 401); @@ -93,6 +103,12 @@ async function handle(c: Context, opts: HookAppOptions) { return c.json({ error: "unsupported_body" }, 415); } + if (loaded.meta.verify === "x-hub-signature") { + const mail = xHubMail(loaded.meta.headers, c.req.raw.headers, body); + if (typeof mail !== "string") return c.json({ error: mail.error }, 400); + body = mail; + } + const replayKey = replayClaim(loaded, c.req.raw.headers); if (replayKey !== undefined) { let fresh: boolean; @@ -121,12 +137,20 @@ async function handle(c: Context, opts: HookAppOptions) { /** * Standard Webhooks senders reuse `webhook-id` across retries; Slack has no id, * so its signature stands in. Each key lives as long as its timestamp would - * still verify. Bearer hooks carry no nonce and cannot be deduplicated. + * still verify. An `x-hub-signature` delivery id has no timestamp, so it is + * held for `DELIVERY_ID_TTL_S`. Bearer hooks carry no nonce and cannot be + * deduplicated. */ function replayClaim( loaded: LoadedHook, headers: Headers, ): { nonce: string; expiresAt: Date } | undefined { + if (loaded.meta.verify === "x-hub-signature") { + return { + nonce: `x-hub-signature:${headers.get(loaded.meta.headers.delivery)}`, + expiresAt: new Date(Date.now() + DELIVERY_ID_TTL_S * 1000), + }; + } const [nonce, timestamp] = loaded.meta.verify === "standard-webhooks" ? [headers.get("webhook-id"), headers.get("webhook-timestamp")] @@ -194,6 +218,26 @@ async function forward( return c.json({ ok: true, to: dest.to }, 202); } +/** + * The signed body says nothing of which event it is or which delivery, so the + * mail wraps it with both, keeping the payload's raw JSON. + */ +function xHubMail( + names: HookHeaders, + headers: Headers, + body: string, +): string | { error: "missing_headers" | "invalid_json" } { + const event = headers.get(names.event); + const delivery = headers.get(names.delivery); + if (!event || !delivery) return { error: "missing_headers" }; + try { + JSON.parse(body); + } catch { + return { error: "invalid_json" }; + } + return `{"event":${JSON.stringify(event)},"deliveryId":${JSON.stringify(delivery)},"payload":${body}}`; +} + function emptyToUndef(value: string | undefined): string | undefined { if (value === undefined) return undefined; const trimmed = value.trim(); diff --git a/src/resolve.test.ts b/src/resolve.test.ts index 3c9a1b4..e1cd698 100644 --- a/src/resolve.test.ts +++ b/src/resolve.test.ts @@ -11,6 +11,38 @@ describe("parseWebhookMeta", () => { ).toEqual({ verify: "slack", workflow: "jimmy" }); }); + test("reads the headers an x-hub-signature hook names", () => { + const headers = { + signature: "x-hub-signature-256", + event: "x-github-event", + delivery: "x-github-delivery", + }; + expect( + parseWebhookMeta({ + webhook: { verify: "x-hub-signature", workflow: "triage", headers }, + }), + ).toEqual({ verify: "x-hub-signature", workflow: "triage", headers }); + expect( + parseWebhookMeta({ webhook: { verify: "x-hub-signature" } }), + ).toBeUndefined(); + expect( + parseWebhookMeta({ + webhook: { + verify: "x-hub-signature", + headers: { ...headers, event: "" }, + }, + }), + ).toBeUndefined(); + expect( + parseWebhookMeta({ + webhook: { + verify: "x-hub-signature", + headers: { ...headers, event: "x-github event" }, + }, + }), + ).toBeUndefined(); + }); + test("ignores credentials that are not hooks", () => { expect(parseWebhookMeta(undefined)).toBeUndefined(); expect(parseWebhookMeta({ provider: "giphy" })).toBeUndefined(); diff --git a/src/resolve.ts b/src/resolve.ts index 47a7f87..82ec450 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -7,14 +7,26 @@ import { import { HOOK_VERIFY, type HookVerify } from "./verify.js"; -export type WebhookMeta = { - verify: HookVerify; +type Destination = { /** Explicit onTrigger mail address. */ to?: string; /** Asset or definition name; selects a live deployment when `to` is omitted. */ workflow?: string; }; +/** Request headers carrying an `x-hub-signature` sender's signature, event name and delivery id. */ +export type HookHeaders = { + signature: string; + event: string; + delivery: string; +}; + +export type WebhookMeta = Destination & + ( + | { verify: Exclude } + | { verify: "x-hub-signature"; headers: HookHeaders } + ); + export type LoadedHook = { credentialId: string; credentialName: string; @@ -40,11 +52,35 @@ export function parseWebhookMeta(metadata: unknown): WebhookMeta | undefined { const to = typeof hook["to"] === "string" ? hook["to"].trim() : ""; const workflow = typeof hook["workflow"] === "string" ? hook["workflow"].trim() : ""; - return { - verify, + const destination: Destination = { ...(to !== "" ? { to } : {}), ...(workflow !== "" ? { workflow } : {}), }; + if (verify !== "x-hub-signature") return { verify, ...destination }; + const headers = parseHookHeaders(hook["headers"]); + return headers && { verify, headers, ...destination }; +} + +// RFC 7230 token, the rule `Headers.get` enforces. +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +function parseHookHeaders(value: unknown): HookHeaders | undefined { + if (value === null || typeof value !== "object") return undefined; + const rec = value as Record; + const [signature, event, delivery] = [ + rec["signature"], + rec["event"], + rec["delivery"], + ].map((name) => (typeof name === "string" ? name.trim() : "")); + if ( + !signature || + !event || + !delivery || + ![signature, event, delivery].every((name) => HEADER_NAME.test(name)) + ) { + return undefined; + } + return { signature, event, delivery }; } function isHookVerify(value: string): value is HookVerify { diff --git a/src/verify.test.ts b/src/verify.test.ts index 13545ed..a28b9de 100644 --- a/src/verify.test.ts +++ b/src/verify.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { verifyBearer, verifySlack, verifyStandardWebhooks } from "./verify.js"; +import { createHmac } from "node:crypto"; + +import { + verifyBearer, + verifySlack, + verifyStandardWebhooks, + verifyXHubSignature, +} from "./verify.js"; const bytes = (text: string) => new TextEncoder().encode(text); @@ -185,3 +192,21 @@ describe("verifySlack", () => { ); }); }); + +describe("verifyXHubSignature", () => { + const body = `{"action":"opened"}`; + const signed = `sha256=${createHmac("sha256", "s3cret").update(body).digest("hex")}`; + + test("accepts the hex HMAC of the raw body", async () => { + expect(await verifyXHubSignature("s3cret", signed, bytes(body))).toBe(true); + }); + + test("rejects a wrong secret, prefix or missing header", async () => { + expect(await verifyXHubSignature("other", signed, bytes(body))).toBe(false); + expect( + await verifyXHubSignature("s3cret", signed.slice(7), bytes(body)), + ).toBe(false); + expect(await verifyXHubSignature("s3cret", null, bytes(body))).toBe(false); + expect(await verifyXHubSignature("", signed, bytes(body))).toBe(false); + }); +}); diff --git a/src/verify.ts b/src/verify.ts index f8ea7a1..3d609db 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -1,4 +1,9 @@ -export const HOOK_VERIFY = ["bearer", "standard-webhooks", "slack"] as const; +export const HOOK_VERIFY = [ + "bearer", + "standard-webhooks", + "slack", + "x-hub-signature", +] as const; export type HookVerify = (typeof HOOK_VERIFY)[number]; @@ -108,3 +113,14 @@ export async function verifySlack( const expected = `v0=${mac.toString("hex")}`; return timingEqual(signature, expected); } + +/** GitHub's `X-Hub-Signature-256` scheme, also Bitbucket Data Center's: `sha256=` and the hex HMAC of the raw body. */ +export async function verifyXHubSignature( + secret: string, + signature: string | null, + body: Uint8Array, +): Promise { + if (secret === "" || !signature) return false; + const mac = await hmacSha256(new TextEncoder().encode(secret), "", body); + return timingEqual(signature, `sha256=${mac.toString("hex")}`); +}