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
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"puppeteer-core": "^25.7.0",
"typescript": "6.0.3",
"typescript-eslint": "^8.42.0",
"@corbits/run-scope": "workspace:*"
"@corbits/run-scope": "workspace:*",
"@corbits/webhook-triggers": "workspace:*"
},
"engines": {
"bun": ">=1.2.0"
Expand Down
5 changes: 4 additions & 1 deletion packages/evals/src/targets/real-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import {
signPayload,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
} from "@corbits/webhook-triggers";

import type {
Expand Down Expand Up @@ -891,13 +892,15 @@ export async function bootMyraTarget(
);
}
const rawBody = JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const response = await fetch(
new URL(`/api/webhooks/${triggerId}`, hub.baseUrl),
{
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signPayload(secret, rawBody),
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload(secret, timestamp, rawBody),
},
body: rawBody,
},
Expand Down
1 change: 1 addition & 0 deletions packages/webhook-triggers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
signPayload,
verifySignature,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
} from "./signature";
export { renderInputTemplate } from "./mapping";
export {
Expand Down
43 changes: 30 additions & 13 deletions packages/webhook-triggers/src/ingress-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@
// Workbench's control (Granola, or anything else) posts here to kick
// off a workflow run. This is THE trust boundary — no session cookie,
// no tenant membership, nothing about the caller is trusted except
// what the HMAC signature over the raw body proves. Every failure
// mode here is loud and specific in the log, but every one of unknown
// trigger, disabled trigger, and bad/missing signature returns the
// SAME generic 401 `unauthorized` response, so a probe against a
// what the HMAC signature over `timestamp.rawBody` proves, and proves
// only within the freshness window `verifySignature` enforces (see
// `./signature.ts`) — so a captured, byte-for-byte replay of a real
// delivery stops verifying once it goes stale. Every failure mode
// here is loud and specific in the log, but every one of unknown
// trigger, disabled trigger, and bad/missing/stale signature returns
// the SAME generic 401 `unauthorized` response, so a probe against a
// wrong triggerId cannot distinguish "no such trigger" from "disabled"
// from "wrong secret".
// from "wrong secret" from "replayed".
import { Hono } from "hono";
import type { Env } from "hono";
import { getLogger } from "@intx/log";

import type { LaunchedWebhookTrigger } from "./launch";
import { WEBHOOK_SIGNATURE_HEADER, verifySignature } from "./signature";
import {
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
isFreshTimestamp,
verifySignature,
} from "./signature";
import type { WebhookTriggerRow } from "./schema";
import type { WebhookTriggerStore } from "./store";

Expand Down Expand Up @@ -72,13 +80,22 @@ export function createWebhookIngressRoutes(

const rawBody = await c.req.text();
const signatureHeader = c.req.header(WEBHOOK_SIGNATURE_HEADER);
if (!verifySignature(trigger.secret, rawBody, signatureHeader)) {
log.warn(
"Rejected webhook delivery for trigger {triggerId}: bad signature",
{
triggerId,
},
);
const timestampHeader = c.req.header(WEBHOOK_TIMESTAMP_HEADER);
if (
!verifySignature(
trigger.secret,
timestampHeader,
rawBody,
signatureHeader,
)
) {
const reason = isFreshTimestamp(timestampHeader)
? "bad signature"
: "missing or stale timestamp";
log.warn("Rejected webhook delivery for trigger {triggerId}: {reason}", {
triggerId,
reason,
});
return c.json(unauthorizedResponse(), 401);
}

Expand Down
69 changes: 57 additions & 12 deletions packages/webhook-triggers/src/signature.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// The trust boundary this package exists to guard: an inbound HTTP
// call from a network Workbench does not control. Every byte off the
// wire is untrusted until its signature verifies against the
// trigger's own secret.
// trigger's own secret, over a delivery recent enough to matter.
//
// Security-model note: the secret is generated server-side with
// `crypto.randomBytes` and shown to the caller exactly once, at
// creation or rotation. `store.ts` now encrypts it at rest through
// creation or rotation. `store.ts` encrypts it at rest through
// Interchange's `CredentialCipher` seam (`@intx/types`), closing the
// "database dump discloses every signing secret" half of this
// tradeoff — but this function still needs the *raw* secret in process
Expand All @@ -18,39 +18,84 @@
// means moving to asymmetric signing (caller signs with a private key,
// this package verifies with a stored public key), a bigger v2 change.
// Flagged, not silently accepted.
//
// The other half of that same tradeoff — one shared secret, verified
// by recomputing the same MAC, with nothing to stop a captured
// request from being replayed forever — is closed here by binding an
// `X-Webhook-Timestamp` into the signed material instead of signing
// `rawBody` alone. `verifySignature` rejects a delivery whose
// timestamp falls outside a 5-minute window on either side of now —
// both a stale replay of an old capture and a timestamp forged far
// into the future — the same window Stripe's `timestamp.signature`
// scheme uses. This still doesn't stop a delivery replayed within
// that window; closing that fully needs a tracked, per-delivery nonce
// (a bigger v2 change, like the asymmetric-signing one above).
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export const WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature";
export const WEBHOOK_TIMESTAMP_HEADER = "x-webhook-timestamp";

const SECRET_BYTES = 32;
const TIMESTAMP_TOLERANCE_SECONDS = 300;

/** A fresh, high-entropy secret for a new or rotated trigger. */
export function generateWebhookSecret(): string {
return randomBytes(SECRET_BYTES).toString("hex");
}

/** The HMAC-SHA256 hex digest of `rawBody` under `secret`. */
export function signPayload(secret: string, rawBody: string): string {
return createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
function signedMaterial(timestamp: string, rawBody: string): string {
return `${timestamp}.${rawBody}`;
}

/** The HMAC-SHA256 hex digest of `timestamp.rawBody` under `secret`. */
export function signPayload(
secret: string,
timestamp: string,
rawBody: string,
): string {
return createHmac("sha256", secret)
.update(signedMaterial(timestamp, rawBody), "utf8")
.digest("hex");
}

/**
* Verifies an inbound `X-Webhook-Signature` header against the
* trigger's stored secret, in constant time so a timing side-channel
* never leaks how many leading bytes of a guess were correct.
* `timingSafeEqual` throws on a length mismatch rather than returning
* false, so an obviously-wrong-length header is rejected explicitly
* before comparison, not left to throw past this function.
* Exported only so `ingress-routes.ts` can log a more specific reason
* for a rejected delivery than "bad signature" — the HTTP response
* stays the same generic 401 either way (see the module doc comment).
*/
export function isFreshTimestamp(
timestampHeader: string | undefined,
): timestampHeader is string {
if (timestampHeader === undefined || timestampHeader === "") return false;
const seconds = Number(timestampHeader);
if (!Number.isFinite(seconds)) return false;
return Math.abs(Date.now() / 1000 - seconds) <= TIMESTAMP_TOLERANCE_SECONDS;
}

/**
* Verifies an inbound `X-Webhook-Signature` header, over
* `X-Webhook-Timestamp.rawBody`, against the trigger's stored secret —
* in constant time so a timing side-channel never leaks how many
* leading bytes of a guess were correct, and only for a timestamp
* within `TIMESTAMP_TOLERANCE_SECONDS` of now so a captured delivery
* stops verifying once it goes stale. `timingSafeEqual` throws on a
* length mismatch rather than returning false, so an obviously-wrong-
* length header is rejected explicitly before comparison, not left to
* throw past this function.
*/
export function verifySignature(
secret: string,
timestampHeader: string | undefined,
rawBody: string,
providedSignatureHex: string | undefined,
): boolean {
if (providedSignatureHex === undefined || providedSignatureHex === "") {
return false;
}
const expected = signPayload(secret, rawBody);
if (!isFreshTimestamp(timestampHeader)) {
return false;
}
const expected = signPayload(secret, timestampHeader, rawBody);
const expectedBuffer = Buffer.from(expected, "hex");
let providedBuffer: Buffer;
try {
Expand Down
86 changes: 76 additions & 10 deletions packages/webhook-triggers/test/ingress-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
// Exercises `createWebhookIngressRoutes`' HTTP surface: signature
// verification (valid/invalid/missing), unknown/disabled-trigger
// verification (valid/invalid/missing/stale), unknown/disabled-trigger
// handling, and payload parsing — with a fake `launch` seam so no
// database or folded-run launch machinery is involved. This is the
// trust-boundary route: no session, no tenant middleware. Unknown
// trigger, disabled trigger, and bad signature must all come back as
// the same generic 401 so a probe can't tell them apart.
// trigger, disabled trigger, bad signature, and a stale/replayed
// timestamp must all come back as the same generic 401 so a probe
// can't tell them apart.
import { describe, expect, test } from "bun:test";
import { createWebhookIngressRoutes } from "../src/ingress-routes";
import { signPayload, WEBHOOK_SIGNATURE_HEADER } from "../src/signature";
import {
signPayload,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
} from "../src/signature";
import { createInMemoryWebhookTriggerStore } from "./test-support";

function nowSeconds(): string {
return String(Math.floor(Date.now() / 1000));
}

async function seedTrigger(
store: ReturnType<typeof createInMemoryWebhookTriggerStore>,
overrides: { enabled?: boolean } = {},
Expand Down Expand Up @@ -43,7 +52,7 @@ function buildApp(
}

describe("POST /:triggerId", () => {
test("launches on a validly signed payload", async () => {
test("launches on a validly signed payload with a fresh timestamp", async () => {
let launchedWith: unknown;
const { app, store } = buildApp(async (_trigger, payload) => {
launchedWith = payload;
Expand All @@ -52,11 +61,13 @@ describe("POST /:triggerId", () => {
await seedTrigger(store);

const body = JSON.stringify({ note: { title: "Q3 planning" } });
const timestamp = nowSeconds();
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", body),
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body),
},
body,
});
Expand All @@ -77,24 +88,71 @@ describe("POST /:triggerId", () => {

const response = await app.request("/ins_trigger1", {
method: "POST",
headers: { "content-type": "application/json" },
headers: {
"content-type": "application/json",
[WEBHOOK_TIMESTAMP_HEADER]: nowSeconds(),
},
body: "{}",
});

expect(response.status).toBe(401);
expect(await response.json()).toEqual(expectedUnauthorizedBody);
});

test("rejects a missing timestamp header with the generic unauthorized response", async () => {
const { app, store } = buildApp();
await seedTrigger(store);

const body = "{}";
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", nowSeconds(), body),
},
body,
});

expect(response.status).toBe(401);
expect(await response.json()).toEqual(expectedUnauthorizedBody);
});

test("rejects a stale timestamp (a replayed delivery) with the generic unauthorized response", async () => {
const { app, store } = buildApp();
await seedTrigger(store);

const body = "{}";
const staleTimestamp = String(Math.floor(Date.now() / 1000) - 301);
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_TIMESTAMP_HEADER]: staleTimestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", staleTimestamp, body),
},
body,
});

expect(response.status).toBe(401);
expect(await response.json()).toEqual(expectedUnauthorizedBody);
});

test("rejects a signature computed with the wrong secret with the generic unauthorized response", async () => {
const { app, store } = buildApp();
await seedTrigger(store);

const body = "{}";
const timestamp = nowSeconds();
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signPayload("wrong-secret", body),
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload(
"wrong-secret",
timestamp,
body,
),
},
body,
});
Expand All @@ -118,9 +176,13 @@ describe("POST /:triggerId", () => {
await seedTrigger(store, { enabled: false });

const body = "{}";
const timestamp = nowSeconds();
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: { [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", body) },
headers: {
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body),
},
body,
});

Expand All @@ -133,9 +195,13 @@ describe("POST /:triggerId", () => {
await seedTrigger(store);

const body = "not json";
const timestamp = nowSeconds();
const response = await app.request("/ins_trigger1", {
method: "POST",
headers: { [WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", body) },
headers: {
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
[WEBHOOK_SIGNATURE_HEADER]: signPayload("s3cr3t", timestamp, body),
},
body,
});

Expand Down
Loading
Loading