@hookflo/tern 4.3.0 → 4.3.1-beta
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/README.md +4 -0
- package/dist/index.js +11 -1
- package/dist/platforms/algorithms.js +65 -0
- package/dist/types.d.ts +5 -1
- package/dist/types.js +4 -0
- package/dist/verifiers/algorithms.d.ts +2 -0
- package/dist/verifiers/algorithms.js +91 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -189,6 +189,10 @@ app.post('/webhooks/stripe', createWebhookHandler({
|
|
|
189
189
|
| **Grafana** | HMAC-SHA256 | ✅ Tested |
|
|
190
190
|
| **Doppler** | HMAC-SHA256 | ✅ Tested |
|
|
191
191
|
| **Sanity** | HMAC-SHA256 | ✅ Tested |
|
|
192
|
+
| **Svix** | HMAC-SHA256 | ⚠️ Untested for now |
|
|
193
|
+
| **Linear** | HMAC-SHA256 | ⚠️ Untested for now |
|
|
194
|
+
| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now |
|
|
195
|
+
| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now |
|
|
192
196
|
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
|
|
193
197
|
| **Vercel** | HMAC-SHA256 | 🔄 Pending |
|
|
194
198
|
|
package/dist/index.js
CHANGED
|
@@ -189,6 +189,10 @@ class WebhookVerificationService {
|
|
|
189
189
|
case 'workos':
|
|
190
190
|
case 'sentry':
|
|
191
191
|
case 'vercel':
|
|
192
|
+
case 'linear':
|
|
193
|
+
case 'pagerduty':
|
|
194
|
+
case 'twilio':
|
|
195
|
+
case 'svix':
|
|
192
196
|
return this.pickString(payload?.id) || null;
|
|
193
197
|
case 'doppler':
|
|
194
198
|
return this.pickString(payload?.event?.id, metadata?.id) || null;
|
|
@@ -220,7 +224,13 @@ class WebhookVerificationService {
|
|
|
220
224
|
if (headers.has('x-hub-signature-256'))
|
|
221
225
|
return 'github';
|
|
222
226
|
if (headers.has('svix-signature'))
|
|
223
|
-
return 'clerk';
|
|
227
|
+
return headers.has('svix-id') ? 'svix' : 'clerk';
|
|
228
|
+
if (headers.has('linear-signature'))
|
|
229
|
+
return 'linear';
|
|
230
|
+
if (headers.has('x-pagerduty-signature'))
|
|
231
|
+
return 'pagerduty';
|
|
232
|
+
if (headers.has('x-twilio-signature'))
|
|
233
|
+
return 'twilio';
|
|
224
234
|
if (headers.has('workos-signature'))
|
|
225
235
|
return 'workos';
|
|
226
236
|
if (headers.has('webhook-signature')) {
|
|
@@ -51,6 +51,27 @@ exports.platformAlgorithmConfigs = {
|
|
|
51
51
|
},
|
|
52
52
|
description: "Clerk webhooks use HMAC-SHA256 with base64 encoding",
|
|
53
53
|
},
|
|
54
|
+
svix: {
|
|
55
|
+
platform: 'svix',
|
|
56
|
+
signatureConfig: {
|
|
57
|
+
algorithm: 'hmac-sha256',
|
|
58
|
+
headerName: 'svix-signature',
|
|
59
|
+
headerFormat: 'raw',
|
|
60
|
+
timestampHeader: 'svix-timestamp',
|
|
61
|
+
timestampFormat: 'unix',
|
|
62
|
+
payloadFormat: 'custom',
|
|
63
|
+
customConfig: {
|
|
64
|
+
signatureFormat: 'v1={signature}',
|
|
65
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
66
|
+
encoding: 'base64',
|
|
67
|
+
secretEncoding: 'base64',
|
|
68
|
+
idHeader: 'svix-id',
|
|
69
|
+
idHeaderAliases: ['webhook-id'],
|
|
70
|
+
timestampHeaderAliases: ['webhook-timestamp'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
description: 'Svix webhooks use HMAC-SHA256 with Standard Webhooks format',
|
|
74
|
+
},
|
|
54
75
|
dodopayments: {
|
|
55
76
|
platform: "dodopayments",
|
|
56
77
|
signatureConfig: {
|
|
@@ -287,6 +308,50 @@ exports.platformAlgorithmConfigs = {
|
|
|
287
308
|
},
|
|
288
309
|
description: "Sanity webhooks use Stripe-compatible HMAC-SHA256 with base64 encoded signature and plain UTF-8 secret",
|
|
289
310
|
},
|
|
311
|
+
linear: {
|
|
312
|
+
platform: 'linear',
|
|
313
|
+
signatureConfig: {
|
|
314
|
+
algorithm: 'hmac-sha256',
|
|
315
|
+
headerName: 'linear-signature',
|
|
316
|
+
headerFormat: 'raw',
|
|
317
|
+
payloadFormat: 'raw',
|
|
318
|
+
customConfig: {
|
|
319
|
+
replayToleranceMs: 60000,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
description: 'Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window',
|
|
323
|
+
},
|
|
324
|
+
pagerduty: {
|
|
325
|
+
platform: 'pagerduty',
|
|
326
|
+
signatureConfig: {
|
|
327
|
+
algorithm: 'hmac-sha256',
|
|
328
|
+
headerName: 'x-pagerduty-signature',
|
|
329
|
+
headerFormat: 'raw',
|
|
330
|
+
payloadFormat: 'raw',
|
|
331
|
+
prefix: 'v1=',
|
|
332
|
+
customConfig: {
|
|
333
|
+
signatureFormat: 'v1={signature}',
|
|
334
|
+
comparePrefixed: true,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
description: 'PagerDuty webhooks use HMAC-SHA256 with v1=<hex> signatures',
|
|
338
|
+
},
|
|
339
|
+
twilio: {
|
|
340
|
+
platform: 'twilio',
|
|
341
|
+
signatureConfig: {
|
|
342
|
+
algorithm: 'hmac-sha1',
|
|
343
|
+
headerName: 'x-twilio-signature',
|
|
344
|
+
headerFormat: 'raw',
|
|
345
|
+
payloadFormat: 'custom',
|
|
346
|
+
customConfig: {
|
|
347
|
+
payloadFormat: '{url}',
|
|
348
|
+
encoding: 'base64',
|
|
349
|
+
secretEncoding: 'utf8',
|
|
350
|
+
validateBodySHA256: true,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)',
|
|
354
|
+
},
|
|
290
355
|
custom: {
|
|
291
356
|
platform: "custom",
|
|
292
357
|
signatureConfig: {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
export type WebhookPlatform = 'custom' | 'clerk' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'paddle' | 'razorpay' | 'lemonsqueezy' | 'workos' | 'woocommerce' | 'replicateai' | 'falai' | 'sentry' | 'grafana' | 'doppler' | 'sanity' | 'unknown';
|
|
1
|
+
export type WebhookPlatform = 'custom' | 'clerk' | 'svix' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'paddle' | 'razorpay' | 'lemonsqueezy' | 'workos' | 'woocommerce' | 'replicateai' | 'falai' | 'sentry' | 'grafana' | 'doppler' | 'sanity' | 'linear' | 'pagerduty' | 'twilio' | 'unknown';
|
|
2
2
|
export declare enum WebhookPlatformKeys {
|
|
3
3
|
GitHub = "github",
|
|
4
4
|
Stripe = "stripe",
|
|
5
5
|
Clerk = "clerk",
|
|
6
|
+
Svix = "svix",
|
|
6
7
|
DodoPayments = "dodopayments",
|
|
7
8
|
Shopify = "shopify",
|
|
8
9
|
Vercel = "vercel",
|
|
@@ -19,6 +20,9 @@ export declare enum WebhookPlatformKeys {
|
|
|
19
20
|
Grafana = "grafana",
|
|
20
21
|
Doppler = "doppler",
|
|
21
22
|
Sanity = "sanity",
|
|
23
|
+
Linear = "linear",
|
|
24
|
+
PagerDuty = "pagerduty",
|
|
25
|
+
Twilio = "twilio",
|
|
22
26
|
Custom = "custom",
|
|
23
27
|
Unknown = "unknown"
|
|
24
28
|
}
|
package/dist/types.js
CHANGED
|
@@ -6,6 +6,7 @@ var WebhookPlatformKeys;
|
|
|
6
6
|
WebhookPlatformKeys["GitHub"] = "github";
|
|
7
7
|
WebhookPlatformKeys["Stripe"] = "stripe";
|
|
8
8
|
WebhookPlatformKeys["Clerk"] = "clerk";
|
|
9
|
+
WebhookPlatformKeys["Svix"] = "svix";
|
|
9
10
|
WebhookPlatformKeys["DodoPayments"] = "dodopayments";
|
|
10
11
|
WebhookPlatformKeys["Shopify"] = "shopify";
|
|
11
12
|
WebhookPlatformKeys["Vercel"] = "vercel";
|
|
@@ -22,6 +23,9 @@ var WebhookPlatformKeys;
|
|
|
22
23
|
WebhookPlatformKeys["Grafana"] = "grafana";
|
|
23
24
|
WebhookPlatformKeys["Doppler"] = "doppler";
|
|
24
25
|
WebhookPlatformKeys["Sanity"] = "sanity";
|
|
26
|
+
WebhookPlatformKeys["Linear"] = "linear";
|
|
27
|
+
WebhookPlatformKeys["PagerDuty"] = "pagerduty";
|
|
28
|
+
WebhookPlatformKeys["Twilio"] = "twilio";
|
|
25
29
|
WebhookPlatformKeys["Custom"] = "custom";
|
|
26
30
|
WebhookPlatformKeys["Unknown"] = "unknown";
|
|
27
31
|
})(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
|
|
@@ -18,6 +18,8 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
|
|
|
18
18
|
protected extractMetadata(request: Request): Record<string, any>;
|
|
19
19
|
}
|
|
20
20
|
export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
21
|
+
private validateLinearReplayWindow;
|
|
22
|
+
private validateTwilioBodyHash;
|
|
21
23
|
private resolveSentryPayloadCandidates;
|
|
22
24
|
verify(request: Request): Promise<WebhookVerificationResult>;
|
|
23
25
|
}
|
|
@@ -63,9 +63,34 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
63
63
|
}
|
|
64
64
|
// Accept "v1=<signature>" variants used by some providers/docs.
|
|
65
65
|
if (sig.startsWith("v1=")) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
if (this.config.customConfig?.comparePrefixed) {
|
|
67
|
+
for (const fragment of sig.split(',')) {
|
|
68
|
+
const candidate = fragment.trim();
|
|
69
|
+
if (candidate.startsWith('v1=')) {
|
|
70
|
+
normalized.push(candidate);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const [, value] = sig.split("=", 2);
|
|
76
|
+
if (value) {
|
|
77
|
+
normalized.push(value.trim());
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const fragment of sig.split(',')) {
|
|
83
|
+
const candidate = fragment.trim();
|
|
84
|
+
if (candidate.startsWith('v1=')) {
|
|
85
|
+
if (this.config.customConfig?.comparePrefixed) {
|
|
86
|
+
normalized.push(candidate);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const [, value] = candidate.split('=', 2);
|
|
90
|
+
if (value) {
|
|
91
|
+
normalized.push(value.trim());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
69
94
|
}
|
|
70
95
|
}
|
|
71
96
|
}
|
|
@@ -77,7 +102,9 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
77
102
|
extractTimestamp(request) {
|
|
78
103
|
if (!this.config.timestampHeader)
|
|
79
104
|
return null;
|
|
80
|
-
const timestampHeader = request.headers.get(this.config.timestampHeader)
|
|
105
|
+
const timestampHeader = request.headers.get(this.config.timestampHeader)
|
|
106
|
+
|| this.config.customConfig?.timestampHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean)
|
|
107
|
+
|| null;
|
|
81
108
|
if (!timestampHeader)
|
|
82
109
|
return null;
|
|
83
110
|
switch (this.config.timestampFormat) {
|
|
@@ -108,7 +135,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
108
135
|
return true;
|
|
109
136
|
// These platforms have timestampHeader in config but timestamp
|
|
110
137
|
// is optional in their spec — validate only if present, never mandate
|
|
111
|
-
const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana'];
|
|
138
|
+
const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana', 'twilio'];
|
|
112
139
|
if (optionalTimestampPlatforms.includes(this.platform))
|
|
113
140
|
return false;
|
|
114
141
|
// For all other platforms: infer from config
|
|
@@ -152,7 +179,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
152
179
|
}
|
|
153
180
|
const customFormat = this.config.customConfig.payloadFormat;
|
|
154
181
|
if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) {
|
|
155
|
-
const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id");
|
|
182
|
+
const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id") || this.config.customConfig?.idHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean);
|
|
156
183
|
const timestamp = request.headers.get(this.config.timestampHeader ||
|
|
157
184
|
this.config.customConfig?.timestampHeader ||
|
|
158
185
|
"x-webhook-timestamp");
|
|
@@ -167,6 +194,11 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
167
194
|
.replace("{timestamp}", timestamp.trim() || "")
|
|
168
195
|
.replace("{body}", rawBody);
|
|
169
196
|
}
|
|
197
|
+
if (customFormat.includes('{url}')) {
|
|
198
|
+
return customFormat
|
|
199
|
+
.replace('{url}', request.url)
|
|
200
|
+
.replace('{body}', rawBody);
|
|
201
|
+
}
|
|
170
202
|
if (customFormat.includes("{timestamp}") &&
|
|
171
203
|
customFormat.includes("{body}")) {
|
|
172
204
|
const timestamp = this.extractTimestampFromSignature(request) ||
|
|
@@ -250,6 +282,40 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
250
282
|
}
|
|
251
283
|
exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
|
|
252
284
|
class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
285
|
+
validateLinearReplayWindow(rawBody) {
|
|
286
|
+
if (this.platform !== 'linear')
|
|
287
|
+
return null;
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(rawBody);
|
|
290
|
+
const rawTimestamp = parsed.webhookTimestamp;
|
|
291
|
+
const timestampMs = Number(rawTimestamp);
|
|
292
|
+
if (!Number.isFinite(timestampMs)) {
|
|
293
|
+
return 'Missing or invalid Linear webhookTimestamp';
|
|
294
|
+
}
|
|
295
|
+
const replayToleranceMs = this.config.customConfig?.replayToleranceMs || 60000;
|
|
296
|
+
if (Math.abs(Date.now() - timestampMs) > replayToleranceMs) {
|
|
297
|
+
return 'Linear webhook timestamp is outside the replay window';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return 'Linear webhook replay check requires JSON payload';
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
validateTwilioBodyHash(rawBody, request) {
|
|
306
|
+
if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const url = new URL(request.url);
|
|
310
|
+
const bodySha = url.searchParams.get('bodySHA256');
|
|
311
|
+
if (!bodySha)
|
|
312
|
+
return null;
|
|
313
|
+
const computed = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
|
|
314
|
+
if (!this.safeCompare(computed, bodySha)) {
|
|
315
|
+
return 'Twilio bodySHA256 query param does not match payload hash';
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
253
319
|
resolveSentryPayloadCandidates(rawBody, request) {
|
|
254
320
|
const candidates = [
|
|
255
321
|
this.formatPayload(rawBody, request),
|
|
@@ -289,6 +355,24 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
289
355
|
};
|
|
290
356
|
}
|
|
291
357
|
const rawBody = await request.text();
|
|
358
|
+
const linearReplayError = this.validateLinearReplayWindow(rawBody);
|
|
359
|
+
if (linearReplayError) {
|
|
360
|
+
return {
|
|
361
|
+
isValid: false,
|
|
362
|
+
error: linearReplayError,
|
|
363
|
+
errorCode: 'TIMESTAMP_EXPIRED',
|
|
364
|
+
platform: this.platform,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request);
|
|
368
|
+
if (twilioBodyHashError) {
|
|
369
|
+
return {
|
|
370
|
+
isValid: false,
|
|
371
|
+
error: twilioBodyHashError,
|
|
372
|
+
errorCode: 'INVALID_SIGNATURE',
|
|
373
|
+
platform: this.platform,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
292
376
|
let timestamp = null;
|
|
293
377
|
if (this.config.headerFormat === "comma-separated") {
|
|
294
378
|
timestamp = this.extractTimestampFromSignature(request);
|
|
@@ -322,7 +406,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
322
406
|
if (this.config.customConfig?.encoding === "base64") {
|
|
323
407
|
isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
|
|
324
408
|
}
|
|
325
|
-
else if (this.config.headerFormat === "prefixed") {
|
|
409
|
+
else if (this.config.headerFormat === "prefixed" || this.config.customConfig?.comparePrefixed) {
|
|
326
410
|
isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
|
|
327
411
|
}
|
|
328
412
|
else {
|
package/package.json
CHANGED