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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ Responses: `202` delivered, `200 { challenge }` for Slack `url_verification`, `4

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 also verifies when the sender used it as raw bytes, which is how 0.1 read it. `bearer` rejects an empty secret.
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. Bearer requests carry no id and are not deduplicated.
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.

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
23 changes: 23 additions & 0 deletions src/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,29 @@ describe("verifyStandardWebhooks", () => {
});
});

describe("zero-length signing keys", () => {
test.each(["", "whsec_"])(
"standard-webhooks rejects the empty secret %p instead of throwing",
async (secret) => {
const body = bytes("{}");
const headers = new Headers({
"webhook-id": "evt_1",
"webhook-timestamp": now(),
"webhook-signature": "v1,AAAA",
});
expect(await verifyStandardWebhooks(secret, headers, body)).toBe(false);
},
);

test("slack rejects an empty secret instead of throwing", async () => {
const headers = new Headers({
"x-slack-request-timestamp": now(),
"x-slack-signature": "v0=00",
});
expect(await verifySlack("", headers, bytes("{}"))).toBe(false);
});
});

describe("verifyBearer", () => {
test("accepts Authorization Bearer", () => {
const headers = new Headers({ authorization: "Bearer s3cret" });
Expand Down
6 changes: 4 additions & 2 deletions src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export async function verifyStandardWebhooks(
const [ver, val] = part.split(",", 2);
return ver === "v1" && val !== undefined && val !== "" ? [val] : [];
});
for (const key of standardWebhooksKeys(secret)) {
// A zero-length HMAC key is unusable (WebCrypto throws), so it never matches.
const keys = standardWebhooksKeys(secret).filter((key) => key.length > 0);
for (const key of keys) {
const mac = await hmacSha256(key, `${id}.${timestamp}.`, body);
const expected = mac.toString("base64");
if (candidates.some((c) => timingEqual(c, expected))) return true;
Expand All @@ -90,7 +92,7 @@ export async function verifySlack(
): Promise<boolean> {
const timestamp = headers.get("x-slack-request-timestamp");
const signature = headers.get("x-slack-signature");
if (!timestamp || !signature) return false;
if (secret === "" || !timestamp || !signature) return false;
const ts = Number(timestamp);
if (
!Number.isFinite(ts) ||
Expand Down
Loading