From 6bb478974ed55038597f24b3b6f03fd3db4a9a12 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 14:26:24 -0700 Subject: [PATCH 1/4] fix(security): add webhook HMAC signature verification for Airtable, HubSpot, and Webflow - Airtable: verify X-Airtable-Content-MAC via HMAC-SHA256 of raw body; strip hmac-sha256= prefix before comparing; fail-open with warning when macSecretBase64 is absent (existing webhooks predating secret storage) - HubSpot: verify X-HubSpot-Signature via SHA-256(clientSecret + body) per v1 spec - Webflow: verify X-Webflow-Signature via HMAC-SHA256(secretKey, timestamp:body); store secretKey from webhook creation response; add 5-min replay protection; fail-open when secretKey absent for pre-existing webhooks - tool-schemas-v1.ts: linter formatting cleanup (computed keys) --- .../lib/copilot/generated/tool-schemas-v1.ts | 174 +++++++++--------- apps/sim/lib/webhooks/providers/airtable.ts | 57 +++++- apps/sim/lib/webhooks/providers/hubspot.ts | 41 +++++ apps/sim/lib/webhooks/providers/webflow.ts | 51 ++++- 4 files changed, 234 insertions(+), 89 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index b9c0b4f5687..bb329500012 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - ['agent']: { + agent: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['auth']: { + auth: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['check_deployment_status']: { + check_deployment_status: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['complete_job']: { + complete_job: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['context_write']: { + context_write: { parameters: { type: 'object', properties: { @@ -78,7 +78,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['crawl_website']: { + crawl_website: { parameters: { type: 'object', properties: { @@ -113,7 +113,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_file']: { + create_file: { parameters: { type: 'object', properties: { @@ -149,7 +149,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['create_folder']: { + create_folder: { parameters: { type: 'object', properties: { @@ -170,7 +170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_job']: { + create_job: { parameters: { type: 'object', properties: { @@ -220,7 +220,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workflow']: { + create_workflow: { parameters: { type: 'object', properties: { @@ -245,7 +245,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workspace_mcp_server']: { + create_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -266,7 +266,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['debug']: { + debug: { parameters: { properties: { context: { @@ -285,7 +285,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_file']: { + delete_file: { parameters: { type: 'object', properties: { @@ -314,7 +314,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['delete_folder']: { + delete_folder: { parameters: { type: 'object', properties: { @@ -330,7 +330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workflow']: { + delete_workflow: { parameters: { type: 'object', properties: { @@ -346,7 +346,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workspace_mcp_server']: { + delete_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -359,7 +359,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy']: { + deploy: { parameters: { properties: { request: { @@ -373,7 +373,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy_api']: { + deploy_api: { parameters: { type: 'object', properties: { @@ -447,7 +447,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_chat']: { + deploy_chat: { parameters: { type: 'object', properties: { @@ -595,7 +595,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_mcp']: { + deploy_mcp: { parameters: { type: 'object', properties: { @@ -711,7 +711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - ['download_to_workspace_file']: { + download_to_workspace_file: { parameters: { type: 'object', properties: { @@ -730,7 +730,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['edit_content']: { + edit_content: { parameters: { type: 'object', properties: { @@ -762,7 +762,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['edit_workflow']: { + edit_workflow: { parameters: { type: 'object', properties: { @@ -801,13 +801,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['file']: { + file: { parameters: { type: 'object', }, resultSchema: undefined, }, - ['function_execute']: { + function_execute: { parameters: { type: 'object', properties: { @@ -868,7 +868,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_api_key']: { + generate_api_key: { parameters: { type: 'object', properties: { @@ -886,7 +886,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_image']: { + generate_image: { parameters: { type: 'object', properties: { @@ -923,7 +923,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_visualization']: { + generate_visualization: { parameters: { type: 'object', properties: { @@ -963,7 +963,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_outputs']: { + get_block_outputs: { parameters: { type: 'object', properties: { @@ -984,7 +984,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_upstream_references']: { + get_block_upstream_references: { parameters: { type: 'object', properties: { @@ -1006,7 +1006,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployed_workflow_state']: { + get_deployed_workflow_state: { parameters: { type: 'object', properties: { @@ -1019,7 +1019,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployment_version']: { + get_deployment_version: { parameters: { type: 'object', properties: { @@ -1036,7 +1036,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_execution_summary']: { + get_execution_summary: { parameters: { type: 'object', properties: { @@ -1063,7 +1063,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_job_logs']: { + get_job_logs: { parameters: { type: 'object', properties: { @@ -1088,7 +1088,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_page_contents']: { + get_page_contents: { parameters: { type: 'object', properties: { @@ -1116,14 +1116,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_platform_actions']: { + get_platform_actions: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['get_workflow_data']: { + get_workflow_data: { parameters: { type: 'object', properties: { @@ -1142,7 +1142,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_workflow_logs']: { + get_workflow_logs: { parameters: { type: 'object', properties: { @@ -1168,7 +1168,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['glob']: { + glob: { parameters: { type: 'object', properties: { @@ -1187,7 +1187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['grep']: { + grep: { parameters: { type: 'object', properties: { @@ -1234,7 +1234,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['job']: { + job: { parameters: { properties: { request: { @@ -1247,7 +1247,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['knowledge']: { + knowledge: { parameters: { properties: { request: { @@ -1260,7 +1260,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['knowledge_base']: { + knowledge_base: { parameters: { type: 'object', properties: { @@ -1452,7 +1452,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['list_folders']: { + list_folders: { parameters: { type: 'object', properties: { @@ -1464,14 +1464,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_user_workspaces']: { + list_user_workspaces: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['list_workspace_mcp_servers']: { + list_workspace_mcp_servers: { parameters: { type: 'object', properties: { @@ -1483,7 +1483,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_credential']: { + manage_credential: { parameters: { type: 'object', properties: { @@ -1512,7 +1512,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_custom_tool']: { + manage_custom_tool: { parameters: { type: 'object', properties: { @@ -1591,7 +1591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_job']: { + manage_job: { parameters: { type: 'object', properties: { @@ -1661,7 +1661,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_mcp_tool']: { + manage_mcp_tool: { parameters: { type: 'object', properties: { @@ -1712,7 +1712,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_skill']: { + manage_skill: { parameters: { type: 'object', properties: { @@ -1744,7 +1744,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['materialize_file']: { + materialize_file: { parameters: { type: 'object', properties: { @@ -1778,7 +1778,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_folder']: { + move_folder: { parameters: { type: 'object', properties: { @@ -1796,7 +1796,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_workflow']: { + move_workflow: { parameters: { type: 'object', properties: { @@ -1816,7 +1816,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_get_auth_link']: { + oauth_get_auth_link: { parameters: { type: 'object', properties: { @@ -1830,7 +1830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_request_access']: { + oauth_request_access: { parameters: { type: 'object', properties: { @@ -1844,7 +1844,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['open_resource']: { + open_resource: { parameters: { type: 'object', properties: { @@ -1872,7 +1872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['read']: { + read: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['redeploy']: { + redeploy: { parameters: { type: 'object', properties: { @@ -1967,7 +1967,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['rename_file']: { + rename_file: { parameters: { type: 'object', properties: { @@ -2002,7 +2002,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['rename_workflow']: { + rename_workflow: { parameters: { type: 'object', properties: { @@ -2019,7 +2019,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['research']: { + research: { parameters: { properties: { topic: { @@ -2032,7 +2032,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['respond']: { + respond: { parameters: { additionalProperties: true, properties: { @@ -2055,7 +2055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['restore_resource']: { + restore_resource: { parameters: { type: 'object', properties: { @@ -2073,7 +2073,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['revert_to_version']: { + revert_to_version: { parameters: { type: 'object', properties: { @@ -2090,7 +2090,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run']: { + run: { parameters: { properties: { context: { @@ -2107,7 +2107,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_block']: { + run_block: { parameters: { type: 'object', properties: { @@ -2139,7 +2139,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_from_block']: { + run_from_block: { parameters: { type: 'object', properties: { @@ -2171,7 +2171,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow']: { + run_workflow: { parameters: { type: 'object', properties: { @@ -2199,7 +2199,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow_until_block']: { + run_workflow_until_block: { parameters: { type: 'object', properties: { @@ -2231,7 +2231,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['scrape_page']: { + scrape_page: { parameters: { type: 'object', properties: { @@ -2252,7 +2252,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_documentation']: { + search_documentation: { parameters: { type: 'object', properties: { @@ -2269,7 +2269,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_library_docs']: { + search_library_docs: { parameters: { type: 'object', properties: { @@ -2290,7 +2290,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_online']: { + search_online: { parameters: { type: 'object', properties: { @@ -2331,7 +2331,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_patterns']: { + search_patterns: { parameters: { type: 'object', properties: { @@ -2353,7 +2353,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_block_enabled']: { + set_block_enabled: { parameters: { type: 'object', properties: { @@ -2375,7 +2375,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_environment_variables']: { + set_environment_variables: { parameters: { type: 'object', properties: { @@ -2409,7 +2409,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_global_workflow_variables']: { + set_global_workflow_variables: { parameters: { type: 'object', properties: { @@ -2447,7 +2447,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['superagent']: { + superagent: { parameters: { properties: { task: { @@ -2461,7 +2461,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['table']: { + table: { parameters: { properties: { request: { @@ -2474,7 +2474,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['tool_search_tool_regex']: { + tool_search_tool_regex: { parameters: { properties: { case_insensitive: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_job_history']: { + update_job_history: { parameters: { type: 'object', properties: { @@ -2513,7 +2513,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_workspace_mcp_server']: { + update_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -2538,7 +2538,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_memory']: { + user_memory: { parameters: { type: 'object', properties: { @@ -2586,7 +2586,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_table']: { + user_table: { parameters: { type: 'object', properties: { @@ -2915,13 +2915,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['workflow']: { + workflow: { parameters: { type: 'object', }, resultSchema: undefined, }, - ['workspace_file']: { + workspace_file: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index fd0463b4b95..e0d57a8c6ae 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -1,7 +1,10 @@ +import crypto from 'crypto' import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' 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, @@ -438,6 +442,52 @@ async function fetchAndProcessAirtablePayloads( } export const airtableHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const macSecretBase64 = providerConfig.macSecretBase64 as string | undefined | null + + if (!macSecretBase64) { + logger.warn( + `[${requestId}] Airtable webhook has no macSecretBase64 in providerConfig — skipping MAC verification. Re-create the webhook to enable signature verification.` + ) + return null + } + + const signature = request.headers.get('X-Airtable-Content-MAC') + if (!signature) { + logger.warn(`[${requestId}] Airtable webhook missing X-Airtable-Content-MAC header`) + return new NextResponse('Unauthorized - Missing Airtable MAC header', { status: 401 }) + } + + const EXPECTED_PREFIX = 'hmac-sha256=' + if (!signature.startsWith(EXPECTED_PREFIX)) { + logger.warn(`[${requestId}] Airtable MAC signature has invalid format`) + return new NextResponse('Unauthorized - Invalid Airtable MAC signature format', { + status: 401, + }) + } + const providedHex = signature.slice(EXPECTED_PREFIX.length) + + try { + const secretBytes = Buffer.from(macSecretBase64, 'base64') + const computedHex = crypto + .createHmac('sha256', secretBytes) + .update(rawBody, 'ascii') + .digest('hex') + + if (!safeCompare(computedHex, providedHex)) { + logger.warn(`[${requestId}] Airtable MAC signature verification failed`) + return new NextResponse('Unauthorized - Invalid Airtable MAC signature', { status: 401 }) + } + } catch (error) { + logger.error(`[${requestId}] Error verifying Airtable MAC signature`, { + error: (error as Error).message, + }) + return new NextResponse('Unauthorized - Signature verification error', { status: 401 }) + } + + return null + }, + async createSubscription({ webhook: webhookRecord, workflow, @@ -554,7 +604,12 @@ export const airtableHandler: WebhookProviderHandler = { airtableWebhookId: responseBody.id, } ) - return { providerConfigUpdates: { externalId: responseBody.id } } + return { + providerConfigUpdates: { + externalId: responseBody.id, + macSecretBase64: responseBody.macSecretBase64 ?? null, + }, + } } 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..a0a5250f626 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -1,5 +1,9 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { NextResponse } from 'next/server' import type { + AuthContext, EventMatchContext, FormatInputContext, FormatInputResult, @@ -9,6 +13,43 @@ import type { const logger = createLogger('WebhookProvider:HubSpot') export const hubspotHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const clientSecret = providerConfig.clientSecret as string | undefined + + if (!clientSecret) { + logger.warn( + `[${requestId}] HubSpot webhook missing clientSecret in providerConfig — rejecting request` + ) + return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 }) + } + + 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 }) + } + + try { + // HubSpot v1 signature: SHA-256 hash of (clientSecret + requestBody) + const computedHash = crypto + .createHash('sha256') + .update(clientSecret + rawBody, 'utf8') + .digest('hex') + + if (!safeCompare(computedHash, signature)) { + logger.warn(`[${requestId}] HubSpot signature verification failed`) + return new NextResponse('Unauthorized - Invalid HubSpot signature', { status: 401 }) + } + } catch (error) { + logger.error(`[${requestId}] Error verifying HubSpot signature`, { + error: (error as Error).message, + }) + return new NextResponse('Unauthorized - Signature verification error', { 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..c6859b55919 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,8 +1,12 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +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, @@ -16,6 +20,46 @@ import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/ const logger = createLogger('WebhookProvider:Webflow') export const webflowHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secretKey = providerConfig.secretKey as string | undefined | null + + if (!secretKey) { + // Fail-open for existing webhooks created before this change (no secretKey stored) + logger.warn( + `[${requestId}] Webflow webhook missing secretKey in providerConfig — skipping signature verification` + ) + return null + } + + const signature = request.headers.get('x-webflow-signature') + const timestamp = request.headers.get('x-webflow-timestamp') + + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Webflow webhook missing signature or timestamp headers`) + return new NextResponse('Unauthorized - Missing Webflow signature headers', { status: 401 }) + } + + // Replay protection: reject if timestamp is more than 5 minutes old + const ts = Number.parseInt(timestamp, 10) + if (Number.isNaN(ts) || Date.now() - ts > 5 * 60 * 1000) { + logger.warn(`[${requestId}] Webflow webhook timestamp expired or invalid`) + return new NextResponse('Unauthorized - Webhook timestamp expired', { status: 401 }) + } + + // HMAC-SHA256 of "${timestamp}:${rawBody}" + const computedHash = crypto + .createHmac('sha256', secretKey) + .update(`${timestamp}:${rawBody}`, 'utf8') + .digest('hex') + + if (!safeCompare(computedHash, signature)) { + 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 +176,12 @@ export const webflowHandler: WebhookProviderHandler = { } ) - return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } + return { + providerConfigUpdates: { + externalId: responseBody.id || responseBody._id, + secretKey: responseBody.secretKey, + }, + } } catch (error: unknown) { const err = error as Error logger.error( From 49b95038538a16d80e260bf7e72eb109736aa4b9 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:17:22 -0700 Subject: [PATCH 2/4] fix(security): correct webflow timestamp unit comparison and airtable hmac encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - webflow: compare Date.now()/1000 against x-webflow-timestamp (both in Unix seconds) — the original Date.now() - ts comparison always exceeded the 300 000ms threshold, rejecting every valid webhook - airtable: use utf8 encoding in HMAC update instead of ascii — ascii silently mangles bytes above 127, producing an incorrect digest for non-ASCII payloads - hubspot: add TSDoc comment clarifying v1 signature scheme is intentional for CRM subscriptions --- apps/sim/lib/webhooks/providers/airtable.ts | 2 +- apps/sim/lib/webhooks/providers/hubspot.ts | 6 +++++- apps/sim/lib/webhooks/providers/webflow.ts | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index e0d57a8c6ae..66b745a1f56 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -471,7 +471,7 @@ export const airtableHandler: WebhookProviderHandler = { const secretBytes = Buffer.from(macSecretBase64, 'base64') const computedHex = crypto .createHmac('sha256', secretBytes) - .update(rawBody, 'ascii') + .update(rawBody, 'utf8') .digest('hex') if (!safeCompare(computedHex, providedHex)) { diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts index a0a5250f626..7be5056b343 100644 --- a/apps/sim/lib/webhooks/providers/hubspot.ts +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -30,7 +30,11 @@ export const hubspotHandler: WebhookProviderHandler = { } try { - // HubSpot v1 signature: SHA-256 hash of (clientSecret + requestBody) + /** + * HubSpot v1 signature: SHA-256 of (clientSecret + requestBody), verified against X-HubSpot-Signature. + * v1 is intentionally used for CRM webhook subscriptions — v3 requires the full request URL and method, + * which are not available in verifyAuth. + */ const computedHash = crypto .createHash('sha256') .update(clientSecret + rawBody, 'utf8') diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index c6859b55919..2d25b95c70c 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -39,9 +39,10 @@ export const webflowHandler: WebhookProviderHandler = { return new NextResponse('Unauthorized - Missing Webflow signature headers', { status: 401 }) } - // Replay protection: reject if timestamp is more than 5 minutes old + // Replay protection: reject if timestamp is more than 5 minutes old. + // x-webflow-timestamp is Unix seconds; Date.now() is milliseconds — compare both in seconds. const ts = Number.parseInt(timestamp, 10) - if (Number.isNaN(ts) || Date.now() - ts > 5 * 60 * 1000) { + if (Number.isNaN(ts) || Date.now() / 1000 - ts > 5 * 60) { logger.warn(`[${requestId}] Webflow webhook timestamp expired or invalid`) return new NextResponse('Unauthorized - Webhook timestamp expired', { status: 401 }) } From 73d69fe19e7acdabfcef0375670297dc7c8f1305 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:46:02 -0700 Subject: [PATCH 3/4] fix(security): add try-catch to Webflow verifyAuth and clean up debug artifacts - webflow: wrap HMAC computation and safeCompare in try-catch to return a clean 401 instead of an unhandled 500 when secretKey is a non-string truthy value (mirrors the defensive pattern already used by Airtable and HubSpot in the same PR) - airtable: remove CRITICAL_TRACE/TRACE/DEBUG log prefixes, redundant "Error logging handled by logging session" comments, and other stale implementation notes that pre-date the refactor --- apps/sim/lib/webhooks/providers/airtable.ts | 23 +++++-------------- apps/sim/lib/webhooks/providers/webflow.ts | 25 +++++++++++++-------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts index 66b745a1f56..ff41a232acf 100644 --- a/apps/sim/lib/webhooks/providers/airtable.ts +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -56,14 +56,11 @@ async function fetchAndProcessAirtablePayloads( workflowData: Record, requestId: string // Original request ID from the ping, used for the final execution log ) { - // Logging handles all error logging let currentCursor: number | null = null let mightHaveMore = true let payloadsFetched = 0 let apiCallCount = 0 - // Use a Map to consolidate changes per record ID const consolidatedChangesMap = new Map() - // Capture raw payloads from Airtable for exposure to workflows const allPayloads = [] const localProviderConfig = { ...((webhookData.providerConfig as Record) || {}), @@ -221,7 +218,6 @@ async function fetchAndProcessAirtablePayloads( error: errorMessage, } ) - // Error logging handled by logging session mightHaveMore = false break } @@ -304,7 +300,6 @@ async function fetchAndProcessAirtablePayloads( } } } - // TODO: Handle deleted records (`destroyedRecordIds`) if needed } } } @@ -316,7 +311,6 @@ async function fetchAndProcessAirtablePayloads( if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { currentCursor = nextCursor - // Follow exactly the old implementation - use awaited update instead of parallel const updatedConfig = { ...localProviderConfig, externalWebhookCursor: currentCursor, @@ -326,7 +320,7 @@ async function fetchAndProcessAirtablePayloads( await db .update(webhook) .set({ - providerConfig: updatedConfig, // Use full object + providerConfig: updatedConfig, updatedAt: new Date(), }) .where(eq(webhook.id, webhookData.id as string)) @@ -339,7 +333,6 @@ async function fetchAndProcessAirtablePayloads( cursor: currentCursor, error: err.message, }) - // Error logging handled by logging session mightHaveMore = false throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly } @@ -358,7 +351,6 @@ async function fetchAndProcessAirtablePayloads( `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, fetchError ) - // Error logging handled by logging session mightHaveMore = false break } @@ -371,7 +363,6 @@ async function fetchAndProcessAirtablePayloads( if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { try { - // Build input exposing raw payloads and consolidated changes const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null const input: Record = { payloads: allPayloads, @@ -388,9 +379,8 @@ async function fetchAndProcessAirtablePayloads( }, } - // CRITICAL EXECUTION TRACE POINT logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + `[${requestId}] Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, { workflowId: workflowData.id, recordCount: finalConsolidatedChanges.length, @@ -399,8 +389,7 @@ async function fetchAndProcessAirtablePayloads( } ) - // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + logger.info(`[${requestId}] Airtable changes processed, returning input`, { workflowId: workflowData.id, recordCount: finalConsolidatedChanges.length, rawPayloadCount: allPayloads.length, @@ -410,7 +399,7 @@ async function fetchAndProcessAirtablePayloads( return input } catch (processingError: unknown) { const err = processingError as Error - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + logger.error(`[${requestId}] Error processing Airtable changes`, { workflowId: workflowData.id, error: err.message, stack: err.stack, @@ -420,8 +409,7 @@ async function fetchAndProcessAirtablePayloads( throw processingError } } else { - // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + logger.info(`[${requestId}] No Airtable changes to process`, { workflowId: workflowData.id, apiCallCount, webhookId: webhookData.id, @@ -437,7 +425,6 @@ async function fetchAndProcessAirtablePayloads( error: (error as Error).message, } ) - // Error logging handled by logging session } } diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 2d25b95c70c..09fb0ebba1e 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -47,15 +47,22 @@ export const webflowHandler: WebhookProviderHandler = { return new NextResponse('Unauthorized - Webhook timestamp expired', { status: 401 }) } - // HMAC-SHA256 of "${timestamp}:${rawBody}" - const computedHash = crypto - .createHmac('sha256', secretKey) - .update(`${timestamp}:${rawBody}`, 'utf8') - .digest('hex') - - if (!safeCompare(computedHash, signature)) { - logger.warn(`[${requestId}] Webflow signature verification failed`) - return new NextResponse('Unauthorized - Invalid Webflow signature', { status: 401 }) + try { + // HMAC-SHA256 of "${timestamp}:${rawBody}" + const computedHash = crypto + .createHmac('sha256', secretKey) + .update(`${timestamp}:${rawBody}`, 'utf8') + .digest('hex') + + if (!safeCompare(computedHash, signature)) { + logger.warn(`[${requestId}] Webflow signature verification failed`) + return new NextResponse('Unauthorized - Invalid Webflow signature', { status: 401 }) + } + } catch (error) { + logger.error(`[${requestId}] Error verifying Webflow signature`, { + error: (error as Error).message, + }) + return new NextResponse('Unauthorized - Signature verification error', { status: 401 }) } return null From 6ccc7b43851b120557b2aabda0afc918e9901ce4 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 10 May 2026 18:49:16 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(security):=20correct=20Webflow=20timest?= =?UTF-8?q?amp=20unit=20=E2=80=94=20x-webflow-timestamp=20is=20millisecond?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per official Webflow docs (example value 1722370035277, 13 digits), the header is Unix milliseconds. Comparing Date.now()/1000 against a ms timestamp produced a permanently-negative delta, making replay protection always pass. Reverted to Date.now() - ts > 5 * 60 * 1000, matching Webflow's own reference implementation. Also normalized caught error via toError(). --- apps/sim/lib/webhooks/providers/webflow.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts index 09fb0ebba1e..9e4ed72ac38 100644 --- a/apps/sim/lib/webhooks/providers/webflow.ts +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -40,9 +41,9 @@ export const webflowHandler: WebhookProviderHandler = { } // Replay protection: reject if timestamp is more than 5 minutes old. - // x-webflow-timestamp is Unix seconds; Date.now() is milliseconds — compare both in seconds. + // x-webflow-timestamp is Unix milliseconds (e.g. 1722370035277) — compare directly with Date.now(). const ts = Number.parseInt(timestamp, 10) - if (Number.isNaN(ts) || Date.now() / 1000 - ts > 5 * 60) { + if (Number.isNaN(ts) || Date.now() - ts > 5 * 60 * 1000) { logger.warn(`[${requestId}] Webflow webhook timestamp expired or invalid`) return new NextResponse('Unauthorized - Webhook timestamp expired', { status: 401 }) } @@ -60,7 +61,7 @@ export const webflowHandler: WebhookProviderHandler = { } } catch (error) { logger.error(`[${requestId}] Error verifying Webflow signature`, { - error: (error as Error).message, + error: toError(error).message, }) return new NextResponse('Unauthorized - Signature verification error', { status: 401 }) }