Skip to content
Merged
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
## 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`).

## 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.
Expand Down
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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":<the raw body>}`, 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.

Expand Down
77 changes: 77 additions & 0 deletions src/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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", {
Expand Down
48 changes: 46 additions & 2 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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 {
TIMESTAMP_TOLERANCE_S,
verifyBearer,
verifySlack,
verifyStandardWebhooks,
verifyXHubSignature,
} from "./verify.js";

export type LoadHook = (
Expand All @@ -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(
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions src/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
44 changes: 40 additions & 4 deletions src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HookVerify, "x-hub-signature"> }
| { verify: "x-hub-signature"; headers: HookHeaders }
);

export type LoadedHook = {
credentialId: string;
credentialName: string;
Expand All @@ -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<string, unknown>;
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 {
Expand Down
Loading
Loading