From 418a509baa58fddcdd623660d8dbd9d4fc3ba88c Mon Sep 17 00:00:00 2001 From: eslam-reda-div Date: Mon, 11 May 2026 22:57:15 +0300 Subject: [PATCH 1/4] feat: add webhook signature verification helpers --- src/helpers/main.ts | 10 + src/helpers/webhooks.ts | 471 ++++++++++++++++++++++++++++++++++++++++ tests/webhooks.spec.ts | 93 ++++++++ 3 files changed, 574 insertions(+) create mode 100644 src/helpers/webhooks.ts create mode 100644 tests/webhooks.spec.ts diff --git a/src/helpers/main.ts b/src/helpers/main.ts index 84dd60e7..c5f4644d 100644 --- a/src/helpers/main.ts +++ b/src/helpers/main.ts @@ -61,3 +61,13 @@ export { VerificationToken } from './verification_token.ts' * to prevent timing attacks. */ export { safeTiming } from './safe_timing.ts' + +/** + * Webhook signature verification helpers. + */ +export { + createWebhookVerifier, + createStandardWebhookVerifier, + parseStandardWebhookSignatures, + WebhookVerificationError, +} from './webhooks.ts' diff --git a/src/helpers/webhooks.ts b/src/helpers/webhooks.ts new file mode 100644 index 00000000..1939684f --- /dev/null +++ b/src/helpers/webhooks.ts @@ -0,0 +1,471 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createHmac, createPublicKey, verify as verifySignature, type KeyObject } from 'node:crypto' +import string from '@poppinss/utils/string' +import { safeEqual, Secret } from '@poppinss/utils' + +/** + * Raw payload supported by the webhook verifier. + */ +export type WebhookPayload = string | Buffer + +/** + * Header bag used by the webhook verifiers. + */ +export type WebhookHeaders = Record + +/** + * A parsed webhook signature entry. + */ +export type WebhookSignature = { + scheme: string + signature: string +} + +/** + * Reasons for webhook verification failures. + */ +export type WebhookVerificationErrorCode = + | 'missing_headers' + | 'invalid_timestamp' + | 'timestamp_out_of_range' + | 'invalid_signature_header' + | 'signature_mismatch' + | 'unsupported_signature' + +/** + * Result returned by the webhook verifiers. + */ +export type WebhookVerificationResult = { + isValid: boolean + reason?: WebhookVerificationErrorCode + webhookId?: string + timestamp?: number + matchedSignature?: WebhookSignature +} + +/** + * Successful verification result. + */ +export type WebhookVerificationSuccess = WebhookVerificationResult & { isValid: true } + +/** + * Error raised by the "verifyOrThrow" helpers. + */ +export class WebhookVerificationError extends Error { + code: WebhookVerificationErrorCode + + constructor(code: WebhookVerificationErrorCode, message?: string) { + super(message || WEBHOOK_ERROR_MESSAGES[code]) + this.code = code + this.name = 'WebhookVerificationError' + } +} + +/** + * Supported webhook signing secrets. + */ +export type WebhookKey = string | Buffer | Secret + +/** + * Context passed to the payload builder. + */ +export type WebhookSignedPayloadContext = { + payload: WebhookPayload + headers: Record + webhookId?: string + timestamp?: number +} + +/** + * Options for creating a custom HMAC webhook verifier. + */ +export type WebhookVerifierOptions = { + key: WebhookKey | WebhookKey[] + signatureHeader: string + signatureEncoding?: 'base64' | 'hex' + algorithm?: 'sha256' | 'sha1' | 'sha512' + keyFormat?: 'raw' | 'base64' | 'hex' + parseSignatures: ( + signatureHeaderValue: string, + headers: Record + ) => WebhookSignature[] + buildSignedPayload: (context: WebhookSignedPayloadContext) => string | Buffer + timestampHeader?: string + idHeader?: string + tolerance?: number | string | false + now?: () => number +} + +/** + * Webhook verifier interface. + */ +export type WebhookVerifier = { + verify(payload: WebhookPayload, headers: WebhookHeaders): WebhookVerificationResult + verifyOrThrow(payload: WebhookPayload, headers: WebhookHeaders): WebhookVerificationSuccess +} + +/** + * Options for the Standard Webhooks verifier. + */ +export type StandardWebhookVerifierOptions = { + format?: 'raw' + tolerance?: number | string | false + now?: () => number +} + +const WEBHOOK_ERROR_MESSAGES: Record = { + missing_headers: 'Missing required webhook headers.', + invalid_timestamp: 'Invalid webhook timestamp.', + timestamp_out_of_range: 'Webhook timestamp is outside the allowed tolerance.', + invalid_signature_header: 'Invalid webhook signature header.', + signature_mismatch: 'Webhook signature does not match.', + unsupported_signature: 'Webhook signature scheme is not supported.', +} + +const DEFAULT_STANDARD_TOLERANCE = 5 * 60 +const STANDARD_WEBHOOK_ID_HEADER = 'webhook-id' +const STANDARD_WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp' +const STANDARD_WEBHOOK_SIGNATURE_HEADER = 'webhook-signature' +const STANDARD_SECRET_PREFIX = 'whsec_' +const STANDARD_PUBLIC_PREFIX = 'whpk_' +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +/** + * Create a custom HMAC-based webhook verifier. + */ +export function createWebhookVerifier(options: WebhookVerifierOptions): WebhookVerifier { + if (!options.key || (Array.isArray(options.key) && options.key.length === 0)) { + throw new Error('Webhook signing key cannot be empty.') + } + + const signatureHeader = options.signatureHeader.toLowerCase() + const timestampHeader = options.timestampHeader?.toLowerCase() + const idHeader = options.idHeader?.toLowerCase() + const keys = resolveKeyList(options.key, options.keyFormat ?? 'raw') + const signatureEncoding = options.signatureEncoding ?? 'base64' + const algorithm = options.algorithm ?? 'sha256' + const tolerance = resolveTolerance(options.tolerance) + + const verify = (payload: WebhookPayload, headers: WebhookHeaders): WebhookVerificationResult => { + const normalizedHeaders = normalizeHeaders(headers) + const signatureHeaderValue = normalizedHeaders[signatureHeader] + + if (!signatureHeaderValue) { + return { isValid: false, reason: 'missing_headers' } + } + + const webhookId = idHeader ? normalizedHeaders[idHeader] : undefined + if (idHeader && !webhookId) { + return { isValid: false, reason: 'missing_headers' } + } + + let timestamp: number | undefined + if (timestampHeader) { + const rawTimestamp = normalizedHeaders[timestampHeader] + if (!rawTimestamp) { + return { isValid: false, reason: 'missing_headers', webhookId } + } + + timestamp = Number.parseInt(rawTimestamp, 10) + if (Number.isNaN(timestamp)) { + return { isValid: false, reason: 'invalid_timestamp', webhookId } + } + } + + if (tolerance !== undefined && timestamp !== undefined) { + const now = options.now ? options.now() : Math.floor(Date.now() / 1000) + if (Math.abs(now - timestamp) > tolerance) { + return { isValid: false, reason: 'timestamp_out_of_range', webhookId, timestamp } + } + } + + let signatures: WebhookSignature[] + try { + signatures = options.parseSignatures(signatureHeaderValue, normalizedHeaders) + } catch { + return { isValid: false, reason: 'invalid_signature_header', webhookId, timestamp } + } + + if (!signatures.length) { + return { isValid: false, reason: 'invalid_signature_header', webhookId, timestamp } + } + + const signedPayload = options.buildSignedPayload({ + payload, + headers: normalizedHeaders, + webhookId, + timestamp, + }) + + const signedPayloadBuffer = toBuffer(signedPayload) + const expectedSignatures = keys.map((key) => + createHmac(algorithm, key).update(signedPayloadBuffer).digest(signatureEncoding) + ) + + for (const signature of signatures) { + if (!signature.signature) { + continue + } + + for (const expectedSignature of expectedSignatures) { + if (safeEqual(signature.signature, expectedSignature)) { + return { isValid: true, webhookId, timestamp, matchedSignature: signature } + } + } + } + + return { isValid: false, reason: 'signature_mismatch', webhookId, timestamp } + } + + const verifyOrThrow = ( + payload: WebhookPayload, + headers: WebhookHeaders + ): WebhookVerificationSuccess => { + const result = verify(payload, headers) + if (!result.isValid) { + throw new WebhookVerificationError(result.reason!, WEBHOOK_ERROR_MESSAGES[result.reason!]) + } + + return result as WebhookVerificationSuccess + } + + return { verify, verifyOrThrow } +} + +/** + * Create a Standard Webhooks verifier. + */ +export function createStandardWebhookVerifier( + secret: WebhookKey | WebhookKey[], + options: StandardWebhookVerifierOptions = {} +): WebhookVerifier { + if (!secret || (Array.isArray(secret) && secret.length === 0)) { + throw new Error('Webhook signing key cannot be empty.') + } + + const tolerance = resolveTolerance( + options.tolerance === undefined ? DEFAULT_STANDARD_TOLERANCE : options.tolerance + ) + const nowFn = options.now ?? (() => Math.floor(Date.now() / 1000)) + const keys = resolveStandardKeys(secret, options.format) + const hmacKeys = keys.filter((key) => key.type === 'hmac').map((key) => key.key) + const ed25519Keys = keys.filter((key) => key.type === 'ed25519').map((key) => key.key) + + const verify = (payload: WebhookPayload, headers: WebhookHeaders): WebhookVerificationResult => { + const normalizedHeaders = normalizeHeaders(headers) + const webhookId = normalizedHeaders[STANDARD_WEBHOOK_ID_HEADER] + const timestampHeader = normalizedHeaders[STANDARD_WEBHOOK_TIMESTAMP_HEADER] + const signatureHeader = normalizedHeaders[STANDARD_WEBHOOK_SIGNATURE_HEADER] + + if (!webhookId || !timestampHeader || !signatureHeader) { + return { isValid: false, reason: 'missing_headers' } + } + + const timestamp = Number.parseInt(timestampHeader, 10) + if (Number.isNaN(timestamp)) { + return { isValid: false, reason: 'invalid_timestamp', webhookId } + } + + if (tolerance !== undefined) { + const now = nowFn() + if (Math.abs(now - timestamp) > tolerance) { + return { isValid: false, reason: 'timestamp_out_of_range', webhookId, timestamp } + } + } + + const signatures = parseStandardWebhookSignatures(signatureHeader) + if (!signatures.length) { + return { isValid: false, reason: 'invalid_signature_header', webhookId, timestamp } + } + + const signedPayload = buildStandardSignedPayload(payload, webhookId, timestamp) + const expectedHmacSignatures = hmacKeys.map((key) => + createHmac('sha256', key).update(signedPayload).digest('base64') + ) + + let supportedSignatureSeen = false + + for (const signature of signatures) { + if (signature.scheme === 'v1' && expectedHmacSignatures.length) { + supportedSignatureSeen = true + + for (const expectedSignature of expectedHmacSignatures) { + if (safeEqual(signature.signature, expectedSignature)) { + return { isValid: true, webhookId, timestamp, matchedSignature: signature } + } + } + } + + if (signature.scheme === 'v1a' && ed25519Keys.length) { + supportedSignatureSeen = true + const signatureBytes = Buffer.from(signature.signature, 'base64') + + for (const key of ed25519Keys) { + try { + if (verifySignature(null, signedPayload, key, signatureBytes)) { + return { isValid: true, webhookId, timestamp, matchedSignature: signature } + } + } catch { + // Ignore malformed signatures and continue searching. + } + } + } + } + + if (!supportedSignatureSeen) { + return { isValid: false, reason: 'unsupported_signature', webhookId, timestamp } + } + + return { isValid: false, reason: 'signature_mismatch', webhookId, timestamp } + } + + const verifyOrThrow = ( + payload: WebhookPayload, + headers: WebhookHeaders + ): WebhookVerificationSuccess => { + const result = verify(payload, headers) + if (!result.isValid) { + throw new WebhookVerificationError(result.reason!, WEBHOOK_ERROR_MESSAGES[result.reason!]) + } + + return result as WebhookVerificationSuccess + } + + return { verify, verifyOrThrow } +} + +/** + * Parse Standard Webhooks signature header values. + */ +export function parseStandardWebhookSignatures(signatureHeader: string): WebhookSignature[] { + const signatures: WebhookSignature[] = [] + const matcher = /([a-z0-9]+),([^\s,]+)/gi + + for (const match of signatureHeader.matchAll(matcher)) { + signatures.push({ scheme: match[1].toLowerCase(), signature: match[2] }) + } + + return signatures +} + +type StandardKey = + | { + type: 'hmac' + key: Buffer + } + | { + type: 'ed25519' + key: KeyObject + } + +function normalizeHeaders(headers: WebhookHeaders): Record { + const normalized: Record = {} + + for (const [key, value] of Object.entries(headers)) { + if (typeof value === 'undefined') { + continue + } + + normalized[key.toLowerCase()] = normalizeHeaderValue(value) + } + + return normalized +} + +function normalizeHeaderValue(value: string | string[]): string { + if (Array.isArray(value)) { + return value.join(',') + } + + return value +} + +function resolveTolerance(value?: number | string | false): number | undefined { + if (value === undefined || value === false) { + return undefined + } + + if (typeof value === 'number') { + return value + } + + return string.seconds.parse(value) +} + +function resolveKeyList( + keys: WebhookKey | WebhookKey[], + format: 'raw' | 'base64' | 'hex' +): Buffer[] { + const list = Array.isArray(keys) ? keys : [keys] + return list.map((key) => resolveKeyBytes(key, format)) +} + +function resolveKeyBytes(key: WebhookKey, format: 'raw' | 'base64' | 'hex'): Buffer { + if (Buffer.isBuffer(key)) { + return key + } + + const resolved = key instanceof Secret ? key.release() : key + if (format === 'raw') { + return Buffer.from(String(resolved)) + } + + if (format === 'hex') { + return Buffer.from(String(resolved), 'hex') + } + + return Buffer.from(String(resolved), 'base64') +} + +function resolveStandardKeys(secret: WebhookKey | WebhookKey[], format?: 'raw'): StandardKey[] { + const secrets = Array.isArray(secret) ? secret : [secret] + + return secrets.map((entry) => { + if (Buffer.isBuffer(entry)) { + return { type: 'hmac', key: entry } + } + + const value = entry instanceof Secret ? entry.release() : entry + const secretValue = String(value) + + if (format !== 'raw' && secretValue.startsWith(STANDARD_PUBLIC_PREFIX)) { + const rawPublicKey = Buffer.from(secretValue.slice(STANDARD_PUBLIC_PREFIX.length), 'base64') + const spkiKey = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]) + return { + type: 'ed25519', + key: createPublicKey({ key: spkiKey, format: 'der', type: 'spki' }), + } + } + + if (format === 'raw') { + return { type: 'hmac', key: Buffer.from(secretValue) } + } + + const stripped = secretValue.startsWith(STANDARD_SECRET_PREFIX) + ? secretValue.slice(STANDARD_SECRET_PREFIX.length) + : secretValue + return { type: 'hmac', key: Buffer.from(stripped, 'base64') } + }) +} + +function buildStandardSignedPayload( + payload: WebhookPayload, + webhookId: string, + timestamp: number +): Buffer { + const payloadBuffer = toBuffer(payload) + const prefix = Buffer.from(`${webhookId}.${timestamp}.`) + return Buffer.concat([prefix, payloadBuffer]) +} + +function toBuffer(value: string | Buffer): Buffer { + return typeof value === 'string' ? Buffer.from(value) : value +} diff --git a/tests/webhooks.spec.ts b/tests/webhooks.spec.ts new file mode 100644 index 00000000..28bd8fec --- /dev/null +++ b/tests/webhooks.spec.ts @@ -0,0 +1,93 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { createHmac } from 'node:crypto' +import { createStandardWebhookVerifier, createWebhookVerifier } from '../src/helpers/webhooks.ts' + +test.group('Webhook verification', () => { + test('verify Standard Webhooks signatures', ({ assert }) => { + const rawSecret = 'super-secret' + const secret = `whsec_${Buffer.from(rawSecret).toString('base64')}` + const webhookId = 'msg_2KWPBgLlAfxdpx2AI54pPJ85f4W' + const timestamp = 1700000000 + const payload = JSON.stringify({ type: 'user.created' }) + + const signedPayload = `${webhookId}.${timestamp}.${payload}` + const signature = createHmac('sha256', Buffer.from(rawSecret)) + .update(signedPayload) + .digest('base64') + + const verifier = createStandardWebhookVerifier(secret, { now: () => timestamp }) + const result = verifier.verify(payload, { + 'webhook-id': webhookId, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `v1,${signature}`, + }) + + assert.isTrue(result.isValid) + assert.equal(result.webhookId, webhookId) + assert.equal(result.timestamp, timestamp) + }) + + test('reject when required headers are missing', ({ assert }) => { + const rawSecret = 'super-secret' + const secret = `whsec_${Buffer.from(rawSecret).toString('base64')}` + const verifier = createStandardWebhookVerifier(secret) + + const result = verifier.verify('payload', {}) + assert.isFalse(result.isValid) + assert.equal(result.reason, 'missing_headers') + }) + + test('reject when timestamp is outside tolerance', ({ assert }) => { + const rawSecret = 'super-secret' + const secret = `whsec_${Buffer.from(rawSecret).toString('base64')}` + const webhookId = 'msg_123' + const timestamp = 1700000000 + const payload = JSON.stringify({ type: 'invoice.paid' }) + + const signedPayload = `${webhookId}.${timestamp}.${payload}` + const signature = createHmac('sha256', Buffer.from(rawSecret)) + .update(signedPayload) + .digest('base64') + + const verifier = createStandardWebhookVerifier(secret, { + now: () => timestamp + 1000, + tolerance: 10, + }) + + const result = verifier.verify(payload, { + 'webhook-id': webhookId, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `v1,${signature}`, + }) + + assert.isFalse(result.isValid) + assert.equal(result.reason, 'timestamp_out_of_range') + }) + + test('verify custom HMAC signatures', ({ assert }) => { + const payload = 'hello' + const signature = createHmac('sha256', Buffer.from('custom-secret')) + .update(payload) + .digest('hex') + + const verifier = createWebhookVerifier({ + key: 'custom-secret', + signatureHeader: 'x-signature', + signatureEncoding: 'hex', + parseSignatures: (headerValue) => [{ scheme: 'hmac', signature: headerValue }], + buildSignedPayload: ({ payload: body }) => body, + }) + + const result = verifier.verify(payload, { 'x-signature': signature }) + assert.isTrue(result.isValid) + }) +}) From 796155d38c394d241ef8d20e60f6ab692e757ba1 Mon Sep 17 00:00:00 2001 From: eslam-reda-div Date: Tue, 12 May 2026 18:19:20 +0300 Subject: [PATCH 2/4] fix: harden standard webhook verification --- src/helpers/webhooks.ts | 14 ++++++++++---- tests/webhooks.spec.ts | 43 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/helpers/webhooks.ts b/src/helpers/webhooks.ts index 1939684f..bb9cead7 100644 --- a/src/helpers/webhooks.ts +++ b/src/helpers/webhooks.ts @@ -438,10 +438,16 @@ function resolveStandardKeys(secret: WebhookKey | WebhookKey[], format?: 'raw'): if (format !== 'raw' && secretValue.startsWith(STANDARD_PUBLIC_PREFIX)) { const rawPublicKey = Buffer.from(secretValue.slice(STANDARD_PUBLIC_PREFIX.length), 'base64') - const spkiKey = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]) - return { - type: 'ed25519', - key: createPublicKey({ key: spkiKey, format: 'der', type: 'spki' }), + try { + const spkiKey = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]) + return { + type: 'ed25519', + key: createPublicKey({ key: spkiKey, format: 'der', type: 'spki' }), + } + } catch { + throw new Error( + 'Invalid Standard Webhooks public key. Expected whpk_ base64 encoded ed25519 key.' + ) } } diff --git a/tests/webhooks.spec.ts b/tests/webhooks.spec.ts index 28bd8fec..ef6eceb5 100644 --- a/tests/webhooks.spec.ts +++ b/tests/webhooks.spec.ts @@ -8,7 +8,7 @@ */ import { test } from '@japa/runner' -import { createHmac } from 'node:crypto' +import { createHmac, generateKeyPairSync, sign } from 'node:crypto' import { createStandardWebhookVerifier, createWebhookVerifier } from '../src/helpers/webhooks.ts' test.group('Webhook verification', () => { @@ -36,6 +36,47 @@ test.group('Webhook verification', () => { assert.equal(result.timestamp, timestamp) }) + test('verify Standard Webhooks ed25519 signatures', ({ assert }) => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const spkiPublicKey = publicKey.export({ format: 'der', type: 'spki' }) as Buffer + const prefix = Buffer.from('302a300506032b6570032100', 'hex') + const rawPublicKey = spkiPublicKey.subarray(prefix.length) + const secret = `whpk_${rawPublicKey.toString('base64')}` + const webhookId = 'msg_ed25519' + const timestamp = 1700000000 + const payload = JSON.stringify({ type: 'user.updated' }) + const signedPayload = Buffer.from(`${webhookId}.${timestamp}.${payload}`) + const signature = sign(null, signedPayload, privateKey).toString('base64') + + const verifier = createStandardWebhookVerifier(secret, { now: () => timestamp }) + const result = verifier.verify(payload, { + 'webhook-id': webhookId, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `v1a,${signature}`, + }) + + assert.isTrue(result.isValid) + assert.equal(result.matchedSignature?.scheme, 'v1a') + }) + + test('reject unsupported Standard Webhooks schemes', ({ assert }) => { + const rawSecret = 'super-secret' + const secret = `whsec_${Buffer.from(rawSecret).toString('base64')}` + const webhookId = 'msg_unsupported' + const timestamp = 1700000000 + const payload = JSON.stringify({ type: 'user.deleted' }) + + const verifier = createStandardWebhookVerifier(secret, { now: () => timestamp }) + const result = verifier.verify(payload, { + 'webhook-id': webhookId, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': 'v2,deadbeef', + }) + + assert.isFalse(result.isValid) + assert.equal(result.reason, 'unsupported_signature') + }) + test('reject when required headers are missing', ({ assert }) => { const rawSecret = 'super-secret' const secret = `whsec_${Buffer.from(rawSecret).toString('base64')}` From 048a0ae714dcd1585fe22edba57c477b5dc3c81e Mon Sep 17 00:00:00 2001 From: eslam-reda-div Date: Tue, 12 May 2026 19:20:40 +0300 Subject: [PATCH 3/4] refactor: align standard webhook verification --- src/helpers/webhooks.ts | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/helpers/webhooks.ts b/src/helpers/webhooks.ts index bb9cead7..870b0ebd 100644 --- a/src/helpers/webhooks.ts +++ b/src/helpers/webhooks.ts @@ -138,6 +138,17 @@ const STANDARD_SECRET_PREFIX = 'whsec_' const STANDARD_PUBLIC_PREFIX = 'whpk_' const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') +function wrapVerifyOrThrow(verify: WebhookVerifier['verify']) { + return (payload: WebhookPayload, headers: WebhookHeaders): WebhookVerificationSuccess => { + const result = verify(payload, headers) + if (!result.isValid) { + throw new WebhookVerificationError(result.reason!) + } + + return result as WebhookVerificationSuccess + } +} + /** * Create a custom HMAC-based webhook verifier. */ @@ -225,19 +236,7 @@ export function createWebhookVerifier(options: WebhookVerifierOptions): WebhookV return { isValid: false, reason: 'signature_mismatch', webhookId, timestamp } } - const verifyOrThrow = ( - payload: WebhookPayload, - headers: WebhookHeaders - ): WebhookVerificationSuccess => { - const result = verify(payload, headers) - if (!result.isValid) { - throw new WebhookVerificationError(result.reason!, WEBHOOK_ERROR_MESSAGES[result.reason!]) - } - - return result as WebhookVerificationSuccess - } - - return { verify, verifyOrThrow } + return { verify, verifyOrThrow: wrapVerifyOrThrow(verify) } } /** @@ -327,19 +326,7 @@ export function createStandardWebhookVerifier( return { isValid: false, reason: 'signature_mismatch', webhookId, timestamp } } - const verifyOrThrow = ( - payload: WebhookPayload, - headers: WebhookHeaders - ): WebhookVerificationSuccess => { - const result = verify(payload, headers) - if (!result.isValid) { - throw new WebhookVerificationError(result.reason!, WEBHOOK_ERROR_MESSAGES[result.reason!]) - } - - return result as WebhookVerificationSuccess - } - - return { verify, verifyOrThrow } + return { verify, verifyOrThrow: wrapVerifyOrThrow(verify) } } /** @@ -436,7 +423,7 @@ function resolveStandardKeys(secret: WebhookKey | WebhookKey[], format?: 'raw'): const value = entry instanceof Secret ? entry.release() : entry const secretValue = String(value) - if (format !== 'raw' && secretValue.startsWith(STANDARD_PUBLIC_PREFIX)) { + if (secretValue.startsWith(STANDARD_PUBLIC_PREFIX)) { const rawPublicKey = Buffer.from(secretValue.slice(STANDARD_PUBLIC_PREFIX.length), 'base64') try { const spkiKey = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]) From 9fca45afb79573ad6cedb8febfa05e19f3e871ce Mon Sep 17 00:00:00 2001 From: eslam-reda-div Date: Tue, 12 May 2026 20:23:04 +0300 Subject: [PATCH 4/4] fix: harden webhook verifier errors --- src/helpers/webhooks.ts | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/helpers/webhooks.ts b/src/helpers/webhooks.ts index 870b0ebd..83c15517 100644 --- a/src/helpers/webhooks.ts +++ b/src/helpers/webhooks.ts @@ -37,6 +37,7 @@ export type WebhookVerificationErrorCode = | 'invalid_timestamp' | 'timestamp_out_of_range' | 'invalid_signature_header' + | 'invalid_signed_payload' | 'signature_mismatch' | 'unsupported_signature' @@ -126,6 +127,7 @@ const WEBHOOK_ERROR_MESSAGES: Record = { invalid_timestamp: 'Invalid webhook timestamp.', timestamp_out_of_range: 'Webhook timestamp is outside the allowed tolerance.', invalid_signature_header: 'Invalid webhook signature header.', + invalid_signed_payload: 'Unable to build signed webhook payload.', signature_mismatch: 'Webhook signature does not match.', unsupported_signature: 'Webhook signature scheme is not supported.', } @@ -209,12 +211,17 @@ export function createWebhookVerifier(options: WebhookVerifierOptions): WebhookV return { isValid: false, reason: 'invalid_signature_header', webhookId, timestamp } } - const signedPayload = options.buildSignedPayload({ - payload, - headers: normalizedHeaders, - webhookId, - timestamp, - }) + let signedPayload: string | Buffer + try { + signedPayload = options.buildSignedPayload({ + payload, + headers: normalizedHeaders, + webhookId, + timestamp, + }) + } catch { + return { isValid: false, reason: 'invalid_signed_payload', webhookId, timestamp } + } const signedPayloadBuffer = toBuffer(signedPayload) const expectedSignatures = keys.map((key) => @@ -414,10 +421,12 @@ function resolveKeyBytes(key: WebhookKey, format: 'raw' | 'base64' | 'hex'): Buf function resolveStandardKeys(secret: WebhookKey | WebhookKey[], format?: 'raw'): StandardKey[] { const secrets = Array.isArray(secret) ? secret : [secret] + const invalidSecretMessage = + 'Invalid Standard Webhooks secret. Expected whsec_ base64 encoded key.' return secrets.map((entry) => { if (Buffer.isBuffer(entry)) { - return { type: 'hmac', key: entry } + return { type: 'hmac', key: assertNonEmptyKey(entry, invalidSecretMessage) } } const value = entry instanceof Secret ? entry.release() : entry @@ -439,16 +448,30 @@ function resolveStandardKeys(secret: WebhookKey | WebhookKey[], format?: 'raw'): } if (format === 'raw') { - return { type: 'hmac', key: Buffer.from(secretValue) } + return { + type: 'hmac', + key: assertNonEmptyKey(Buffer.from(secretValue), invalidSecretMessage), + } } const stripped = secretValue.startsWith(STANDARD_SECRET_PREFIX) ? secretValue.slice(STANDARD_SECRET_PREFIX.length) : secretValue - return { type: 'hmac', key: Buffer.from(stripped, 'base64') } + return { + type: 'hmac', + key: assertNonEmptyKey(Buffer.from(stripped, 'base64'), invalidSecretMessage), + } }) } +function assertNonEmptyKey(key: Buffer, message: string): Buffer { + if (key.length === 0) { + throw new Error(message) + } + + return key +} + function buildStandardSignedPayload( payload: WebhookPayload, webhookId: string,