From cc1700907af68c2fb4d4edd9317953691e0c611b Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:25:12 +0530 Subject: [PATCH 1/2] docs: add new webhook platforms to README as untested --- README.md | 4 ++ src/index.ts | 9 ++- src/platforms/algorithms.ts | 69 ++++++++++++++++++++ src/test.ts | 125 ++++++++++++++++++++++++++++++++++++ src/types.ts | 8 +++ src/verifiers/algorithms.ts | 106 ++++++++++++++++++++++++++++-- 6 files changed, 313 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c2e43e0..2b40508 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,10 @@ app.post('/webhooks/stripe', createWebhookHandler({ | **Grafana** | HMAC-SHA256 | ✅ Tested | | **Doppler** | HMAC-SHA256 | ✅ Tested | | **Sanity** | HMAC-SHA256 | ✅ Tested | +| **Svix** | HMAC-SHA256 | ⚠️ Untested for now | +| **Linear** | HMAC-SHA256 | ⚠️ Untested for now | +| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now | +| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now | | **Razorpay** | HMAC-SHA256 | 🔄 Pending | | **Vercel** | HMAC-SHA256 | 🔄 Pending | diff --git a/src/index.ts b/src/index.ts index 3997d65..2dcc0a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -246,6 +246,10 @@ export class WebhookVerificationService { case 'workos': case 'sentry': case 'vercel': + case 'linear': + case 'pagerduty': + case 'twilio': + case 'svix': return this.pickString(payload?.id) || null; case 'doppler': return this.pickString(payload?.event?.id, metadata?.id) || null; @@ -287,7 +291,10 @@ export class WebhookVerificationService { if (headers.has('stripe-signature')) return 'stripe'; if (headers.has('x-hub-signature-256')) return 'github'; - if (headers.has('svix-signature')) return 'clerk'; + if (headers.has('svix-signature')) return headers.has('svix-id') ? 'svix' : 'clerk'; + if (headers.has('linear-signature')) return 'linear'; + if (headers.has('x-pagerduty-signature')) return 'pagerduty'; + if (headers.has('x-twilio-signature')) return 'twilio'; if (headers.has('workos-signature')) return 'workos'; if (headers.has('webhook-signature')) { const userAgent = headers.get('user-agent')?.toLowerCase() || ''; diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index e7e9245..9e501ba 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -56,6 +56,28 @@ export const platformAlgorithmConfigs: Record< description: "Clerk webhooks use HMAC-SHA256 with base64 encoding", }, + svix: { + platform: 'svix', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'svix-signature', + headerFormat: 'raw', + timestampHeader: 'svix-timestamp', + timestampFormat: 'unix', + payloadFormat: 'custom', + customConfig: { + signatureFormat: 'v1={signature}', + payloadFormat: '{id}.{timestamp}.{body}', + encoding: 'base64', + secretEncoding: 'base64', + idHeader: 'svix-id', + idHeaderAliases: ['webhook-id'], + timestampHeaderAliases: ['webhook-timestamp'], + }, + }, + description: 'Svix webhooks use HMAC-SHA256 with Standard Webhooks format', + }, + dodopayments: { platform: "dodopayments", signatureConfig: { @@ -322,6 +344,53 @@ export const platformAlgorithmConfigs: Record< "Sanity webhooks use Stripe-compatible HMAC-SHA256 with base64 encoded signature and plain UTF-8 secret", }, + linear: { + platform: 'linear', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'linear-signature', + headerFormat: 'raw', + payloadFormat: 'raw', + customConfig: { + replayToleranceMs: 60_000, + }, + }, + description: 'Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window', + }, + + pagerduty: { + platform: 'pagerduty', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'x-pagerduty-signature', + headerFormat: 'raw', + payloadFormat: 'raw', + prefix: 'v1=', + customConfig: { + signatureFormat: 'v1={signature}', + comparePrefixed: true, + }, + }, + description: 'PagerDuty webhooks use HMAC-SHA256 with v1= signatures', + }, + + twilio: { + platform: 'twilio', + signatureConfig: { + algorithm: 'hmac-sha1', + headerName: 'x-twilio-signature', + headerFormat: 'raw', + payloadFormat: 'custom', + customConfig: { + payloadFormat: '{url}', + encoding: 'base64', + secretEncoding: 'utf8', + validateBodySHA256: true, + }, + }, + description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)', + }, + custom: { platform: "custom", signatureConfig: { diff --git a/src/test.ts b/src/test.ts index 98bc356..d49e3d4 100644 --- a/src/test.ts +++ b/src/test.ts @@ -114,6 +114,32 @@ function createSanitySignature(body: string, secret: string, timestamp: number): return `t=${timestamp},v1=${hmac.digest('base64')}`; } +function createPagerDutySignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return `v1=${hmac.digest('hex')}`; +} + +function createLinearSignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return hmac.digest('hex'); +} + +function createSvixSignature(body: string, secret: string, id: string, timestamp: number): string { + const signedContent = `${id}.${timestamp}.${body}`; + const secretBytes = new Uint8Array(Buffer.from(secret.split('whsec_')[1], 'base64')); + const hmac = createHmac('sha256', secretBytes); + hmac.update(signedContent); + return `v1,${hmac.digest('base64')}`; +} + +function createTwilioSignature(url: string, authToken: string): string { + const hmac = createHmac('sha1', authToken); + hmac.update(url); + return hmac.digest('base64'); +} + function createFalPayloadToSign(body: string, requestId: string, userId: string, timestamp: string): string { const bodyHash = createHash('sha256').update(body).digest('hex'); return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`; @@ -992,6 +1018,105 @@ async function runTests() { console.log(' ❌ Hono invalid signature test failed:', error); } + // Test 26: PagerDuty platform verification + console.log('\n26. Testing PagerDuty platform verification...'); + try { + const payload = JSON.stringify({ messages: [{ event: 'incident.triggered' }] }); + const signature = createPagerDutySignature(payload, testSecret); + const request = createMockRequest({ + 'x-pagerduty-signature': `${signature},v1=deadbeef`, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'pagerduty', + testSecret, + ); + + console.log(' ✅ PagerDuty:', trackCheck('pagerduty platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ PagerDuty platform verifier test failed:', error); + } + + // Test 27: Linear platform verification with replay protection + console.log('\n27. Testing Linear platform verification...'); + try { + const payload = JSON.stringify({ + action: 'Issue', + webhookTimestamp: Date.now(), + }); + const signature = createLinearSignature(payload, testSecret); + const request = createMockRequest({ + 'linear-signature': signature, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'linear', + testSecret, + ); + + console.log(' ✅ Linear:', trackCheck('linear platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Linear platform verifier test failed:', error); + } + + // Test 28: Svix platform verification with replay protection + console.log('\n28. Testing Svix platform verification...'); + try { + const id = 'msg_2LJC7S5QfRZk9k9bM2QxWjv1l3U'; + const timestamp = Math.floor(Date.now() / 1000); + const payload = JSON.stringify({ type: 'invoice.paid' }); + const svixSecret = `whsec_${Buffer.from(testSecret).toString('base64')}`; + const signature = createSvixSignature(payload, svixSecret, id, timestamp); + + const request = createMockRequest({ + 'svix-id': id, + 'svix-timestamp': String(timestamp), + 'svix-signature': `${signature} v1,invalid`, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'svix', + svixSecret, + ); + + console.log(' ✅ Svix:', trackCheck('svix platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Svix platform verifier test failed:', error); + } + + // Test 29: Twilio platform verification (JSON + bodySHA256) + console.log('\n29. Testing Twilio platform verification...'); + try { + const payload = JSON.stringify({ callSid: 'CA123', status: 'completed' }); + const bodySha256 = createHash('sha256').update(payload).digest('hex'); + const url = `https://example.com/twilio/webhook?bodySHA256=${bodySha256}`; + const signature = createTwilioSignature(url, testSecret); + const request = new Request(url, { + method: 'POST', + headers: { + 'x-twilio-signature': signature, + 'content-type': 'application/json', + }, + body: payload, + }); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'twilio', + testSecret, + ); + + console.log(' ✅ Twilio:', trackCheck('twilio platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Twilio platform verifier test failed:', error); + } + if (failedChecks.length > 0) { throw new Error(`Test checks failed: ${failedChecks.join(', ')}`); } diff --git a/src/types.ts b/src/types.ts index 8af4f33..c2ae798 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export type WebhookPlatform = | 'custom' | 'clerk' + | 'svix' | 'github' | 'stripe' | 'shopify' @@ -19,12 +20,16 @@ export type WebhookPlatform = | 'grafana' | 'doppler' | 'sanity' + | 'linear' + | 'pagerduty' + | 'twilio' | 'unknown'; export enum WebhookPlatformKeys { GitHub = 'github', Stripe = 'stripe', Clerk = 'clerk', + Svix = 'svix', DodoPayments = 'dodopayments', Shopify = 'shopify', Vercel = 'vercel', @@ -41,6 +46,9 @@ export enum WebhookPlatformKeys { Grafana = 'grafana', Doppler = 'doppler', Sanity = 'sanity', + Linear = 'linear', + PagerDuty = 'pagerduty', + Twilio = 'twilio', Custom = 'custom', Unknown = 'unknown' } diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 13f8e94..465fc7f 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -92,9 +92,33 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { // Accept "v1=" variants used by some providers/docs. if (sig.startsWith("v1=")) { - const [, value] = sig.split("=", 2); - if (value) { - normalized.push(value.trim()); + if (this.config.customConfig?.comparePrefixed) { + for (const fragment of sig.split(',')) { + const candidate = fragment.trim(); + if (candidate.startsWith('v1=')) { + normalized.push(candidate); + } + } + } else { + const [, value] = sig.split("=", 2); + if (value) { + normalized.push(value.trim()); + } + } + continue; + } + + for (const fragment of sig.split(',')) { + const candidate = fragment.trim(); + if (candidate.startsWith('v1=')) { + if (this.config.customConfig?.comparePrefixed) { + normalized.push(candidate); + } else { + const [, value] = candidate.split('=', 2); + if (value) { + normalized.push(value.trim()); + } + } } } } @@ -108,7 +132,9 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { protected extractTimestamp(request: Request): number | null { if (!this.config.timestampHeader) return null; - const timestampHeader = request.headers.get(this.config.timestampHeader); + const timestampHeader = request.headers.get(this.config.timestampHeader) + || this.config.customConfig?.timestampHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean) + || null; if (!timestampHeader) return null; switch (this.config.timestampFormat) { @@ -142,7 +168,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { // These platforms have timestampHeader in config but timestamp // is optional in their spec — validate only if present, never mandate - const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana']; + const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana', 'twilio']; if (optionalTimestampPlatforms.includes(this.platform as string)) return false; // For all other platforms: infer from config @@ -193,7 +219,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) { const id = request.headers.get( this.config.customConfig.idHeader || "x-webhook-id", - ); + ) || this.config.customConfig?.idHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean); const timestamp = request.headers.get( this.config.timestampHeader || this.config.customConfig?.timestampHeader || @@ -219,6 +245,12 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { .replace("{body}", rawBody); } + if (customFormat.includes('{url}')) { + return customFormat + .replace('{url}', request.url) + .replace('{body}', rawBody); + } + if ( customFormat.includes("{timestamp}") && customFormat.includes("{body}") @@ -336,6 +368,46 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { } export class GenericHMACVerifier extends AlgorithmBasedVerifier { + private validateLinearReplayWindow(rawBody: string): string | null { + if (this.platform !== 'linear') return null; + + try { + const parsed = JSON.parse(rawBody) as Record; + const rawTimestamp = parsed.webhookTimestamp; + const timestampMs = Number(rawTimestamp); + + if (!Number.isFinite(timestampMs)) { + return 'Missing or invalid Linear webhookTimestamp'; + } + + const replayToleranceMs = this.config.customConfig?.replayToleranceMs || 60_000; + if (Math.abs(Date.now() - timestampMs) > replayToleranceMs) { + return 'Linear webhook timestamp is outside the replay window'; + } + } catch { + return 'Linear webhook replay check requires JSON payload'; + } + + return null; + } + + private validateTwilioBodyHash(rawBody: string, request: Request): string | null { + if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) { + return null; + } + + const url = new URL(request.url); + const bodySha = url.searchParams.get('bodySHA256'); + if (!bodySha) return null; + + const computed = createHash('sha256').update(rawBody).digest('hex'); + if (!this.safeCompare(computed, bodySha)) { + return 'Twilio bodySHA256 query param does not match payload hash'; + } + + return null; + } + private resolveSentryPayloadCandidates( rawBody: string, request: Request, @@ -385,6 +457,26 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { const rawBody = await request.text(); + const linearReplayError = this.validateLinearReplayWindow(rawBody); + if (linearReplayError) { + return { + isValid: false, + error: linearReplayError, + errorCode: 'TIMESTAMP_EXPIRED', + platform: this.platform, + }; + } + + const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request); + if (twilioBodyHashError) { + return { + isValid: false, + error: twilioBodyHashError, + errorCode: 'INVALID_SIGNATURE', + platform: this.platform, + }; + } + let timestamp: number | null = null; if (this.config.headerFormat === "comma-separated") { timestamp = this.extractTimestampFromSignature(request); @@ -422,7 +514,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { for (const signature of signatures) { if (this.config.customConfig?.encoding === "base64") { isValid = this.verifyHMACWithBase64(payload, signature, algorithm); - } else if (this.config.headerFormat === "prefixed") { + } else if (this.config.headerFormat === "prefixed" || this.config.customConfig?.comparePrefixed) { isValid = this.verifyHMACWithPrefix(payload, signature, algorithm); } else { isValid = this.verifyHMAC(payload, signature, algorithm); From 891c497c11fc97aa809014a4f06a99fb0dee5b88 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Sun, 15 Mar 2026 13:45:14 +0530 Subject: [PATCH 2/2] package version update --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdd6809..9fd6965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.1-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" @@ -3556,9 +3556,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 432cd47..1dbceb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.1-beta", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts",