@blokjs/trigger-webhook 0.2.1 → 0.6.1

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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Webhook signature verifiers — one strategy per supported provider.
3
+ *
4
+ * Each verifier reads the raw request bytes (NOT the JSON-parsed body)
5
+ * and the provider-specific signature header, computes the expected
6
+ * HMAC against a shared secret, and constant-time compares. On match,
7
+ * returns `{ ok: true, eventId, eventType }`. On mismatch / missing
8
+ * header / drift, returns `{ ok: false, reason, message }`.
9
+ *
10
+ * Constant-time comparison via `crypto.timingSafeEqual` is mandatory —
11
+ * a naive `===` compare leaks the expected HMAC byte by byte through
12
+ * timing variance and a network-adjacent attacker can recover the
13
+ * secret in ~256 requests per byte.
14
+ *
15
+ * Built-in providers + their signature shapes:
16
+ *
17
+ * - **github**: `X-Hub-Signature-256: sha256=<hex>` over rawBody.
18
+ * Event id from `X-GitHub-Delivery`; event type
19
+ * from `X-GitHub-Event` header.
20
+ * - **stripe**: `Stripe-Signature: t=<ts>,v1=<hex>` over
21
+ * `<ts>.<rawBody>` with a 5-minute drift window.
22
+ * Event id + type from body.id / body.type.
23
+ * - **slack**: `X-Slack-Signature: v0=<hex>` over
24
+ * `v0:<X-Slack-Request-Timestamp>:<rawBody>` with a
25
+ * 5-minute drift window. Event type from
26
+ * body.event.type; event id from body.event_id.
27
+ * - **shopify**: `X-Shopify-Hmac-Sha256: <base64>` over rawBody.
28
+ * Event type from `X-Shopify-Topic`; event id from
29
+ * `X-Shopify-Webhook-Id`.
30
+ * - **svix**: Standard Webhooks. `webhook-signature:
31
+ * v1,<base64>` over `<webhook-id>.<webhook-timestamp>.<rawBody>`
32
+ * with a 5-minute drift window. Event id from
33
+ * `webhook-id`; event type from body.type.
34
+ *
35
+ * Custom (unknown provider) verifier is built dynamically by
36
+ * `buildCustomVerifier()` from the workflow's `signature` config.
37
+ */
38
+
39
+ import { createHmac, timingSafeEqual } from "node:crypto";
40
+
41
+ /** Successful verification result — workflow may proceed. */
42
+ export interface VerifyOk {
43
+ ok: true;
44
+ /** Provider-specific event id (used for replay-protection cache key). */
45
+ eventId: string;
46
+ /** Provider-specific event type (used for the allowlist check). */
47
+ eventType: string;
48
+ }
49
+
50
+ /** Verification failure — trigger returns 401 with structured reason. */
51
+ export interface VerifyError {
52
+ ok: false;
53
+ /** Stable discriminator — log/alert dashboards branch on this. */
54
+ reason:
55
+ | "missing_signature"
56
+ | "missing_timestamp"
57
+ | "missing_secret"
58
+ | "bad_format"
59
+ | "timestamp_drift"
60
+ | "signature_mismatch";
61
+ /** Human-readable error message. Safe to surface to the sender. */
62
+ message: string;
63
+ }
64
+
65
+ export type VerifyResult = VerifyOk | VerifyError;
66
+
67
+ /** Inputs every verifier receives. */
68
+ export interface VerifyInput {
69
+ headers: Record<string, string>;
70
+ rawBody: string;
71
+ parsedBody: unknown;
72
+ secret: string;
73
+ toleranceSec: number;
74
+ }
75
+
76
+ export interface Verifier {
77
+ verify(input: VerifyInput): VerifyResult;
78
+ }
79
+
80
+ const DEFAULT_TOLERANCE_SEC = 300;
81
+
82
+ function safeEqualString(a: string, b: string): boolean {
83
+ const aBuf = Buffer.from(a);
84
+ const bBuf = Buffer.from(b);
85
+ if (aBuf.length !== bBuf.length) return false;
86
+ return timingSafeEqual(aBuf, bBuf);
87
+ }
88
+
89
+ function hmacHex(algo: "sha256" | "sha1" | "sha512", secret: string, data: string): string {
90
+ return createHmac(algo, secret).update(data).digest("hex");
91
+ }
92
+
93
+ function hmacBase64(algo: "sha256" | "sha1" | "sha512", secret: string, data: string): string {
94
+ return createHmac(algo, secret).update(data).digest("base64");
95
+ }
96
+
97
+ function isWithinTolerance(timestampSec: number, toleranceSec: number): boolean {
98
+ const nowSec = Math.floor(Date.now() / 1000);
99
+ return Math.abs(nowSec - timestampSec) <= toleranceSec;
100
+ }
101
+
102
+ function getEventTypeFromBody(parsedBody: unknown, key = "type"): string | undefined {
103
+ if (!parsedBody || typeof parsedBody !== "object") return undefined;
104
+ const value = (parsedBody as Record<string, unknown>)[key];
105
+ return typeof value === "string" ? value : undefined;
106
+ }
107
+
108
+ // =============================================================================
109
+ // Built-in providers
110
+ // =============================================================================
111
+
112
+ export const githubVerifier: Verifier = {
113
+ verify({ headers, rawBody, secret }) {
114
+ if (!secret) return { ok: false, reason: "missing_secret", message: "GitHub: secret not configured" };
115
+ const sig = headers["x-hub-signature-256"];
116
+ if (!sig) return { ok: false, reason: "missing_signature", message: "GitHub: X-Hub-Signature-256 header missing" };
117
+ const expected = `sha256=${hmacHex("sha256", secret, rawBody)}`;
118
+ if (!safeEqualString(sig, expected)) {
119
+ return { ok: false, reason: "signature_mismatch", message: "GitHub: signature mismatch" };
120
+ }
121
+ return {
122
+ ok: true,
123
+ eventId: headers["x-github-delivery"] ?? "",
124
+ eventType: headers["x-github-event"] ?? "unknown",
125
+ };
126
+ },
127
+ };
128
+
129
+ export const stripeVerifier: Verifier = {
130
+ verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
131
+ if (!secret) return { ok: false, reason: "missing_secret", message: "Stripe: secret not configured" };
132
+ const sig = headers["stripe-signature"];
133
+ if (!sig) return { ok: false, reason: "missing_signature", message: "Stripe: Stripe-Signature header missing" };
134
+
135
+ // Format: t=1234567890,v1=<hex>,v0=<hex>,...
136
+ const parts = Object.fromEntries(
137
+ sig
138
+ .split(",")
139
+ .map((p) => p.trim())
140
+ .map((p) => {
141
+ const idx = p.indexOf("=");
142
+ return idx === -1 ? [p, ""] : [p.slice(0, idx), p.slice(idx + 1)];
143
+ }),
144
+ );
145
+ const ts = parts.t;
146
+ const v1 = parts.v1;
147
+ if (!ts || !v1) {
148
+ return {
149
+ ok: false,
150
+ reason: "bad_format",
151
+ message: "Stripe: signature missing t= or v1= component",
152
+ };
153
+ }
154
+ const tsNum = Number.parseInt(ts, 10);
155
+ if (!Number.isFinite(tsNum)) {
156
+ return { ok: false, reason: "bad_format", message: "Stripe: t= is not numeric" };
157
+ }
158
+ if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
159
+ return {
160
+ ok: false,
161
+ reason: "timestamp_drift",
162
+ message: `Stripe: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
163
+ };
164
+ }
165
+ const expected = hmacHex("sha256", secret, `${ts}.${rawBody}`);
166
+ if (!safeEqualString(v1, expected)) {
167
+ return { ok: false, reason: "signature_mismatch", message: "Stripe: signature mismatch" };
168
+ }
169
+ const eventId = (parsedBody as { id?: unknown } | null)?.id;
170
+ const eventType = getEventTypeFromBody(parsedBody);
171
+ return {
172
+ ok: true,
173
+ eventId: typeof eventId === "string" ? eventId : "",
174
+ eventType: eventType ?? "unknown",
175
+ };
176
+ },
177
+ };
178
+
179
+ export const slackVerifier: Verifier = {
180
+ verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
181
+ if (!secret) return { ok: false, reason: "missing_secret", message: "Slack: secret not configured" };
182
+ const sig = headers["x-slack-signature"];
183
+ if (!sig) return { ok: false, reason: "missing_signature", message: "Slack: X-Slack-Signature header missing" };
184
+ const ts = headers["x-slack-request-timestamp"];
185
+ if (!ts) {
186
+ return {
187
+ ok: false,
188
+ reason: "missing_timestamp",
189
+ message: "Slack: X-Slack-Request-Timestamp header missing",
190
+ };
191
+ }
192
+ const tsNum = Number.parseInt(ts, 10);
193
+ if (!Number.isFinite(tsNum)) {
194
+ return { ok: false, reason: "bad_format", message: "Slack: timestamp is not numeric" };
195
+ }
196
+ if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
197
+ return {
198
+ ok: false,
199
+ reason: "timestamp_drift",
200
+ message: `Slack: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
201
+ };
202
+ }
203
+ const expected = `v0=${hmacHex("sha256", secret, `v0:${ts}:${rawBody}`)}`;
204
+ if (!safeEqualString(sig, expected)) {
205
+ return { ok: false, reason: "signature_mismatch", message: "Slack: signature mismatch" };
206
+ }
207
+ const event = (parsedBody as { event?: { type?: unknown }; event_id?: unknown } | null) ?? {};
208
+ const eventType = typeof event.event?.type === "string" ? event.event.type : "unknown";
209
+ const eventId = typeof event.event_id === "string" ? event.event_id : "";
210
+ return { ok: true, eventId, eventType };
211
+ },
212
+ };
213
+
214
+ export const shopifyVerifier: Verifier = {
215
+ verify({ headers, rawBody, secret }) {
216
+ if (!secret) return { ok: false, reason: "missing_secret", message: "Shopify: secret not configured" };
217
+ const sig = headers["x-shopify-hmac-sha256"];
218
+ if (!sig) {
219
+ return { ok: false, reason: "missing_signature", message: "Shopify: X-Shopify-Hmac-Sha256 header missing" };
220
+ }
221
+ const expected = hmacBase64("sha256", secret, rawBody);
222
+ if (!safeEqualString(sig, expected)) {
223
+ return { ok: false, reason: "signature_mismatch", message: "Shopify: signature mismatch" };
224
+ }
225
+ return {
226
+ ok: true,
227
+ eventId: headers["x-shopify-webhook-id"] ?? "",
228
+ eventType: headers["x-shopify-topic"] ?? "unknown",
229
+ };
230
+ },
231
+ };
232
+
233
+ export const svixVerifier: Verifier = {
234
+ verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
235
+ if (!secret) return { ok: false, reason: "missing_secret", message: "Svix: secret not configured" };
236
+ const webhookId = headers["webhook-id"];
237
+ const webhookTs = headers["webhook-timestamp"];
238
+ const webhookSig = headers["webhook-signature"];
239
+ if (!webhookId || !webhookTs || !webhookSig) {
240
+ return {
241
+ ok: false,
242
+ reason: "missing_signature",
243
+ message: "Svix/Standard Webhooks: missing webhook-id, webhook-timestamp, or webhook-signature header",
244
+ };
245
+ }
246
+ const tsNum = Number.parseInt(webhookTs, 10);
247
+ if (!Number.isFinite(tsNum)) {
248
+ return { ok: false, reason: "bad_format", message: "Svix: webhook-timestamp is not numeric" };
249
+ }
250
+ if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
251
+ return {
252
+ ok: false,
253
+ reason: "timestamp_drift",
254
+ message: `Svix: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
255
+ };
256
+ }
257
+ const signed = `${webhookId}.${webhookTs}.${rawBody}`;
258
+ // Strip optional `whsec_` prefix Svix recommends for secrets.
259
+ const rawSecret = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
260
+ // Svix encodes the secret as base64 — decode before HMAC.
261
+ const secretBuf = Buffer.from(rawSecret, "base64");
262
+ const expected = createHmac("sha256", secretBuf).update(signed).digest("base64");
263
+ // webhook-signature may include multiple versions: `v1,base64 v1,base64 ...`
264
+ const sigs = webhookSig.split(" ").map((s) => {
265
+ const idx = s.indexOf(",");
266
+ return idx === -1 ? s : s.slice(idx + 1);
267
+ });
268
+ const matched = sigs.some((s) => safeEqualString(s, expected));
269
+ if (!matched) {
270
+ return { ok: false, reason: "signature_mismatch", message: "Svix: signature mismatch" };
271
+ }
272
+ return {
273
+ ok: true,
274
+ eventId: webhookId,
275
+ eventType: getEventTypeFromBody(parsedBody) ?? "unknown",
276
+ };
277
+ },
278
+ };
279
+
280
+ // =============================================================================
281
+ // Custom verifier — built from the workflow's `signature: { ... }` config
282
+ // =============================================================================
283
+
284
+ export interface CustomSignatureConfig {
285
+ scheme: "hmac-sha256" | "hmac-sha1" | "hmac-sha512";
286
+ header: string;
287
+ format: string;
288
+ secretEnv: string;
289
+ tolerance: number;
290
+ timestampHeader?: string;
291
+ }
292
+
293
+ export function buildCustomVerifier(config: CustomSignatureConfig): Verifier {
294
+ const algo: "sha256" | "sha1" | "sha512" =
295
+ config.scheme === "hmac-sha1" ? "sha1" : config.scheme === "hmac-sha512" ? "sha512" : "sha256";
296
+ const headerLower = config.header.toLowerCase();
297
+ const tsHeaderLower = config.timestampHeader?.toLowerCase();
298
+
299
+ return {
300
+ verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
301
+ if (!secret) return { ok: false, reason: "missing_secret", message: `${config.header}: secret not configured` };
302
+ const sig = headers[headerLower];
303
+ if (!sig) {
304
+ return { ok: false, reason: "missing_signature", message: `${config.header}: header missing` };
305
+ }
306
+
307
+ let signedString = rawBody;
308
+ if (tsHeaderLower) {
309
+ const ts = headers[tsHeaderLower];
310
+ if (!ts) {
311
+ return {
312
+ ok: false,
313
+ reason: "missing_timestamp",
314
+ message: `${config.timestampHeader}: header missing`,
315
+ };
316
+ }
317
+ const tsNum = Number.parseInt(ts, 10);
318
+ if (!Number.isFinite(tsNum)) {
319
+ return {
320
+ ok: false,
321
+ reason: "bad_format",
322
+ message: `${config.timestampHeader}: not numeric`,
323
+ };
324
+ }
325
+ if (!isWithinTolerance(tsNum, toleranceSec || config.tolerance)) {
326
+ return {
327
+ ok: false,
328
+ reason: "timestamp_drift",
329
+ message: `Timestamp drift exceeds ${toleranceSec || config.tolerance}s tolerance`,
330
+ };
331
+ }
332
+ signedString = `${ts}.${rawBody}`;
333
+ }
334
+
335
+ const hex = hmacHex(algo, secret, signedString);
336
+ const base64 = hmacBase64(algo, secret, signedString);
337
+ const expected = config.format.replace("{hex}", hex).replace("{base64}", base64);
338
+
339
+ if (!safeEqualString(sig, expected)) {
340
+ return { ok: false, reason: "signature_mismatch", message: `${config.header}: signature mismatch` };
341
+ }
342
+ return {
343
+ ok: true,
344
+ eventId: "",
345
+ eventType: getEventTypeFromBody(parsedBody) ?? "unknown",
346
+ };
347
+ },
348
+ };
349
+ }
350
+
351
+ export const BUILTIN_VERIFIERS: Record<string, Verifier> = {
352
+ github: githubVerifier,
353
+ stripe: stripeVerifier,
354
+ slack: slackVerifier,
355
+ shopify: shopifyVerifier,
356
+ svix: svixVerifier,
357
+ };