Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/sim/app/api/webhooks/trigger/[path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,49 @@ describe('Webhook Trigger API Route', () => {
expect(response.status).toBe(402)
})

it('returns the pre-deployment verification 200 immediately even when the path is shared', async () => {
testData.webhooks.push(
{
id: 'not-yet-deployed-webhook',
provider: 'microsoft-teams',
path: 'test-path',
isActive: true,
providerConfig: {},
workflowId: 'test-workflow-id',
},
{
id: 'generic-webhook-b',
provider: 'generic',
path: 'test-path',
isActive: true,
providerConfig: { requireAuth: false },
workflowId: 'test-workflow-id',
}
)
testData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
workspaceId: 'test-workspace-id',
})

const { NextResponse } = await import('next/server')
dispatchResolvedWebhookTargetMock.mockImplementationOnce(async () => ({
outcome: 'verified',
reason: 'block-missing',
response: NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }),
}))

const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
const params = Promise.resolve({ path: 'test-path' })

const response = await POST(req as any, { params })

expect(response.status).toBe(200)
const data = await response.json()
expect(data.message).toBe('Webhook endpoint verified')
expect(dispatchResolvedWebhookTargetMock).toHaveBeenCalledOnce()
})

it('should authenticate with Bearer token when no custom header is configured', async () => {
testData.webhooks.push({
id: 'generic-webhook-id',
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/webhooks/trigger/[path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ async function handleWebhookPost(
continue
}

if (dispatchResult.outcome === 'verified') {
return dispatchResult.response
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified return skips queued sibling

Medium Severity

On a shared path, when an earlier webhook already produced a queued success response, a later webhook with outcome verified still short-circuits the loop and returns the URL-verification body. The caller may get a verification 200 even though another webhook on the path already accepted the delivery.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f95c224. Configure here.

Comment on lines +189 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared Path Events Are Skipped

When a block-missing webhook on a shared path belongs to a provider with pending verification enabled, this branch returns 200 before later webhooks on the same path are checked. Since the new verified outcome is based on provider registration rather than a matched verification probe, a real event during that block-missing state can be acknowledged and the remaining shared-path deliveries are never dispatched.


if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
if (webhooksForPath.length > 1) {
logger.warn(
Expand Down
47 changes: 46 additions & 1 deletion apps/sim/lib/webhooks/processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
envFlagsMock,
executionPreprocessingMock,
executionPreprocessingMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
} from '@sim/testing'
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
Expand All @@ -19,13 +21,15 @@ const {
mockProviderHandler,
mockShouldExecuteInline,
mockWebhookLookupResult,
mockRequiresPendingWebhookVerification,
} = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockProviderHandler: { current: {} as Record<string, unknown> },
mockShouldExecuteInline: vi.fn(),
mockWebhookLookupResult: { rows: [] as Array<{ webhook: any; workflow: any }> },
mockRequiresPendingWebhookVerification: vi.fn().mockReturnValue(false),
}))

const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
Expand Down Expand Up @@ -86,9 +90,11 @@ vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
vi.mock('@/lib/webhooks/pending-verification', () => ({
getPendingWebhookVerification: vi.fn(),
matchesPendingWebhookVerificationProbe: vi.fn().mockReturnValue(false),
requiresPendingWebhookVerification: vi.fn().mockReturnValue(false),
requiresPendingWebhookVerification: mockRequiresPendingWebhookVerification,
}))

vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)

vi.mock('@/lib/webhooks/utils', () => ({
convertSquareBracketsToTwiML: vi.fn((value: string) => value),
}))
Expand Down Expand Up @@ -330,3 +336,42 @@ describe('webhook processor execution identity', () => {
)
})
})

describe('webhook processor dispatch when the trigger block is missing', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(false)
mockRequiresPendingWebhookVerification.mockReturnValue(false)
mockProviderHandler.current = {}
})

it('reports outcome "verified" so callers never treat the URL-verification response as droppable', async () => {
mockRequiresPendingWebhookVerification.mockReturnValue(true)

const result = await dispatchResolvedWebhookTarget(
makeWebhookRecord({ blockId: 'block-not-yet-deployed', provider: 'generic' }),
makeWorkflowRecord({}),
{},
createMockRequest('POST', {}) as NextRequest,
{ requestId: 'request-verify' }
)

expect(result.outcome).toBe('verified')
expect(result.reason).toBe('block-missing')
expect(result.response.status).toBe(200)
})

it('reports outcome "ignored" with a 404 when no verification is required', async () => {
const result = await dispatchResolvedWebhookTarget(
makeWebhookRecord({ blockId: 'block-not-yet-deployed', provider: 'generic' }),
makeWorkflowRecord({}),
{},
createMockRequest('POST', {}) as NextRequest,
{ requestId: 'request-missing' }
)

expect(result.outcome).toBe('ignored')
expect(result.reason).toBe('block-missing')
expect(result.response.status).toBe(404)
})
})
14 changes: 9 additions & 5 deletions apps/sim/lib/webhooks/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ export async function checkWebhookPreprocessing(
}
}

export type WebhookDispatchOutcome = 'queued' | 'ignored' | 'failed'
export type WebhookDispatchOutcome = 'queued' | 'ignored' | 'failed' | 'verified'

export interface WebhookDispatchResult {
outcome: WebhookDispatchOutcome
Expand Down Expand Up @@ -739,7 +739,10 @@ async function queueWebhookExecutionWithResult(

/**
* Runs the common post-authentication lifecycle for a resolved webhook target and returns a typed
* outcome so app-level fanout workers do not infer queue state from HTTP response bodies.
* outcome so app-level fanout workers do not infer queue state from HTTP response bodies. A
* `verified` outcome is the pre-deployment URL verification handshake some providers require
* before a workflow is even deployed; callers must return it as-is rather than treating it as a
* droppable per-webhook failure on a path shared by other webhooks.
*/
export async function dispatchResolvedWebhookTarget(
foundWebhook: typeof webhook.$inferSelect,
Expand Down Expand Up @@ -778,11 +781,12 @@ export async function dispatchResolvedWebhookTarget(
const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId)
if (!blockExists) {
const verificationResponse = handlePreDeploymentVerification(webhookRecord, options.requestId)
if (verificationResponse) {
return { outcome: 'verified', response: verificationResponse, reason: 'block-missing' }
}
return {
outcome: 'ignored',
response:
verificationResponse ??
new NextResponse('Trigger block not found in deployment', { status: 404 }),
response: new NextResponse('Trigger block not found in deployment', { status: 404 }),
reason: 'block-missing',
}
}
Expand Down
Loading