Skip to content

Commit 0def46d

Browse files
authored
fix(webhooks): authorize credential references on webhook upsert (#7800)
* fix(webhooks): authorize credential references on webhook upsert POST /api/webhooks persisted client-supplied providerConfig verbatim, while subscription handlers and pollers resolve providerConfig.credentialId by id alone and mint tokens as the credential's owner. Authorize credentialId for the acting user within the workflow's workspace before subscribing or saving, require it to be a literal id, and never accept a client-supplied userId, which the polling token resolver falls back to. * fix(webhooks): authorize the stored credential and drop stored userId on re-save * fix(webhooks): authorize both requested and stored credentials on upsert * fix(webhooks): authorize the stored credential only when the save uses it * fix(webhooks): check credentials after the permission-group gate * improvement(webhooks): validate credential id shape once before authorization
1 parent e15cca1 commit 0def46d

2 files changed

Lines changed: 324 additions & 57 deletions

File tree

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

Lines changed: 270 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { beforeEach, describe, expect, it, vi } from 'vitest'
2020

2121
const mocks = vi.hoisted(() => ({
22+
authorizeCredentialUseForAuth: vi.fn(),
2223
configurePolling: vi.fn(),
2324
createExternalWebhookSubscription: vi.fn(),
2425
findConflictingWebhookPathOwner: vi.fn(),
@@ -31,6 +32,9 @@ vi.mock('@sim/audit', () => auditMock)
3132
vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock)
3233
vi.mock('@/lib/core/telemetry', () => telemetryMock)
3334
vi.mock('@/lib/posthog/server', () => posthogServerMock)
35+
vi.mock('@/lib/auth/credential-access', () => ({
36+
authorizeCredentialUseForAuth: mocks.authorizeCredentialUseForAuth,
37+
}))
3438
vi.mock('@/lib/webhooks/env-resolver', () => ({
3539
resolveEnvVarsInObject: mocks.resolveEnvVarsInObject,
3640
}))
@@ -353,46 +357,75 @@ describe('POST /api/webhooks polling configuration', () => {
353357
})
354358
})
355359

356-
describe('POST /api/webhooks triggers.webhook gate', () => {
357-
beforeEach(() => {
358-
vi.clearAllMocks()
359-
resetDbChainMock()
360-
authMockFns.mockGetSession.mockResolvedValue({
361-
user: { id: 'actor-1', name: 'Actor', email: 'actor@example.com' },
362-
session: { id: 'session-1' },
363-
})
364-
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
365-
allowed: true,
366-
status: 200,
367-
workflow: { id: 'workflow-1' },
368-
workspacePermission: 'write',
369-
})
370-
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
371-
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null)
372-
mocks.findConflictingWebhookPathOwner.mockResolvedValue(null)
373-
mocks.resolveEnvVarsInObject.mockImplementation(async (config) => config)
374-
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(false)
375-
mocks.getProviderHandler.mockReturnValue({})
376-
mocks.createExternalWebhookSubscription.mockResolvedValue({
377-
updatedProviderConfig: {},
378-
externalSubscriptionCreated: false,
379-
})
380-
})
360+
const CREDENTIAL_ALLOWED = { ok: true, workspaceId: 'workspace-1' }
361+
const CREDENTIAL_DENIED = { ok: false, error: 'You do not have access to this credential.' }
381362

382-
function upsertRequest() {
383-
return createMockRequest('POST', {
363+
/** Mocks an actor with write access to `workflow-1` and no provider side effects. */
364+
function setupUpsertMocks(): void {
365+
vi.clearAllMocks()
366+
resetDbChainMock()
367+
authMockFns.mockGetSession.mockResolvedValue({
368+
user: { id: 'actor-1', name: 'Actor', email: 'actor@example.com' },
369+
session: { id: 'session-1' },
370+
})
371+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
372+
allowed: true,
373+
status: 200,
374+
workflow: { id: 'workflow-1' },
375+
workspacePermission: 'write',
376+
})
377+
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
378+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue(null)
379+
mocks.findConflictingWebhookPathOwner.mockResolvedValue(null)
380+
mocks.resolveEnvVarsInObject.mockImplementation(async (config) => config)
381+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(false)
382+
mocks.getProviderHandler.mockReturnValue({})
383+
mocks.createExternalWebhookSubscription.mockResolvedValue({
384+
updatedProviderConfig: {},
385+
externalSubscriptionCreated: false,
386+
})
387+
}
388+
389+
function upsertRequest(providerConfig: Record<string, unknown> = {}) {
390+
return createMockRequest('POST', {
391+
workflowId: 'workflow-1',
392+
path: 'inbound-orders',
393+
provider: 'generic',
394+
providerConfig,
395+
})
396+
}
397+
398+
/** The reads the create path makes, in the order the handler issues them. */
399+
function queueCreatePathRows(): void {
400+
queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }])
401+
queueTableRows(webhook, [])
402+
}
403+
404+
/** The reads the update path makes: the path claim, then the existing row. */
405+
function queueUpdatePathRows(
406+
isActive: boolean,
407+
providerConfig: Record<string, unknown> = {}
408+
): void {
409+
queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }])
410+
queueTableRows(webhook, [{ id: 'webhook-1' }])
411+
queueTableRows(webhook, [
412+
{
413+
id: 'webhook-1',
384414
workflowId: 'workflow-1',
415+
blockId: 'block-1',
385416
path: 'inbound-orders',
386417
provider: 'generic',
387-
providerConfig: {},
388-
})
389-
}
418+
providerConfig,
419+
isActive,
420+
},
421+
])
422+
dbChainMockFns.returning.mockImplementationOnce(async () => [
423+
{ id: 'webhook-1', workflowId: 'workflow-1', path: 'inbound-orders', isActive: true },
424+
])
425+
}
390426

391-
/** The reads the create path makes, in the order the handler issues them. */
392-
function queueCreatePathRows(): void {
393-
queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }])
394-
queueTableRows(webhook, [])
395-
}
427+
describe('POST /api/webhooks triggers.webhook gate', () => {
428+
beforeEach(setupUpsertMocks)
396429

397430
/**
398431
* Making a workflow reachable from an inbound webhook is the only external
@@ -421,26 +454,6 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
421454
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1)
422455
})
423456

424-
/** The reads the update path makes: the path claim, then the existing row. */
425-
function queueUpdatePathRows(isActive: boolean): void {
426-
queueTableRows(workflow, [{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' }])
427-
queueTableRows(webhook, [{ id: 'webhook-1' }])
428-
queueTableRows(webhook, [
429-
{
430-
id: 'webhook-1',
431-
workflowId: 'workflow-1',
432-
blockId: 'block-1',
433-
path: 'inbound-orders',
434-
provider: 'generic',
435-
providerConfig: {},
436-
isActive,
437-
},
438-
])
439-
dbChainMockFns.returning.mockImplementationOnce(async () => [
440-
{ id: 'webhook-1', workflowId: 'workflow-1', path: 'inbound-orders', isActive: true },
441-
])
442-
}
443-
444457
/**
445458
* The upsert always writes `isActive: true`, so re-saving a dormant webhook is
446459
* the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming
@@ -488,3 +501,205 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
488501
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: true }))
489502
})
490503
})
504+
505+
describe('POST /api/webhooks credential references', () => {
506+
beforeEach(setupUpsertMocks)
507+
508+
/**
509+
* Subscription setup and polling mint tokens as the credential's owner, so a
510+
* reference the actor cannot use must be refused before either runs.
511+
*/
512+
it('refuses a credential the actor cannot use', async () => {
513+
mocks.authorizeCredentialUseForAuth.mockResolvedValue({
514+
ok: false,
515+
error: 'Credential is not accessible from this workflow workspace',
516+
})
517+
queueCreatePathRows()
518+
519+
const response = await POST(upsertRequest({ credentialId: 'victim-credential' }))
520+
521+
expect(response.status).toBe(403)
522+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(
523+
expect.objectContaining({ success: true, userId: 'actor-1' }),
524+
{ credentialId: 'victim-credential', workflowId: 'workflow-1' }
525+
)
526+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
527+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
528+
})
529+
530+
it('refuses a credential id supplied through an env-var reference', async () => {
531+
mocks.resolveEnvVarsInObject.mockImplementation(async (config) => ({
532+
...config,
533+
credentialId: 'victim-credential',
534+
}))
535+
queueCreatePathRows()
536+
537+
const response = await POST(upsertRequest({ credentialId: '{{CREDENTIAL}}' }))
538+
539+
expect(response.status).toBe(400)
540+
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
541+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
542+
})
543+
544+
it('saves a credential the actor can use in the workflow workspace', async () => {
545+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
546+
queueCreatePathRows()
547+
548+
const response = await POST(upsertRequest({ credentialId: 'own-credential' }))
549+
550+
expect(response.status).toBe(201)
551+
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1)
552+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
553+
expect.objectContaining({ providerConfig: { credentialId: 'own-credential' } })
554+
)
555+
})
556+
557+
/** The polling token resolver mints `providerConfig.userId`'s token when no credential is set. */
558+
it('drops a client-supplied userId before subscribing or saving', async () => {
559+
queueCreatePathRows()
560+
561+
const response = await POST(
562+
upsertRequest({ userId: 'victim-user', eventType: 'record.created' })
563+
)
564+
565+
expect(response.status).toBe(201)
566+
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledWith(
567+
expect.anything(),
568+
expect.objectContaining({ providerConfig: { eventType: 'record.created' } }),
569+
expect.anything(),
570+
'actor-1',
571+
expect.anything()
572+
)
573+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
574+
expect.objectContaining({ providerConfig: { eventType: 'record.created' } })
575+
)
576+
})
577+
578+
/**
579+
* A re-save that omits `credentialId` still acts with the stored credential
580+
* (polling setup and subscription cleanup read it), so that credential is
581+
* authorized and kept, while a stored `userId` is never carried forward.
582+
*/
583+
it('authorizes and keeps the stored credential on a re-save that omits it', async () => {
584+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
585+
queueUpdatePathRows(true, { credentialId: 'stored-credential', userId: 'stored-user' })
586+
587+
const response = await POST(upsertRequest({ eventType: 'record.created' }))
588+
589+
expect(response.status).toBe(200)
590+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(expect.anything(), {
591+
credentialId: 'stored-credential',
592+
workflowId: 'workflow-1',
593+
})
594+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
595+
expect.objectContaining({
596+
providerConfig: { eventType: 'record.created', credentialId: 'stored-credential' },
597+
})
598+
)
599+
})
600+
601+
it('refuses a re-save whose stored credential the actor cannot use', async () => {
602+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_DENIED)
603+
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
604+
605+
const response = await POST(upsertRequest({ eventType: 'record.created' }))
606+
607+
expect(response.status).toBe(403)
608+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
609+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
610+
})
611+
612+
/**
613+
* Clearing `credentialId` does not stop the save from acting with the stored
614+
* credential: a recreate still cleans up the previous subscription with it.
615+
*/
616+
it.each([null, ''])(
617+
'still authorizes the stored credential when a re-save sends credentialId %j',
618+
async (credentialId) => {
619+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_DENIED)
620+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
621+
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
622+
623+
const response = await POST(upsertRequest({ credentialId }))
624+
625+
expect(response.status).toBe(403)
626+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(expect.anything(), {
627+
credentialId: 'stored-credential',
628+
workflowId: 'workflow-1',
629+
})
630+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
631+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
632+
}
633+
)
634+
635+
/** Rotation without recreation never touches the old credential, so it needs no access to it. */
636+
it('rotates the credential without access to the stored one when nothing is recreated', async () => {
637+
mocks.authorizeCredentialUseForAuth.mockImplementation(async (_auth, { credentialId }) =>
638+
credentialId === 'new-credential' ? CREDENTIAL_ALLOWED : CREDENTIAL_DENIED
639+
)
640+
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
641+
642+
const response = await POST(upsertRequest({ credentialId: 'new-credential' }))
643+
644+
expect(response.status).toBe(200)
645+
expect(mocks.authorizeCredentialUseForAuth.mock.calls.map(([, params]) => params)).toEqual([
646+
{ credentialId: 'new-credential', workflowId: 'workflow-1' },
647+
])
648+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
649+
expect.objectContaining({ providerConfig: { credentialId: 'new-credential' } })
650+
)
651+
})
652+
653+
/** Recreation cleans up the previous subscription with the stored credential. */
654+
it('authorizes both credentials when a rotation recreates the subscription', async () => {
655+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
656+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
657+
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
658+
659+
const response = await POST(upsertRequest({ credentialId: 'new-credential' }))
660+
661+
expect(response.status).toBe(200)
662+
expect(mocks.authorizeCredentialUseForAuth.mock.calls.map(([, params]) => params)).toEqual([
663+
{ credentialId: 'new-credential', workflowId: 'workflow-1' },
664+
{ credentialId: 'stored-credential', workflowId: 'workflow-1' },
665+
])
666+
})
667+
668+
/**
669+
* Recreation cleans up the previous subscription with the stored credential even
670+
* when the request omits it, and a `userId` echoed back by the provider is not saved.
671+
*/
672+
it('authorizes the stored credential and drops userId when an omitting re-save recreates', async () => {
673+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
674+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
675+
mocks.createExternalWebhookSubscription.mockResolvedValue({
676+
updatedProviderConfig: { externalId: 'subscription-2', userId: 'stored-user' },
677+
externalSubscriptionCreated: true,
678+
})
679+
queueUpdatePathRows(true, { credentialId: 'stored-credential', userId: 'stored-user' })
680+
681+
const response = await POST(upsertRequest({ eventType: 'record.created' }))
682+
683+
expect(response.status).toBe(200)
684+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(expect.anything(), {
685+
credentialId: 'stored-credential',
686+
workflowId: 'workflow-1',
687+
})
688+
const savedConfig = dbChainMockFns.set.mock.calls.at(-1)?.[0].providerConfig
689+
expect(savedConfig).toEqual({ eventType: 'record.created', externalId: 'subscription-2' })
690+
})
691+
692+
/** The permission-group refusal keeps answering first, before any credential lookup. */
693+
it('refuses a withheld creation before authorizing its credential', async () => {
694+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
695+
...DEFAULT_PERMISSION_GROUP_CONFIG,
696+
disableWebhookTriggers: true,
697+
})
698+
queueCreatePathRows()
699+
700+
const response = await POST(upsertRequest({ credentialId: 'victim-credential' }))
701+
702+
expect(response.status).toBe(403)
703+
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
704+
})
705+
})

0 commit comments

Comments
 (0)