Skip to content

Commit 17128ac

Browse files
committed
fix(webhooks): check credentials after the permission-group gate
1 parent ab90ec1 commit 17128ac

2 files changed

Lines changed: 87 additions & 47 deletions

File tree

apps/sim/app/api/webhooks/route.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ describe('POST /api/webhooks credential references', () => {
605605
const response = await POST(upsertRequest({ eventType: 'record.created' }))
606606

607607
expect(response.status).toBe(403)
608+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
608609
expect(dbChainMockFns.set).not.toHaveBeenCalled()
609610
})
610611

@@ -668,4 +669,43 @@ describe('POST /api/webhooks credential references', () => {
668669
{ credentialId: 'stored-credential', workflowId: 'workflow-1' },
669670
])
670671
})
672+
673+
/**
674+
* Recreation cleans up the previous subscription with the stored credential even
675+
* when the request omits it, and a `userId` echoed back by the provider is not saved.
676+
*/
677+
it('authorizes the stored credential and drops userId when an omitting re-save recreates', async () => {
678+
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
679+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
680+
mocks.createExternalWebhookSubscription.mockResolvedValue({
681+
updatedProviderConfig: { externalId: 'subscription-2', userId: 'stored-user' },
682+
externalSubscriptionCreated: true,
683+
})
684+
queueUpdatePathRows(true, { credentialId: 'stored-credential', userId: 'stored-user' })
685+
686+
const response = await POST(upsertRequest({ eventType: 'record.created' }))
687+
688+
expect(response.status).toBe(200)
689+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(expect.anything(), {
690+
credentialId: 'stored-credential',
691+
workflowId: 'workflow-1',
692+
})
693+
const savedConfig = dbChainMockFns.set.mock.calls.at(-1)?.[0].providerConfig
694+
expect(savedConfig.userId).toBeUndefined()
695+
expect(savedConfig).toEqual({ eventType: 'record.created', externalId: 'subscription-2' })
696+
})
697+
698+
/** The permission-group refusal keeps answering first, before any credential lookup. */
699+
it('refuses a withheld creation before authorizing its credential', async () => {
700+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
701+
...DEFAULT_PERMISSION_GROUP_CONFIG,
702+
disableWebhookTriggers: true,
703+
})
704+
queueCreatePathRows()
705+
706+
const response = await POST(upsertRequest({ credentialId: 'victim-credential' }))
707+
708+
expect(response.status).toBe(403)
709+
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
710+
})
671711
})

apps/sim/app/api/webhooks/route.ts

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -390,14 +390,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
390390
workflowRecord.workspaceId || undefined
391391
)
392392

393-
/** The row stores the unresolved text, so only a literal credential id can be authorized. */
394-
if (resolvedProviderConfig.credentialId !== originalProviderConfig.credentialId) {
395-
return NextResponse.json(
396-
{ error: 'providerConfig.credentialId must be a literal credential id' },
397-
{ status: 400 }
398-
)
399-
}
400-
401393
let externalSubscriptionCreated = false
402394
const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({
403395
id: targetWebhookId || generateShortId(),
@@ -418,6 +410,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
418410
existingWebhook = existingRows[0] || null
419411
}
420412

413+
/**
414+
* permission-group-enforced: triggers.webhook — a raw upsert handler with no
415+
* application operation to declare the capability on, so it is asserted
416+
* here.
417+
*
418+
* Creation and reactivation, because both end with a workflow newly
419+
* reachable from an inbound webhook: this upsert always writes
420+
* `isActive: true`, so re-saving a dormant webhook turns it back on exactly
421+
* as `PATCH /api/webhooks/[id]` would, and that route already gates the same
422+
* transition.
423+
*
424+
* Re-saving an already-active webhook is not gated. It changes the config of
425+
* an endpoint that is already reachable and adds no exposure, and refusing
426+
* it would strand a member unable to repair a live integration — the same
427+
* reason inbound delivery is never gated. Inbound delivery runs with no
428+
* session to resolve a group against, and refusing there would break live
429+
* integrations at the provider rather than in Sim. Removing existing
430+
* exposure stays a deliberate act of deleting or deactivating the webhook.
431+
*/
432+
if (!existingWebhook || existingWebhook.isActive === false) {
433+
const withheld = workflowRecord.workspaceId
434+
? await isWorkspaceCapabilityWithheld(
435+
userId,
436+
workflowRecord.workspaceId,
437+
'triggers.webhook'
438+
)
439+
: false
440+
if (withheld) {
441+
logger.warn(
442+
`[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`,
443+
{
444+
userId,
445+
workflowId,
446+
}
447+
)
448+
return NextResponse.json({ error: capabilityRefusal('triggers.webhook') }, { status: 403 })
449+
}
450+
}
451+
421452
const shouldRecreateSubscription =
422453
existingWebhook &&
423454
shouldRecreateExternalWebhookSubscription({
@@ -428,6 +459,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
428459
nextConfig: resolvedProviderConfig,
429460
})
430461

462+
/** The row stores the unresolved text, so only a literal credential id can be authorized. */
463+
if (resolvedProviderConfig.credentialId !== originalProviderConfig.credentialId) {
464+
return NextResponse.json(
465+
{ error: 'providerConfig.credentialId must be a literal credential id' },
466+
{ status: 400 }
467+
)
468+
}
469+
431470
/**
432471
* Subscription handlers, pollers, and subscription cleanup look `credentialId`
433472
* up by id alone and mint tokens as its owner, so every credential this save
@@ -467,45 +506,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
467506
}
468507
}
469508

470-
/**
471-
* permission-group-enforced: triggers.webhook — a raw upsert handler with no
472-
* application operation to declare the capability on, so it is asserted
473-
* here.
474-
*
475-
* Creation and reactivation, because both end with a workflow newly
476-
* reachable from an inbound webhook: this upsert always writes
477-
* `isActive: true`, so re-saving a dormant webhook turns it back on exactly
478-
* as `PATCH /api/webhooks/[id]` would, and that route already gates the same
479-
* transition.
480-
*
481-
* Re-saving an already-active webhook is not gated. It changes the config of
482-
* an endpoint that is already reachable and adds no exposure, and refusing
483-
* it would strand a member unable to repair a live integration — the same
484-
* reason inbound delivery is never gated. Inbound delivery runs with no
485-
* session to resolve a group against, and refusing there would break live
486-
* integrations at the provider rather than in Sim. Removing existing
487-
* exposure stays a deliberate act of deleting or deactivating the webhook.
488-
*/
489-
if (!existingWebhook || existingWebhook.isActive === false) {
490-
const withheld = workflowRecord.workspaceId
491-
? await isWorkspaceCapabilityWithheld(
492-
userId,
493-
workflowRecord.workspaceId,
494-
'triggers.webhook'
495-
)
496-
: false
497-
if (withheld) {
498-
logger.warn(
499-
`[${requestId}] Webhook ${existingWebhook ? 'reactivation' : 'creation'} blocked by permission group`,
500-
{
501-
userId,
502-
workflowId,
503-
}
504-
)
505-
return NextResponse.json({ error: capabilityRefusal('triggers.webhook') }, { status: 403 })
506-
}
507-
}
508-
509509
if (!existingWebhook || shouldRecreateSubscription) {
510510
try {
511511
const result = await createExternalWebhookSubscription(

0 commit comments

Comments
 (0)