fix(webhooks): fail closed when HMAC signing secret is not configured

Inbound webhook signature verification failed open for HMAC providers
(GitHub, Intercom, Jira, JSM, Confluence, Cal.com, Notion, Greenhouse,
Typeform, Fireflies, Circleback): when no signing secret was stored,
verifyAuth returned null and the workflow executed on a fully
attacker-controlled body. Reject these deliveries with 401 instead,
matching the fail-closed Stripe/WhatsApp/Vercel providers.

Run provider reachability/verification handshakes (Notion
verification_token, Grain/Intercom ping) ahead of auth so the
pre-secret setup handshake still completes — those return a canned 200
without executing the workflow, and real event payloads fall through to
fail-closed verification.

Update the trigger secret-field copy to state the secret is required
for deliveries to be accepted (was misleadingly marked optional).
This commit is contained in:
waleed
2026-06-10 08:44:31 -07:00
parent 61d8f49f46
commit 5b6cae9120
12 changed files with 45 additions and 21 deletions
@@ -111,6 +111,11 @@ async function handleWebhookPost(
const responses: NextResponse[] = []
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
const authError = await verifyProviderAuth(
foundWebhook,
foundWorkflow,
@@ -126,11 +131,6 @@ async function handleWebhookPost(
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessResult.error) {
if (webhooksForPath.length > 1) {
+6 -1
View File
@@ -48,7 +48,12 @@ export const githubHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
return null
logger.warn(
`[${requestId}] GitHub webhook missing webhookSecret in providerConfig — rejecting request`
)
return new NextResponse('Unauthorized - GitHub signing secret not configured', {
status: 401,
})
}
const signature =
+6 -1
View File
@@ -49,7 +49,12 @@ export const intercomHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secret = providerConfig.webhookSecret as string | undefined
if (!secret) {
return null
logger.warn(
`[${requestId}] Intercom webhook missing webhookSecret in providerConfig — rejecting request`
)
return new NextResponse('Unauthorized - Intercom signing secret not configured', {
status: 401,
})
}
const signature = request.headers.get('X-Hub-Signature')
+10 -1
View File
@@ -16,6 +16,10 @@ interface HmacVerifierOptions {
/**
* Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers.
* Covers the common pattern: get secret → check header → validate signature → return 401 or null.
*
* Fails closed: when no signing secret is configured the request is rejected (401), matching
* Stripe/WhatsApp/Vercel. A signed-provider webhook with no secret would otherwise accept any
* unauthenticated body that knows the URL, downgrading the provider's mandatory signature check.
*/
export function createHmacVerifier({
configKey,
@@ -31,7 +35,12 @@ export function createHmacVerifier({
}: AuthContext): Promise<NextResponse | null> => {
const secret = providerConfig[configKey] as string | undefined
if (!secret) {
return null
logger.warn(
`[${requestId}] ${providerLabel} webhook missing signing secret in providerConfig — rejecting request`
)
return new NextResponse(`Unauthorized - ${providerLabel} signing secret not configured`, {
status: 401,
})
}
const signature = request.headers.get(headerName)
+3 -2
View File
@@ -38,8 +38,9 @@ export const circlebackWebhookTrigger: TriggerConfig = {
id: 'webhookSecret',
title: 'Signing Secret',
type: 'short-input',
placeholder: 'Paste signing secret from Circleback (optional)',
description: 'Validates that webhook deliveries originate from Circleback using HMAC-SHA256.',
placeholder: 'Paste signing secret from Circleback',
description:
'Validates that webhook deliveries originate from Circleback using HMAC-SHA256. Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
+1 -1
View File
@@ -54,7 +54,7 @@ export function buildConfluenceExtraFields(triggerId: string): SubBlockConfig[]
type: 'short-input',
placeholder: 'Enter a strong secret',
description:
'Optional secret to validate webhook deliveries from Confluence using HMAC signature',
'Secret to validate webhook deliveries from Confluence using HMAC signature. Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
@@ -25,7 +25,8 @@ export const firefliesTranscriptionCompleteTrigger: TriggerConfig = {
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter your 16-32 character secret',
description: 'Secret key for HMAC signature verification (set in Fireflies dashboard)',
description:
'Secret key for HMAC signature verification (set in Fireflies dashboard). Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
+2 -1
View File
@@ -46,7 +46,8 @@ export const githubWebhookTrigger: TriggerConfig = {
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Generate or enter a strong secret',
description: 'Validates that webhook deliveries originate from GitHub.',
description:
'Validates that webhook deliveries originate from GitHub. Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
+4 -4
View File
@@ -62,17 +62,17 @@ export function isGreenhouseEventMatch(triggerId: string, action: string): boole
/**
* Builds extra fields for Greenhouse triggers.
* Includes an optional secret key for HMAC signature verification.
* Includes the secret key used for HMAC signature verification.
*/
export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'secretKey',
title: 'Secret Key (Optional)',
title: 'Secret Key',
type: 'short-input',
placeholder: 'Enter the same secret key configured in Greenhouse',
description:
'When set, requests must include a valid Signature header (HMAC-SHA256). If left empty, the endpoint does not verify signatures—only use on a private URL you fully control.',
'Used to verify the HMAC-SHA256 Signature header. Required: deliveries are rejected until a secret key is configured here and in Greenhouse.',
password: true,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
@@ -101,7 +101,7 @@ export function greenhouseSetupInstructions(eventType: string): string {
'In Greenhouse, go to <strong>Configure &gt; Dev Center &gt; Webhooks</strong>.',
'Click <strong>Create New Webhook</strong>.',
'Paste the Webhook URL into the <strong>Endpoint URL</strong> field.',
'Enter a <strong>Secret Key</strong> for HMAC signature verification (recommended). Leave empty only if you accept unauthenticated POSTs to this URL.',
'Enter a <strong>Secret Key</strong> for HMAC signature verification. This is required — deliveries without a valid signature are rejected.',
`Under <strong>When</strong>, select the appropriate <strong>${eventType}</strong>.`,
'Click <strong>Create Webhook</strong> to save.',
'Click "Save" above to activate your trigger.',
+2 -1
View File
@@ -34,7 +34,8 @@ export const jiraWebhookTrigger: TriggerConfig = {
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature',
description:
'Secret to validate webhook deliveries from Jira using HMAC signature. Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
+2 -1
View File
@@ -49,7 +49,8 @@ function jsmWebhookSecretField(triggerId: string): SubBlockConfig {
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Optional secret to validate webhook deliveries from Jira using HMAC signature',
description:
'Secret to validate webhook deliveries from Jira using HMAC signature. Required: deliveries are rejected until this is set.',
password: true,
required: false,
mode: 'trigger',
+2 -2
View File
@@ -45,9 +45,9 @@ export const typeformWebhookTrigger: TriggerConfig = {
id: 'secret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a secret for webhook signature verification (optional)',
placeholder: 'Enter a secret for webhook signature verification',
description:
'A secret string used to verify webhook authenticity. Highly recommended for security. Generate a secure random string (min 20 characters recommended).',
'A secret string used to verify webhook authenticity. Required: deliveries are rejected until this is set. Generate a secure random string (min 20 characters recommended).',
password: true,
required: false,
mode: 'trigger',