From 6e7cc491a53e2c53fa754e8771da436d411200ee Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 14 May 2026 19:10:37 -0700 Subject: [PATCH 1/5] fix(security): add webhook signature verification for Webflow, HubSpot, and Airtable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three handlers accepted any POST to the webhook path without verifying the provider's signature, enabling spoofed events to trigger arbitrary workflow executions. Webflow: - Capture `secretToken` from the subscription creation response and persist it as `webhookSecret` in providerConfigUpdates (previously discarded) - Add `verifyAuth` using HMAC-SHA256(rawBody, webhookSecret), comparing against `X-Webflow-Signature` HubSpot: - Add `verifyAuth` using SHA256(clientSecret + rawBody) → hex, comparing against `X-HubSpot-Signature` (v1 scheme; `clientSecret` was already collected in providerConfig but never used for verification) Airtable: - Capture `macSecretBase64` from the subscription creation response and persist it as `webhookSecret` (previously discarded) - Add `verifyAuth` using HMAC-SHA256(rawBody, base64decode(webhookSecret)) → base64, comparing against `X-Airtable-Content-Mac: hmac-sha256=` All implementations use timing-safe comparison via `safeCompare` and return null on success / 401 on failure, matching the existing provider pattern. --- apps/sim/lib/webhooks/providers/airtable.ts | 38 ++++++++++++++++++++- apps/sim/lib/webhooks/providers/hubspot.ts | 34 ++++++++++++++++++ apps/sim/lib/webhooks/providers/webflow.ts | 34 +++++++++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index fd0463b4b95..67c18ed79fd 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -1,7 +1,10 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' import { validateAirtableId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -10,6 +13,7 @@ import { getProviderConfig, } from '@/lib/webhooks/provider-subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, FormatInputContext, SubscriptionContext, @@ -437,7 +441,34 @@ async function fetchAndProcessAirtablePayloads( } } +function validateAirtableSignature(webhookSecret: string, mac: string, rawBody: string): boolean { + const prefix = 'hmac-sha256=' + if (!mac.startsWith(prefix)) return false + const provided = mac.slice(prefix.length) + const secretBuffer = Buffer.from(webhookSecret, 'base64') + const computed = hmacSha256Base64(rawBody, secretBuffer) + return safeCompare(provided, computed) +} + export const airtableHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const webhookSecret = providerConfig.webhookSecret as string | undefined + if (!webhookSecret) return null + + const mac = request.headers.get('x-airtable-content-mac') + if (!mac) { + logger.warn(`[${requestId}] Airtable webhook missing X-Airtable-Content-Mac header`) + return new NextResponse('Unauthorized - Missing Airtable signature', { status: 401 }) + } + + if (!validateAirtableSignature(webhookSecret, mac, rawBody)) { + logger.warn(`[${requestId}] Airtable signature verification failed`) + return new NextResponse('Unauthorized - Invalid Airtable signature', { status: 401 }) + } + + return null + }, + async createSubscription({ webhook: webhookRecord, workflow, @@ -554,7 +585,12 @@ export const airtableHandler: WebhookProviderHandler = { airtableWebhookId: responseBody.id, } ) - return { providerConfigUpdates: { externalId: responseBody.id } } + return { + providerConfigUpdates: { + externalId: responseBody.id, + ...(responseBody.macSecretBase64 ? { webhookSecret: responseBody.macSecretBase64 } : {}), + }, + } } catch (error: unknown) { const err = error as Error logger.error( diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index 2591ee40175..49341ab0ed6 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -1,5 +1,9 @@ +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { NextResponse } from 'next/server' import type { + AuthContext, EventMatchContext, FormatInputContext, FormatInputResult, @@ -8,7 +12,37 @@ import type { const logger = createLogger('WebhookProvider:HubSpot') +function validateHubSpotSignature( + clientSecret: string, + signature: string, + rawBody: string +): boolean { + // HubSpot v1: SHA256(clientSecret + rawBody) → hex + const hash = createHash('sha256') + .update(clientSecret + rawBody, 'utf8') + .digest('hex') + return safeCompare(hash, signature) +} + export const hubspotHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const clientSecret = providerConfig.clientSecret as string | undefined + if (!clientSecret) return null + + const signature = request.headers.get('x-hubspot-signature') + if (!signature) { + logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) + return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) + } + + if (!validateHubSpotSignature(clientSecret, signature, rawBody)) { + logger.warn(`[${requestId}] HubSpot signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + + return null + }, + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { const triggerId = providerConfig.triggerId as string | undefined diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 7494ae39568..29a577f50a8 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,8 +1,12 @@ import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { NextResponse } from 'next/server' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, EventFilterContext, FormatInputContext, @@ -15,7 +19,30 @@ import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/ const logger = createLogger('WebhookProvider:Webflow') +function validateWebflowSignature(secret: string, signature: string, rawBody: string): boolean { + const computed = hmacSha256Hex(rawBody, secret) + return safeCompare(computed, signature) +} + export const webflowHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) return null + + const signature = request.headers.get('x-webflow-signature') + if (!signature) { + logger.warn(`[${requestId}] Webflow webhook missing X-Webflow-Signature header`) + return new NextResponse('Unauthorized - Missing Webflow signature', { status: 401 }) + } + + if (!validateWebflowSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] Webflow signature verification failed`) + return new NextResponse('Unauthorized - Invalid Webflow signature', { status: 401 }) + } + + return null + }, + async createSubscription({ webhook: webhookRecord, workflow, @@ -132,7 +159,12 @@ export const webflowHandler: WebhookProviderHandler = { } ) - return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } + return { + providerConfigUpdates: { + externalId: responseBody.id || responseBody._id, + ...(responseBody.secretToken ? { webhookSecret: responseBody.secretToken } : {}), + }, + } } catch (error: unknown) { const err = error as Error logger.error( From 3a337545616337f360aa1fc861771523e8ba8cbe Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 14 May 2026 19:19:35 -0700 Subject: [PATCH 2/5] fix(security): correct webhook signature algorithms for Webflow and Airtable - Webflow: sign `${timestamp}:${rawBody}` per official docs (was rawBody only), capture `secretKey` not `secretToken` from create response, add 5-minute replay protection via X-Webflow-Timestamp - Airtable: use hmacSha256Hex for hmac-sha256= format (was Base64) --- apps/sim/lib/webhooks/providers/airtable.ts | 4 ++-- apps/sim/lib/webhooks/providers/webflow.ts | 26 +++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 67c18ed79fd..41666c0fa26 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' -import { hmacSha256Base64 } from '@sim/security/hmac' +import { hmacSha256Hex } from '@sim/security/hmac' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { validateAirtableId } from '@/lib/core/security/input-validation' @@ -446,7 +446,7 @@ function validateAirtableSignature(webhookSecret: string, mac: string, rawBody: if (!mac.startsWith(prefix)) return false const provided = mac.slice(prefix.length) const secretBuffer = Buffer.from(webhookSecret, 'base64') - const computed = hmacSha256Base64(rawBody, secretBuffer) + const computed = hmacSha256Hex(rawBody, secretBuffer) return safeCompare(provided, computed) } diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 29a577f50a8..9a9f2387bc6 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -19,23 +19,41 @@ import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/ const logger = createLogger('WebhookProvider:Webflow') -function validateWebflowSignature(secret: string, signature: string, rawBody: string): boolean { - const computed = hmacSha256Hex(rawBody, secret) +function validateWebflowSignature( + secret: string, + signature: string, + timestamp: string, + rawBody: string +): boolean { + const computed = hmacSha256Hex(`${timestamp}:${rawBody}`, secret) return safeCompare(computed, signature) } +const FIVE_MINUTES_MS = 5 * 60 * 1000 + export const webflowHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined if (!secret) return null + const timestamp = request.headers.get('x-webflow-timestamp') + if (!timestamp) { + logger.warn(`[${requestId}] Webflow webhook missing X-Webflow-Timestamp header`) + return new NextResponse('Unauthorized - Missing Webflow timestamp', { status: 401 }) + } + const signature = request.headers.get('x-webflow-signature') if (!signature) { logger.warn(`[${requestId}] Webflow webhook missing X-Webflow-Signature header`) return new NextResponse('Unauthorized - Missing Webflow signature', { status: 401 }) } - if (!validateWebflowSignature(secret, signature, rawBody)) { + if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) { + logger.warn(`[${requestId}] Webflow webhook timestamp too old, possible replay attack`) + return new NextResponse('Unauthorized - Webflow timestamp expired', { status: 401 }) + } + + if (!validateWebflowSignature(secret, signature, timestamp, rawBody)) { logger.warn(`[${requestId}] Webflow signature verification failed`) return new NextResponse('Unauthorized - Invalid Webflow signature', { status: 401 }) } @@ -162,7 +180,7 @@ export const webflowHandler: WebhookProviderHandler = { return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id, - ...(responseBody.secretToken ? { webhookSecret: responseBody.secretToken } : {}), + ...(responseBody.secretKey ? { webhookSecret: responseBody.secretKey } : {}), }, } } catch (error: unknown) { From 2a32cff7a4f9e2569316523fb3e43efef5e6be75 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 14 May 2026 19:23:18 -0700 Subject: [PATCH 3/5] fix(security): add HubSpot v2/v3 signature support and log when secret absent - HubSpot: dispatch on X-HubSpot-Signature-Version (v1/v2/v3); v1 keeps existing SHA-256(secret+body) logic, v2 adds httpMethod+url to hash, v3 uses HMAC-SHA256 with timestamp replay protection (5 min window) - Webflow/Airtable/HubSpot: log warn when webhookSecret absent so operators can identify webhooks that still need re-registration --- apps/sim/lib/webhooks/providers/airtable.ts | 7 +- apps/sim/lib/webhooks/providers/hubspot.ts | 95 ++++++++++++++++++--- apps/sim/lib/webhooks/providers/webflow.ts | 7 +- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 41666c0fa26..b972b155d2d 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -453,7 +453,12 @@ function validateAirtableSignature(webhookSecret: string, mac: string, rawBody: export const airtableHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const webhookSecret = providerConfig.webhookSecret as string | undefined - if (!webhookSecret) return null + if (!webhookSecret) { + logger.warn( + `[${requestId}] Airtable webhook has no webhookSecret stored — skipping signature verification` + ) + return null + } const mac = request.headers.get('x-airtable-content-mac') if (!mac) { diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index 49341ab0ed6..01e87472089 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, createHmac } from 'node:crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { NextResponse } from 'next/server' @@ -12,32 +12,101 @@ import type { const logger = createLogger('WebhookProvider:HubSpot') -function validateHubSpotSignature( +const FIVE_MINUTES_MS = 5 * 60 * 1000 + +/** v1: SHA-256(clientSecret + rawBody) → hex */ +function validateHubSpotV1(clientSecret: string, signature: string, rawBody: string): boolean { + const hash = createHash('sha256') + .update(clientSecret + rawBody, 'utf8') + .digest('hex') + return safeCompare(hash, signature) +} + +/** v2: SHA-256(clientSecret + httpMethod + fullUrl + rawBody) → hex */ +function validateHubSpotV2( clientSecret: string, signature: string, + method: string, + url: string, rawBody: string ): boolean { - // HubSpot v1: SHA256(clientSecret + rawBody) → hex const hash = createHash('sha256') - .update(clientSecret + rawBody, 'utf8') + .update(clientSecret + method + url + rawBody, 'utf8') .digest('hex') return safeCompare(hash, signature) } +/** v3: HMAC-SHA256(clientSecret, httpMethod + fullUrl + rawBody + timestamp) → base64 */ +function validateHubSpotV3( + clientSecret: string, + signature: string, + method: string, + url: string, + rawBody: string, + timestamp: string +): boolean { + const computed = createHmac('sha256', clientSecret) + .update(method + url + rawBody + timestamp, 'utf8') + .digest('base64') + return safeCompare(computed, signature) +} + export const hubspotHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const clientSecret = providerConfig.clientSecret as string | undefined - if (!clientSecret) return null - - const signature = request.headers.get('x-hubspot-signature') - if (!signature) { - logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) - return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) + if (!clientSecret) { + logger.warn( + `[${requestId}] HubSpot webhook has no clientSecret stored — skipping signature verification` + ) + return null } - if (!validateHubSpotSignature(clientSecret, signature, rawBody)) { - logger.warn(`[${requestId}] HubSpot signature verification failed`) - return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + const version = (request.headers.get('x-hubspot-signature-version') ?? 'v1').toLowerCase() + + if (version === 'v1') { + const signature = request.headers.get('x-hubspot-signature') + if (!signature) { + logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) + return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) + } + if (!validateHubSpotV1(clientSecret, signature, rawBody)) { + logger.warn(`[${requestId}] HubSpot v1 signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + } else if (version === 'v2') { + const signature = request.headers.get('x-hubspot-signature') + if (!signature) { + logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) + return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) + } + if (!validateHubSpotV2(clientSecret, signature, request.method, request.url, rawBody)) { + logger.warn(`[${requestId}] HubSpot v2 signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + } else if (version === 'v3') { + const signature = request.headers.get('x-hubspot-signature-v3') + const timestamp = request.headers.get('x-hubspot-signature-timestamp') + if (!signature || !timestamp) { + logger.warn( + `[${requestId}] HubSpot webhook missing X-HubSpot-Signature-v3 or X-HubSpot-Signature-Timestamp header` + ) + return new NextResponse('Unauthorized - Missing HubSpot v3 signature headers', { + status: 401, + }) + } + if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) { + logger.warn(`[${requestId}] HubSpot webhook timestamp too old, possible replay attack`) + return new NextResponse('Unauthorized - HubSpot timestamp expired', { status: 401 }) + } + if ( + !validateHubSpotV3(clientSecret, signature, request.method, request.url, rawBody, timestamp) + ) { + logger.warn(`[${requestId}] HubSpot v3 signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + } else { + logger.warn(`[${requestId}] Unknown HubSpot signature version: ${version}`) + return new NextResponse('Unauthorized - Unknown HubSpot signature version', { status: 401 }) } return null diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 9a9f2387bc6..f140caa82db 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -34,7 +34,12 @@ const FIVE_MINUTES_MS = 5 * 60 * 1000 export const webflowHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secret = providerConfig.webhookSecret as string | undefined - if (!secret) return null + if (!secret) { + logger.warn( + `[${requestId}] Webflow webhook has no webhookSecret stored — skipping signature verification` + ) + return null + } const timestamp = request.headers.get('x-webflow-timestamp') if (!timestamp) { From 76078986a1f2be7e30a715177a3796d0a6f7173f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 14 May 2026 19:43:10 -0700 Subject: [PATCH 4/5] fix(security): fix HubSpot v3 detection and timestamp header - Detect v3 by presence of X-HubSpot-Signature-v3 header (HubSpot does not send X-HubSpot-Signature-Version for v3 requests) - Use X-HubSpot-Request-Timestamp for replay protection (not the non-existent X-HubSpot-Signature-Timestamp header) - Simplify v1/v2 path: both use X-HubSpot-Signature, read once before the version branch --- apps/sim/lib/webhooks/providers/hubspot.ts | 68 ++++++++++++---------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index 01e87472089..4c69efbc470 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -61,49 +61,55 @@ export const hubspotHandler: WebhookProviderHandler = { return null } - const version = (request.headers.get('x-hubspot-signature-version') ?? 'v1').toLowerCase() - - if (version === 'v1') { - const signature = request.headers.get('x-hubspot-signature') - if (!signature) { - logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) - return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) - } - if (!validateHubSpotV1(clientSecret, signature, rawBody)) { - logger.warn(`[${requestId}] HubSpot v1 signature verification failed`) - return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) - } - } else if (version === 'v2') { - const signature = request.headers.get('x-hubspot-signature') - if (!signature) { - logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) - return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) - } - if (!validateHubSpotV2(clientSecret, signature, request.method, request.url, rawBody)) { - logger.warn(`[${requestId}] HubSpot v2 signature verification failed`) - return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) - } - } else if (version === 'v3') { - const signature = request.headers.get('x-hubspot-signature-v3') - const timestamp = request.headers.get('x-hubspot-signature-timestamp') - if (!signature || !timestamp) { + // v3 is identified by the presence of X-HubSpot-Signature-v3, not a version header + const v3Signature = request.headers.get('x-hubspot-signature-v3') + if (v3Signature) { + // HubSpot v3 sends the timestamp in X-HubSpot-Request-Timestamp + const timestamp = request.headers.get('x-hubspot-request-timestamp') + if (!timestamp) { logger.warn( - `[${requestId}] HubSpot webhook missing X-HubSpot-Signature-v3 or X-HubSpot-Signature-Timestamp header` + `[${requestId}] HubSpot webhook missing X-HubSpot-Request-Timestamp header for v3` ) - return new NextResponse('Unauthorized - Missing HubSpot v3 signature headers', { - status: 401, - }) + return new NextResponse('Unauthorized - Missing HubSpot v3 timestamp', { status: 401 }) } if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) { logger.warn(`[${requestId}] HubSpot webhook timestamp too old, possible replay attack`) return new NextResponse('Unauthorized - HubSpot timestamp expired', { status: 401 }) } if ( - !validateHubSpotV3(clientSecret, signature, request.method, request.url, rawBody, timestamp) + !validateHubSpotV3( + clientSecret, + v3Signature, + request.method, + request.url, + rawBody, + timestamp + ) ) { logger.warn(`[${requestId}] HubSpot v3 signature verification failed`) return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) } + return null + } + + // v1/v2 are identified by X-HubSpot-Signature-Version (defaults to v1 when absent) + const version = (request.headers.get('x-hubspot-signature-version') ?? 'v1').toLowerCase() + const signature = request.headers.get('x-hubspot-signature') + if (!signature) { + logger.warn(`[${requestId}] HubSpot webhook missing X-HubSpot-Signature header`) + return new NextResponse('Unauthorized - Missing HubSpot signature', { status: 401 }) + } + + if (version === 'v1') { + if (!validateHubSpotV1(clientSecret, signature, rawBody)) { + logger.warn(`[${requestId}] HubSpot v1 signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + } else if (version === 'v2') { + if (!validateHubSpotV2(clientSecret, signature, request.method, request.url, rawBody)) { + logger.warn(`[${requestId}] HubSpot v2 signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } } else { logger.warn(`[${requestId}] Unknown HubSpot signature version: ${version}`) return new NextResponse('Unauthorized - Unknown HubSpot signature version', { status: 401 }) From c7cd4ae4b430b007aa61c58b1868c08d87f84aa3 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 14 May 2026 19:44:59 -0700 Subject: [PATCH 5/5] fix(security): guard NaN timestamp before replay-protection window check Number(timestamp) returns NaN for non-numeric strings; Math.abs(Date.now() - NaN) is NaN which is never > FIVE_MINUTES_MS, silently bypassing replay protection. Add isNaN guard in both Webflow and HubSpot v3 timestamp checks. --- apps/sim/lib/webhooks/providers/hubspot.ts | 3 ++- apps/sim/lib/webhooks/providers/webflow.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index 4c69efbc470..90aa148d098 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -72,7 +72,8 @@ export const hubspotHandler: WebhookProviderHandler = { ) return new NextResponse('Unauthorized - Missing HubSpot v3 timestamp', { status: 401 }) } - if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) { + const ts = Number(timestamp) + if (isNaN(ts) || Math.abs(Date.now() - ts) > FIVE_MINUTES_MS) { logger.warn(`[${requestId}] HubSpot webhook timestamp too old, possible replay attack`) return new NextResponse('Unauthorized - HubSpot timestamp expired', { status: 401 }) } diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index f140caa82db..0ee44229c02 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -53,7 +53,8 @@ export const webflowHandler: WebhookProviderHandler = { return new NextResponse('Unauthorized - Missing Webflow signature', { status: 401 }) } - if (Math.abs(Date.now() - Number(timestamp)) > FIVE_MINUTES_MS) { + const ts = Number(timestamp) + if (isNaN(ts) || Math.abs(Date.now() - ts) > FIVE_MINUTES_MS) { logger.warn(`[${requestId}] Webflow webhook timestamp too old, possible replay attack`) return new NextResponse('Unauthorized - Webflow timestamp expired', { status: 401 }) }