@powerhousedao/switchboard 6.2.3-dev.11 → 6.2.3-dev.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuild/test/workflow/harness.d.ts +45 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +17 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-CiKkVE6g.mjs → server-DndPNlPT.mjs} +548 -8
- package/dist/server-DndPNlPT.mjs.map +1 -0
- package/dist/server.d.mts +21 -3
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +17 -13
- package/test/workflow/harness.ts +237 -0
- package/test/workflow/resolvers.test.ts +105 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-unsigned.test.ts +332 -0
- package/test/workflow-runtime.test.ts +412 -0
- package/tsconfig.json +6 -0
- package/dist/server-CiKkVE6g.mjs.map +0 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// The signature-verifying half of core#webhook, over a real socket: config,
|
|
2
|
+
// resolved policy and the reactor's verifier have to agree on the wire format.
|
|
3
|
+
import { createHmac } from "node:crypto";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
SECRET,
|
|
7
|
+
SECRET_REF,
|
|
8
|
+
startWebhookHost,
|
|
9
|
+
waitForRuns,
|
|
10
|
+
type WebhookHost,
|
|
11
|
+
} from "./harness.js";
|
|
12
|
+
|
|
13
|
+
type Algorithm = "sha1" | "sha256" | "sha512";
|
|
14
|
+
type Encoding = "hex" | "base64";
|
|
15
|
+
|
|
16
|
+
// Computed, never pasted: a hard-coded digest would still match after a change
|
|
17
|
+
// to what gets signed, which is what these tests exist to catch.
|
|
18
|
+
function digestOf(
|
|
19
|
+
body: string,
|
|
20
|
+
options: { algorithm?: Algorithm; encoding?: Encoding } = {},
|
|
21
|
+
): string {
|
|
22
|
+
return createHmac(options.algorithm ?? "sha256", SECRET)
|
|
23
|
+
.update(body)
|
|
24
|
+
.digest(options.encoding ?? "hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
28
|
+
|
|
29
|
+
let host: WebhookHost | undefined;
|
|
30
|
+
|
|
31
|
+
// Unconditional, and tolerant of a test that failed before arming: a host left
|
|
32
|
+
// listening leaks its port and handles into the rest of the suite.
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await host?.stop();
|
|
35
|
+
host = undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// A delivery is answered before the run starts, so a rejection and a delayed
|
|
39
|
+
// acceptance are indistinguishable until the bounded wait has passed.
|
|
40
|
+
async function expectNoRun(current: WebhookHost): Promise<void> {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
42
|
+
expect(current.fired).toHaveLength(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("core#webhook signature verification", () => {
|
|
46
|
+
describe("the three layouts", () => {
|
|
47
|
+
it("accepts a bare digest under hmac and starts a run", async () => {
|
|
48
|
+
host = await startWebhookHost();
|
|
49
|
+
const { token } = await host.arm({
|
|
50
|
+
scheme: "hmac",
|
|
51
|
+
secretRef: SECRET_REF,
|
|
52
|
+
methods: "POST",
|
|
53
|
+
});
|
|
54
|
+
const body = '{"id":"evt_1"}';
|
|
55
|
+
|
|
56
|
+
const res = await host.deliver(token, {
|
|
57
|
+
headers: { ...JSON_HEADERS, "x-signature": digestOf(body) },
|
|
58
|
+
body,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(202);
|
|
62
|
+
const [run] = await waitForRuns(host, 1);
|
|
63
|
+
expect(run.payload).toMatchObject({ body: { id: "evt_1" } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("refuses a wrong digest under hmac", async () => {
|
|
67
|
+
host = await startWebhookHost();
|
|
68
|
+
const { token } = await host.arm({
|
|
69
|
+
scheme: "hmac",
|
|
70
|
+
secretRef: SECRET_REF,
|
|
71
|
+
methods: "POST",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const res = await host.deliver(token, {
|
|
75
|
+
headers: { ...JSON_HEADERS, "x-signature": digestOf('{"id":"other"}') },
|
|
76
|
+
body: '{"id":"evt_1"}',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(401);
|
|
80
|
+
await expectNoRun(host);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts a labelled digest under hmac-prefixed and starts a run", async () => {
|
|
84
|
+
host = await startWebhookHost();
|
|
85
|
+
const { token } = await host.arm({
|
|
86
|
+
scheme: "hmac-prefixed",
|
|
87
|
+
secretRef: SECRET_REF,
|
|
88
|
+
methods: "POST",
|
|
89
|
+
});
|
|
90
|
+
const body = '{"a":1}';
|
|
91
|
+
|
|
92
|
+
const res = await host.deliver(token, {
|
|
93
|
+
headers: {
|
|
94
|
+
...JSON_HEADERS,
|
|
95
|
+
"x-hub-signature-256": `sha256=${digestOf(body)}`,
|
|
96
|
+
},
|
|
97
|
+
body,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(res.status).toBe(202);
|
|
101
|
+
await waitForRuns(host, 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("refuses a correct digest carrying the wrong label", async () => {
|
|
105
|
+
host = await startWebhookHost();
|
|
106
|
+
const { token } = await host.arm({
|
|
107
|
+
scheme: "hmac-prefixed",
|
|
108
|
+
secretRef: SECRET_REF,
|
|
109
|
+
methods: "POST",
|
|
110
|
+
});
|
|
111
|
+
const body = '{"a":1}';
|
|
112
|
+
|
|
113
|
+
const res = await host.deliver(token, {
|
|
114
|
+
headers: {
|
|
115
|
+
...JSON_HEADERS,
|
|
116
|
+
"x-hub-signature-256": `sha1=${digestOf(body)}`,
|
|
117
|
+
},
|
|
118
|
+
body,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(res.status).toBe(401);
|
|
122
|
+
await expectNoRun(host);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("accepts t=/v1= under hmac-timestamped and starts a run", async () => {
|
|
126
|
+
host = await startWebhookHost();
|
|
127
|
+
const { token } = await host.arm({
|
|
128
|
+
scheme: "hmac-timestamped",
|
|
129
|
+
secretRef: SECRET_REF,
|
|
130
|
+
methods: "POST",
|
|
131
|
+
});
|
|
132
|
+
const body = '{"id":"evt_2"}';
|
|
133
|
+
const t = Math.floor(Date.now() / 1000);
|
|
134
|
+
|
|
135
|
+
const res = await host.deliver(token, {
|
|
136
|
+
headers: {
|
|
137
|
+
...JSON_HEADERS,
|
|
138
|
+
"stripe-signature": `t=${t},v1=${digestOf(`${t}.${body}`)}`,
|
|
139
|
+
},
|
|
140
|
+
body,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(202);
|
|
144
|
+
await waitForRuns(host, 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("refuses a timestamped digest signed without the timestamp", async () => {
|
|
148
|
+
host = await startWebhookHost();
|
|
149
|
+
const { token } = await host.arm({
|
|
150
|
+
scheme: "hmac-timestamped",
|
|
151
|
+
secretRef: SECRET_REF,
|
|
152
|
+
methods: "POST",
|
|
153
|
+
});
|
|
154
|
+
const body = '{"id":"evt_2"}';
|
|
155
|
+
const t = Math.floor(Date.now() / 1000);
|
|
156
|
+
|
|
157
|
+
// The digest has to cover `<t>.<body>`; over the body alone a captured
|
|
158
|
+
// signature would stay valid under any timestamp.
|
|
159
|
+
const res = await host.deliver(token, {
|
|
160
|
+
headers: {
|
|
161
|
+
...JSON_HEADERS,
|
|
162
|
+
"stripe-signature": `t=${t},v1=${digestOf(body)}`,
|
|
163
|
+
},
|
|
164
|
+
body,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(401);
|
|
168
|
+
await expectNoRun(host);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("verifies the exact bytes received, not a re-encoding of them", async () => {
|
|
173
|
+
host = await startWebhookHost();
|
|
174
|
+
const { token } = await host.arm({
|
|
175
|
+
scheme: "hmac",
|
|
176
|
+
secretRef: SECRET_REF,
|
|
177
|
+
methods: "POST",
|
|
178
|
+
});
|
|
179
|
+
// Deliberately not canonical JSON — unsorted keys, a double space. Anyone
|
|
180
|
+
// parsing and re-encoding the body before hashing breaks this delivery.
|
|
181
|
+
const body = '{"b":1, "a":2}';
|
|
182
|
+
|
|
183
|
+
const res = await host.deliver(token, {
|
|
184
|
+
headers: { ...JSON_HEADERS, "x-signature": digestOf(body) },
|
|
185
|
+
body,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(res.status).toBe(202);
|
|
189
|
+
await waitForRuns(host, 1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("algorithm", () => {
|
|
193
|
+
it("verifies a sha1 digest when the config names sha1", async () => {
|
|
194
|
+
host = await startWebhookHost();
|
|
195
|
+
const { token } = await host.arm({
|
|
196
|
+
scheme: "hmac",
|
|
197
|
+
secretRef: SECRET_REF,
|
|
198
|
+
algorithm: "sha1",
|
|
199
|
+
methods: "POST",
|
|
200
|
+
});
|
|
201
|
+
const body = '{"a":1}';
|
|
202
|
+
|
|
203
|
+
const res = await host.deliver(token, {
|
|
204
|
+
headers: {
|
|
205
|
+
...JSON_HEADERS,
|
|
206
|
+
"x-signature": digestOf(body, { algorithm: "sha1" }),
|
|
207
|
+
},
|
|
208
|
+
body,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(res.status).toBe(202);
|
|
212
|
+
await waitForRuns(host, 1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("verifies a sha512 digest when the config names sha512", async () => {
|
|
216
|
+
host = await startWebhookHost();
|
|
217
|
+
const { token } = await host.arm({
|
|
218
|
+
scheme: "hmac",
|
|
219
|
+
secretRef: SECRET_REF,
|
|
220
|
+
algorithm: "sha512",
|
|
221
|
+
methods: "POST",
|
|
222
|
+
});
|
|
223
|
+
const body = '{"a":1}';
|
|
224
|
+
|
|
225
|
+
const res = await host.deliver(token, {
|
|
226
|
+
headers: {
|
|
227
|
+
...JSON_HEADERS,
|
|
228
|
+
"x-signature": digestOf(body, { algorithm: "sha512" }),
|
|
229
|
+
},
|
|
230
|
+
body,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(res.status).toBe(202);
|
|
234
|
+
await waitForRuns(host, 1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("refuses a sha256 digest once the config names sha1", async () => {
|
|
238
|
+
host = await startWebhookHost();
|
|
239
|
+
const { token } = await host.arm({
|
|
240
|
+
scheme: "hmac",
|
|
241
|
+
secretRef: SECRET_REF,
|
|
242
|
+
algorithm: "sha1",
|
|
243
|
+
methods: "POST",
|
|
244
|
+
});
|
|
245
|
+
const body = '{"a":1}';
|
|
246
|
+
|
|
247
|
+
const res = await host.deliver(token, {
|
|
248
|
+
headers: { ...JSON_HEADERS, "x-signature": digestOf(body) },
|
|
249
|
+
body,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(res.status).toBe(401);
|
|
253
|
+
await expectNoRun(host);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("takes the prefixed label from the algorithm when no prefix is set", async () => {
|
|
257
|
+
host = await startWebhookHost();
|
|
258
|
+
const { token } = await host.arm({
|
|
259
|
+
scheme: "hmac-prefixed",
|
|
260
|
+
secretRef: SECRET_REF,
|
|
261
|
+
algorithm: "sha1",
|
|
262
|
+
methods: "POST",
|
|
263
|
+
});
|
|
264
|
+
const body = '{"a":1}';
|
|
265
|
+
const digest = digestOf(body, { algorithm: "sha1" });
|
|
266
|
+
|
|
267
|
+
// `sha1=`, not the layout's `sha256=` default: the label follows the
|
|
268
|
+
// hash, so switching algorithm alone still matches a sha1 sender.
|
|
269
|
+
const accepted = await host.deliver(token, {
|
|
270
|
+
headers: { ...JSON_HEADERS, "x-hub-signature-256": `sha1=${digest}` },
|
|
271
|
+
body,
|
|
272
|
+
});
|
|
273
|
+
expect(accepted.status).toBe(202);
|
|
274
|
+
await waitForRuns(host, 1);
|
|
275
|
+
|
|
276
|
+
const refused = await host.deliver(token, {
|
|
277
|
+
headers: { ...JSON_HEADERS, "x-hub-signature-256": `sha256=${digest}` },
|
|
278
|
+
body,
|
|
279
|
+
});
|
|
280
|
+
expect(refused.status).toBe(401);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("encoding", () => {
|
|
285
|
+
it("verifies a base64 digest when the config names base64", async () => {
|
|
286
|
+
host = await startWebhookHost();
|
|
287
|
+
const { token } = await host.arm({
|
|
288
|
+
scheme: "hmac",
|
|
289
|
+
secretRef: SECRET_REF,
|
|
290
|
+
encoding: "base64",
|
|
291
|
+
methods: "POST",
|
|
292
|
+
});
|
|
293
|
+
const body = '{"a":1}';
|
|
294
|
+
|
|
295
|
+
const res = await host.deliver(token, {
|
|
296
|
+
headers: {
|
|
297
|
+
...JSON_HEADERS,
|
|
298
|
+
"x-signature": digestOf(body, { encoding: "base64" }),
|
|
299
|
+
},
|
|
300
|
+
body,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(res.status).toBe(202);
|
|
304
|
+
await waitForRuns(host, 1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("refuses a lower-cased base64 signature", async () => {
|
|
308
|
+
host = await startWebhookHost();
|
|
309
|
+
const { token } = await host.arm({
|
|
310
|
+
scheme: "hmac",
|
|
311
|
+
secretRef: SECRET_REF,
|
|
312
|
+
encoding: "base64",
|
|
313
|
+
methods: "POST",
|
|
314
|
+
});
|
|
315
|
+
const body = '{"a":1}';
|
|
316
|
+
const digest = digestOf(body, { encoding: "base64" });
|
|
317
|
+
// Guards the case itself: an all-lower-case digest would make the
|
|
318
|
+
// assertion below prove nothing.
|
|
319
|
+
expect(digest).not.toBe(digest.toLowerCase());
|
|
320
|
+
|
|
321
|
+
const res = await host.deliver(token, {
|
|
322
|
+
headers: { ...JSON_HEADERS, "x-signature": digest.toLowerCase() },
|
|
323
|
+
body,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Case is data in base64 — `a` and `A` are different six-bit groups — so
|
|
327
|
+
// folding it would accept a signature nobody computed.
|
|
328
|
+
expect(res.status).toBe(401);
|
|
329
|
+
await expectNoRun(host);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("accepts an upper-cased hex signature", async () => {
|
|
333
|
+
host = await startWebhookHost();
|
|
334
|
+
const { token } = await host.arm({
|
|
335
|
+
scheme: "hmac",
|
|
336
|
+
secretRef: SECRET_REF,
|
|
337
|
+
methods: "POST",
|
|
338
|
+
});
|
|
339
|
+
const body = '{"a":1}';
|
|
340
|
+
|
|
341
|
+
// Hex has one value per digit whatever the case, and senders disagree on
|
|
342
|
+
// which they emit: rejecting these buys nothing and breaks integrations.
|
|
343
|
+
const res = await host.deliver(token, {
|
|
344
|
+
headers: {
|
|
345
|
+
...JSON_HEADERS,
|
|
346
|
+
"x-signature": digestOf(body).toUpperCase(),
|
|
347
|
+
},
|
|
348
|
+
body,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(res.status).toBe(202);
|
|
352
|
+
await waitForRuns(host, 1);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("prefix", () => {
|
|
357
|
+
it("matches a custom label", async () => {
|
|
358
|
+
host = await startWebhookHost();
|
|
359
|
+
const { token } = await host.arm({
|
|
360
|
+
scheme: "hmac-prefixed",
|
|
361
|
+
secretRef: SECRET_REF,
|
|
362
|
+
prefix: "signature=",
|
|
363
|
+
methods: "POST",
|
|
364
|
+
});
|
|
365
|
+
const body = '{"a":1}';
|
|
366
|
+
|
|
367
|
+
const res = await host.deliver(token, {
|
|
368
|
+
headers: {
|
|
369
|
+
...JSON_HEADERS,
|
|
370
|
+
"x-hub-signature-256": `signature=${digestOf(body)}`,
|
|
371
|
+
},
|
|
372
|
+
body,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(res.status).toBe(202);
|
|
376
|
+
await waitForRuns(host, 1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('reads an empty prefix as "no label", unlike omitting it', async () => {
|
|
380
|
+
host = await startWebhookHost();
|
|
381
|
+
const body = '{"a":1}';
|
|
382
|
+
const digest = digestOf(body);
|
|
383
|
+
|
|
384
|
+
const empty = await host.arm({
|
|
385
|
+
scheme: "hmac-prefixed",
|
|
386
|
+
secretRef: SECRET_REF,
|
|
387
|
+
prefix: "",
|
|
388
|
+
methods: "POST",
|
|
389
|
+
});
|
|
390
|
+
const accepted = await host.deliver(empty.token, {
|
|
391
|
+
headers: { ...JSON_HEADERS, "x-hub-signature-256": digest },
|
|
392
|
+
body,
|
|
393
|
+
});
|
|
394
|
+
expect(accepted.status).toBe(202);
|
|
395
|
+
await waitForRuns(host, 1);
|
|
396
|
+
|
|
397
|
+
// Re-armed without the field: omitted means "the algorithm's own label",
|
|
398
|
+
// so the same bare digest now fails. "" is a value, not an absence.
|
|
399
|
+
const omitted = await host.arm({
|
|
400
|
+
scheme: "hmac-prefixed",
|
|
401
|
+
secretRef: SECRET_REF,
|
|
402
|
+
methods: "POST",
|
|
403
|
+
});
|
|
404
|
+
const refused = await host.deliver(omitted.token, {
|
|
405
|
+
headers: { ...JSON_HEADERS, "x-hub-signature-256": digest },
|
|
406
|
+
body,
|
|
407
|
+
});
|
|
408
|
+
expect(refused.status).toBe(401);
|
|
409
|
+
await expectNoRun(host);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("hmac-timestamped replay window", () => {
|
|
414
|
+
it("refuses a timestamp outside the tolerance", async () => {
|
|
415
|
+
host = await startWebhookHost();
|
|
416
|
+
// Seconds rather than the 300s default: the window is what is under
|
|
417
|
+
// test, and a stale timestamp is cheaper to fabricate than to wait for.
|
|
418
|
+
const { token } = await host.arm({
|
|
419
|
+
scheme: "hmac-timestamped",
|
|
420
|
+
secretRef: SECRET_REF,
|
|
421
|
+
toleranceSeconds: 2,
|
|
422
|
+
methods: "POST",
|
|
423
|
+
});
|
|
424
|
+
const body = '{"id":"evt_3"}';
|
|
425
|
+
const stale = Math.floor(Date.now() / 1000) - 60;
|
|
426
|
+
|
|
427
|
+
const res = await host.deliver(token, {
|
|
428
|
+
headers: {
|
|
429
|
+
...JSON_HEADERS,
|
|
430
|
+
"stripe-signature": `t=${stale},v1=${digestOf(`${stale}.${body}`)}`,
|
|
431
|
+
},
|
|
432
|
+
body,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(res.status).toBe(401);
|
|
436
|
+
await expectNoRun(host);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("accepts a fresh timestamp under the same tolerance", async () => {
|
|
440
|
+
host = await startWebhookHost();
|
|
441
|
+
const { token } = await host.arm({
|
|
442
|
+
scheme: "hmac-timestamped",
|
|
443
|
+
secretRef: SECRET_REF,
|
|
444
|
+
toleranceSeconds: 2,
|
|
445
|
+
methods: "POST",
|
|
446
|
+
});
|
|
447
|
+
const body = '{"id":"evt_3"}';
|
|
448
|
+
const t = Math.floor(Date.now() / 1000);
|
|
449
|
+
|
|
450
|
+
const res = await host.deliver(token, {
|
|
451
|
+
headers: {
|
|
452
|
+
...JSON_HEADERS,
|
|
453
|
+
"stripe-signature": `t=${t},v1=${digestOf(`${t}.${body}`)}`,
|
|
454
|
+
},
|
|
455
|
+
body,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(res.status).toBe(202);
|
|
459
|
+
await waitForRuns(host, 1);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("accepts a header where only one of several v1 values matches", async () => {
|
|
463
|
+
host = await startWebhookHost();
|
|
464
|
+
const { token } = await host.arm({
|
|
465
|
+
scheme: "hmac-timestamped",
|
|
466
|
+
secretRef: SECRET_REF,
|
|
467
|
+
methods: "POST",
|
|
468
|
+
});
|
|
469
|
+
const body = '{"id":"evt_4"}';
|
|
470
|
+
const t = Math.floor(Date.now() / 1000);
|
|
471
|
+
const rotated = createHmac("sha256", "previous-secret")
|
|
472
|
+
.update(`${t}.${body}`)
|
|
473
|
+
.digest("hex");
|
|
474
|
+
|
|
475
|
+
// What a rotation looks like on the wire: the provider signs with the old
|
|
476
|
+
// and the new secret at once, so requiring all to match is an outage.
|
|
477
|
+
const res = await host.deliver(token, {
|
|
478
|
+
headers: {
|
|
479
|
+
...JSON_HEADERS,
|
|
480
|
+
"stripe-signature": `t=${t},v1=${rotated},v1=${digestOf(`${t}.${body}`)}`,
|
|
481
|
+
},
|
|
482
|
+
body,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
expect(res.status).toBe(202);
|
|
486
|
+
await waitForRuns(host, 1);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("refuses every delivery when the secret ref resolves to nothing", async () => {
|
|
491
|
+
host = await startWebhookHost();
|
|
492
|
+
// The tempting failure mode is a verify block with no secret in it reading
|
|
493
|
+
// as "nothing to check": an unresolvable ref would then disarm the check.
|
|
494
|
+
const { token } = await host.arm({
|
|
495
|
+
scheme: "hmac",
|
|
496
|
+
secretRef: "secret://v1:ffffffffffffffffffffffffffffffff",
|
|
497
|
+
methods: "POST",
|
|
498
|
+
});
|
|
499
|
+
const body = '{"a":1}';
|
|
500
|
+
|
|
501
|
+
const res = await host.deliver(token, {
|
|
502
|
+
headers: { ...JSON_HEADERS, "x-signature": digestOf(body) },
|
|
503
|
+
body,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(res.status).toBe(401);
|
|
507
|
+
await expectNoRun(host);
|
|
508
|
+
});
|
|
509
|
+
});
|