Skip to content

Commit 9895b56

Browse files
fix: verify raw bytes, decode Standard Webhooks secrets, cap body size (#21)
Reject an empty bearer secret. Unprefixed Standard Webhooks secrets still verify as raw bytes, as 0.1 read them. Refs CL-9451.
1 parent 02da45a commit 9895b56

5 files changed

Lines changed: 195 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ Responses: `202` delivered, `200 { challenge }` for Slack `url_verification`, `4
4747

4848
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`.
4949

50-
With `standard-webhooks`, a `whsec_…` secret is base64-decoded before use as the HMAC key; any other secret is used as-is. Bot tokens for chat integrations belong in the workflow's `credentialBindings`, not in the signing secret.
50+
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.
51+
52+
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.
5153

5254
### Exports
5355

‎src/hooks.test.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import { Hono } from "hono";
33

4-
import { createHookApp } from "./hooks.js";
4+
import { createHookApp, MAX_BODY_BYTES } from "./hooks.js";
55
import type { LoadedHook, LiveRun } from "./resolve.js";
66

77
const JIMMY: LiveRun = {
@@ -209,6 +209,49 @@ describe("createHookApp", () => {
209209
expect(res.status).toBe(503);
210210
});
211211

212+
test("413s a body over the size limit before verifying", async () => {
213+
const { app, delivered } = mount({
214+
loaded: hook({ meta: { verify: "bearer", to: JIMMY.address } }),
215+
runs: [JIMMY],
216+
});
217+
const res = await app.request("/api/hooks/slack", {
218+
method: "POST",
219+
body: "x".repeat(MAX_BODY_BYTES + 1),
220+
headers: { authorization: "Bearer s3cret" },
221+
});
222+
expect(res.status).toBe(413);
223+
expect(delivered).toEqual([]);
224+
});
225+
226+
test("forwards the verified body unchanged", async () => {
227+
const { app, delivered } = mount({
228+
loaded: hook({ meta: { verify: "bearer", to: JIMMY.address } }),
229+
runs: [JIMMY],
230+
});
231+
const body = ' {"n":"é"}\r\n';
232+
const res = await app.request("/api/hooks/slack", {
233+
method: "POST",
234+
body: new TextEncoder().encode(body),
235+
headers: { authorization: "Bearer s3cret" },
236+
});
237+
expect(res.status).toBe(202);
238+
expect(delivered[0]?.content).toBe(body);
239+
});
240+
241+
test("415s a verified body that is not UTF-8", async () => {
242+
const { app, delivered } = mount({
243+
loaded: hook({ meta: { verify: "bearer", to: JIMMY.address } }),
244+
runs: [JIMMY],
245+
});
246+
const res = await app.request("/api/hooks/slack", {
247+
method: "POST",
248+
body: new Uint8Array([0x7b, 0xe9, 0x7d]),
249+
headers: { authorization: "Bearer s3cret" },
250+
});
251+
expect(res.status).toBe(415);
252+
expect(delivered).toEqual([]);
253+
});
254+
212255
test("404 when the hook is ambiguous", async () => {
213256
const { app } = mount({ loaded: "ambiguous" });
214257
const res = await app.request("/api/hooks/slack", {

‎src/hooks.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Hono, type Context } from "hono";
2+
import { bodyLimit } from "hono/body-limit";
23

34
import type { MailDeliverer } from "./deliver.js";
45
import type { LoadedHook, LiveRun } from "./resolve.js";
@@ -12,12 +13,21 @@ export type LoadHook = (
1213

1314
export type ListRuns = (tenantId: string) => Promise<LiveRun[]>;
1415

16+
/** Largest request body accepted, checked before anything is hashed. */
17+
export const MAX_BODY_BYTES = 1024 * 1024;
18+
1519
export function createHookApp(opts: {
1620
deliver: MailDeliverer;
1721
loadHook: LoadHook;
1822
listRuns: ListRuns;
1923
}): Hono {
2024
const app = new Hono();
25+
app.use(
26+
bodyLimit({
27+
maxSize: MAX_BODY_BYTES,
28+
onError: (c) => c.json({ error: "payload_too_large" }, 413),
29+
}),
30+
);
2131
app.post("/", (c) => handle(c, opts));
2232
app.post("/:id", (c) => handle(c, opts));
2333
app.post("/:tenantId/:name", (c) => handle(c, opts));
@@ -59,17 +69,28 @@ async function handle(
5969
return c.json({ error: "unknown_hook" }, 404);
6070
}
6171

62-
const body = await c.req.text();
72+
const raw = new Uint8Array(await c.req.arrayBuffer());
6373
let ok = false;
6474
if (loaded.meta.verify === "bearer") {
6575
ok = verifyBearer(loaded.secret, c.req.raw.headers);
6676
} else if (loaded.meta.verify === "standard-webhooks") {
67-
ok = await verifyStandardWebhooks(loaded.secret, c.req.raw.headers, body);
77+
ok = await verifyStandardWebhooks(loaded.secret, c.req.raw.headers, raw);
6878
} else if (loaded.meta.verify === "slack") {
69-
ok = await verifySlack(loaded.secret, c.req.raw.headers, body);
79+
ok = await verifySlack(loaded.secret, c.req.raw.headers, raw);
7080
}
7181
if (!ok) return c.json({ error: "unauthorized" }, 401);
7282

83+
// Trigger mail carries text, so a body that is not UTF-8 cannot be forwarded
84+
// unchanged; refuse it rather than substitute replacement characters.
85+
let body: string;
86+
try {
87+
body = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
88+
raw,
89+
);
90+
} catch {
91+
return c.json({ error: "unsupported_body" }, 415);
92+
}
93+
7394
if (loaded.meta.verify === "slack") {
7495
const challenge = slackChallenge(body);
7596
if (challenge !== undefined) {

‎src/verify.test.ts‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ import { describe, expect, test } from "bun:test";
22

33
import { verifyBearer, verifySlack, verifyStandardWebhooks } from "./verify.js";
44

5+
const bytes = (text: string) => new TextEncoder().encode(text);
6+
7+
async function swSign(
8+
key: Uint8Array<ArrayBuffer>,
9+
id: string,
10+
timestamp: string,
11+
body: Uint8Array,
12+
): Promise<Headers> {
13+
const hmac = await crypto.subtle.importKey(
14+
"raw",
15+
key,
16+
{ name: "HMAC", hash: "SHA-256" },
17+
false,
18+
["sign"],
19+
);
20+
const mac = await crypto.subtle.sign(
21+
"HMAC",
22+
hmac,
23+
Buffer.concat([bytes(`${id}.${timestamp}.`), body]),
24+
);
25+
return new Headers({
26+
"webhook-id": id,
27+
"webhook-timestamp": timestamp,
28+
"webhook-signature": `v1,${Buffer.from(mac).toString("base64")}`,
29+
});
30+
}
31+
32+
const now = () => String(Math.floor(Date.now() / 1000));
33+
534
describe("verifyStandardWebhooks", () => {
635
test("accepts a valid v1 signature", async () => {
736
const secret = `whsec_${Buffer.from("supersecret").toString("base64")}`;
@@ -25,18 +54,53 @@ describe("verifyStandardWebhooks", () => {
2554
"webhook-timestamp": timestamp,
2655
"webhook-signature": `v1,${Buffer.from(mac).toString("base64")}`,
2756
});
57+
expect(await verifyStandardWebhooks(secret, headers, bytes(body))).toBe(
58+
true,
59+
);
60+
});
61+
62+
test("verifies over raw bytes that are not valid UTF-8", async () => {
63+
const key = bytes("supersecret");
64+
const body = new Uint8Array([0x7b, 0x22, 0xe9, 0x22, 0x7d]);
65+
const headers = await swSign(key, "evt_1", now(), body);
66+
const secret = `whsec_${Buffer.from(key).toString("base64")}`;
2867
expect(await verifyStandardWebhooks(secret, headers, body)).toBe(true);
2968
});
3069

70+
test("base64-decodes a secret without the whsec_ prefix", async () => {
71+
const key = bytes("supersecret");
72+
const headers = await swSign(key, "evt_1", now(), bytes("{}"));
73+
const secret = Buffer.from(key).toString("base64");
74+
expect(await verifyStandardWebhooks(secret, headers, bytes("{}"))).toBe(
75+
true,
76+
);
77+
});
78+
79+
test("still accepts an unprefixed secret used as raw bytes", async () => {
80+
const secret = "plain-secret";
81+
const headers = await swSign(bytes(secret), "evt_1", now(), bytes("{}"));
82+
expect(await verifyStandardWebhooks(secret, headers, bytes("{}"))).toBe(
83+
true,
84+
);
85+
});
86+
87+
test("does not use a whsec_ secret as raw bytes", async () => {
88+
const secret = `whsec_${Buffer.from("supersecret").toString("base64")}`;
89+
const headers = await swSign(bytes(secret), "evt_1", now(), bytes("{}"));
90+
expect(await verifyStandardWebhooks(secret, headers, bytes("{}"))).toBe(
91+
false,
92+
);
93+
});
94+
3195
test("rejects a bad signature", async () => {
3296
const headers = new Headers({
3397
"webhook-id": "evt_1",
3498
"webhook-timestamp": String(Math.floor(Date.now() / 1000)),
3599
"webhook-signature": "v1,nope",
36100
});
37-
expect(await verifyStandardWebhooks("whsec_xxxx", headers, "{}")).toBe(
38-
false,
39-
);
101+
expect(
102+
await verifyStandardWebhooks("whsec_xxxx", headers, bytes("{}")),
103+
).toBe(false);
40104
});
41105
});
42106

@@ -51,6 +115,13 @@ describe("verifyBearer", () => {
51115
expect(verifyBearer("s3cret", headers)).toBe(true);
52116
});
53117

118+
test.each([{ authorization: "Bearer " }, { "x-webhook-secret": "" }])(
119+
"rejects an empty secret (%o)",
120+
(init) => {
121+
expect(verifyBearer("", new Headers(init))).toBe(false);
122+
},
123+
);
124+
54125
test("rejects a mismatch", () => {
55126
const headers = new Headers({ authorization: "Bearer nope" });
56127
expect(verifyBearer("s3cret", headers)).toBe(false);
@@ -78,14 +149,16 @@ describe("verifySlack", () => {
78149
"x-slack-request-timestamp": timestamp,
79150
"x-slack-signature": `v0=${Buffer.from(mac).toString("hex")}`,
80151
});
81-
expect(await verifySlack(secret, headers, body)).toBe(true);
152+
expect(await verifySlack(secret, headers, bytes(body))).toBe(true);
82153
});
83154

84155
test("rejects a bad signature", async () => {
85156
const headers = new Headers({
86157
"x-slack-request-timestamp": String(Math.floor(Date.now() / 1000)),
87158
"x-slack-signature": "v0=00",
88159
});
89-
expect(await verifySlack("signing-secret", headers, "{}")).toBe(false);
160+
expect(await verifySlack("signing-secret", headers, bytes("{}"))).toBe(
161+
false,
162+
);
90163
});
91164
});

‎src/verify.ts‎

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,43 @@ export function timingEqual(a: string, b: string): boolean {
99
return crypto.timingSafeEqual(left, right);
1010
}
1111

12+
async function hmacSha256(
13+
key: Uint8Array<ArrayBuffer>,
14+
prefix: string,
15+
body: Uint8Array,
16+
): Promise<Buffer> {
17+
const hmac = await crypto.subtle.importKey(
18+
"raw",
19+
key,
20+
{ name: "HMAC", hash: "SHA-256" },
21+
false,
22+
["sign"],
23+
);
24+
const message = Buffer.concat([new TextEncoder().encode(prefix), body]);
25+
return Buffer.from(await crypto.subtle.sign("HMAC", hmac, message));
26+
}
27+
28+
const BASE64 =
29+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
30+
31+
/**
32+
* Standard Webhooks keys are base64, optionally behind `whsec_`. Before 0.2 an
33+
* unprefixed secret was used as raw bytes, so those still verify under that
34+
* key too.
35+
*/
36+
function standardWebhooksKeys(secret: string): Uint8Array<ArrayBuffer>[] {
37+
if (secret.startsWith("whsec_")) {
38+
const encoded = secret.slice("whsec_".length);
39+
return BASE64.test(encoded) ? [Buffer.from(encoded, "base64")] : [];
40+
}
41+
const raw = new TextEncoder().encode(secret);
42+
return BASE64.test(secret) ? [Buffer.from(secret, "base64"), raw] : [raw];
43+
}
44+
1245
export async function verifyStandardWebhooks(
1346
secret: string,
1447
headers: Headers,
15-
body: string,
48+
body: Uint8Array,
1649
): Promise<boolean> {
1750
const id = headers.get("webhook-id");
1851
const timestamp = headers.get("webhook-timestamp");
@@ -22,30 +55,20 @@ export async function verifyStandardWebhooks(
2255
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
2356
return false;
2457
}
25-
const keyBytes = secret.startsWith("whsec_")
26-
? Buffer.from(secret.slice("whsec_".length), "base64")
27-
: Buffer.from(secret);
28-
const key = await crypto.subtle.importKey(
29-
"raw",
30-
keyBytes,
31-
{ name: "HMAC", hash: "SHA-256" },
32-
false,
33-
["sign"],
34-
);
35-
const mac = await crypto.subtle.sign(
36-
"HMAC",
37-
key,
38-
new TextEncoder().encode(`${id}.${timestamp}.${body}`),
39-
);
40-
const expected = Buffer.from(mac).toString("base64");
4158
const candidates = signature.split(/\s+/).flatMap((part) => {
4259
const [ver, val] = part.split(",", 2);
4360
return ver === "v1" && val !== undefined && val !== "" ? [val] : [];
4461
});
45-
return candidates.some((c) => timingEqual(c, expected));
62+
for (const key of standardWebhooksKeys(secret)) {
63+
const mac = await hmacSha256(key, `${id}.${timestamp}.`, body);
64+
const expected = mac.toString("base64");
65+
if (candidates.some((c) => timingEqual(c, expected))) return true;
66+
}
67+
return false;
4668
}
4769

4870
export function verifyBearer(secret: string, headers: Headers): boolean {
71+
if (secret === "") return false;
4972
const auth = headers.get("authorization");
5073
if (auth?.startsWith("Bearer ")) {
5174
return timingEqual(auth.slice("Bearer ".length), secret);
@@ -57,7 +80,7 @@ export function verifyBearer(secret: string, headers: Headers): boolean {
5780
export async function verifySlack(
5881
secret: string,
5982
headers: Headers,
60-
body: string,
83+
body: Uint8Array,
6184
): Promise<boolean> {
6285
const timestamp = headers.get("x-slack-request-timestamp");
6386
const signature = headers.get("x-slack-signature");
@@ -66,18 +89,11 @@ export async function verifySlack(
6689
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
6790
return false;
6891
}
69-
const key = await crypto.subtle.importKey(
70-
"raw",
92+
const mac = await hmacSha256(
7193
new TextEncoder().encode(secret),
72-
{ name: "HMAC", hash: "SHA-256" },
73-
false,
74-
["sign"],
75-
);
76-
const mac = await crypto.subtle.sign(
77-
"HMAC",
78-
key,
79-
new TextEncoder().encode(`v0:${timestamp}:${body}`),
94+
`v0:${timestamp}:`,
95+
body,
8096
);
81-
const expected = `v0=${Buffer.from(mac).toString("hex")}`;
97+
const expected = `v0=${mac.toString("hex")}`;
8298
return timingEqual(signature, expected);
8399
}

0 commit comments

Comments
 (0)