Skip to content

Commit 3ac376f

Browse files
committed
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.
1 parent 2799994 commit 3ac376f

2 files changed

Lines changed: 204 additions & 57 deletions

File tree

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

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

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-
}
424+
describe('POST /api/webhooks triggers.webhook gate', () => {
425+
beforeEach(setupUpsertMocks)
396426

397427
/**
398428
* Making a workflow reachable from an inbound webhook is the only external
@@ -421,26 +451,6 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
421451
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1)
422452
})
423453

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-
444454
/**
445455
* The upsert always writes `isActive: true`, so re-saving a dormant webhook is
446456
* the same transition `PATCH /api/webhooks/[id]` gates — a workflow becoming
@@ -488,3 +498,101 @@ describe('POST /api/webhooks triggers.webhook gate', () => {
488498
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ isActive: true }))
489499
})
490500
})
501+
502+
describe('POST /api/webhooks credential references', () => {
503+
beforeEach(setupUpsertMocks)
504+
505+
/**
506+
* Subscription setup and polling mint tokens as the credential's owner, so a
507+
* reference the actor cannot use must be refused before either runs.
508+
*/
509+
it('refuses a credential the actor cannot use', async () => {
510+
mocks.authorizeCredentialUseForAuth.mockResolvedValue({
511+
ok: false,
512+
error: 'Credential is not accessible from this workflow workspace',
513+
})
514+
queueCreatePathRows()
515+
516+
const response = await POST(upsertRequest({ credentialId: 'victim-credential' }))
517+
518+
expect(response.status).toBe(403)
519+
expect(mocks.authorizeCredentialUseForAuth).toHaveBeenCalledWith(
520+
expect.objectContaining({ success: true, userId: 'actor-1' }),
521+
{ credentialId: 'victim-credential', workflowId: 'workflow-1' }
522+
)
523+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
524+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
525+
})
526+
527+
it('refuses a credential id supplied through an env-var reference', async () => {
528+
mocks.resolveEnvVarsInObject.mockImplementation(async (config) => ({
529+
...config,
530+
credentialId: 'victim-credential',
531+
}))
532+
queueCreatePathRows()
533+
534+
const response = await POST(upsertRequest({ credentialId: '{{CREDENTIAL}}' }))
535+
536+
expect(response.status).toBe(400)
537+
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
538+
expect(mocks.createExternalWebhookSubscription).not.toHaveBeenCalled()
539+
})
540+
541+
it('saves a credential the actor can use in the workflow workspace', async () => {
542+
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
543+
queueCreatePathRows()
544+
545+
const response = await POST(upsertRequest({ credentialId: 'own-credential' }))
546+
547+
expect(response.status).toBe(201)
548+
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledTimes(1)
549+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
550+
expect.objectContaining({ providerConfig: { credentialId: 'own-credential' } })
551+
)
552+
})
553+
554+
/** The polling token resolver mints `providerConfig.userId`'s token when no credential is set. */
555+
it('drops a client-supplied userId before subscribing or saving', async () => {
556+
queueCreatePathRows()
557+
558+
const response = await POST(
559+
upsertRequest({ userId: 'victim-user', eventType: 'record.created' })
560+
)
561+
562+
expect(response.status).toBe(201)
563+
expect(mocks.createExternalWebhookSubscription).toHaveBeenCalledWith(
564+
expect.anything(),
565+
expect.objectContaining({ providerConfig: { eventType: 'record.created' } }),
566+
expect.anything(),
567+
'actor-1',
568+
expect.anything()
569+
)
570+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
571+
expect.objectContaining({ providerConfig: { eventType: 'record.created' } })
572+
)
573+
})
574+
575+
/**
576+
* A re-save that omits the identity fields keeps the ones the server stored,
577+
* so an existing integration is not broken by the client never sending them.
578+
*/
579+
it('keeps the stored credential and server-set userId on a re-save that omits them', async () => {
580+
queueUpdatePathRows(true, { credentialId: 'stored-credential', userId: 'credential-owner' })
581+
582+
const response = await POST(
583+
upsertRequest({ userId: 'victim-user', eventType: 'record.created' })
584+
)
585+
586+
expect(response.status).toBe(200)
587+
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
588+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
589+
expect.objectContaining({
590+
providerConfig: {
591+
eventType: 'record.created',
592+
credentialId: 'stored-credential',
593+
userId: 'credential-owner',
594+
},
595+
})
596+
)
597+
})
598+
})

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

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import {
99
} from '@sim/platform-authz/workflow'
1010
import { getErrorMessage } from '@sim/utils/errors'
1111
import { generateId, generateShortId } from '@sim/utils/id'
12+
import { omit } from '@sim/utils/object'
1213
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
1314
import { type NextRequest, NextResponse } from 'next/server'
1415
import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks'
1516
import { parseRequest } from '@/lib/api/server'
1617
import { getSession } from '@/lib/auth'
18+
import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access'
19+
import { AuthType } from '@/lib/auth/hybrid'
1720
import { PlatformEvents } from '@/lib/core/telemetry'
1821
import { generateRequestId } from '@/lib/core/utils/request'
1922
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -374,13 +377,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
374377

375378
let savedWebhook: any = null
376379
let existingWebhook: any = null
377-
const originalProviderConfig = providerConfig || {}
380+
/**
381+
* `userId` is server-owned: the polling token resolver falls back to that
382+
* user's own OAuth account when no credential is set.
383+
*/
384+
const originalProviderConfig: Record<string, unknown> = omit(providerConfig || {}, ['userId'])
378385
let resolvedProviderConfig = await resolveEnvVarsInObject(
379386
originalProviderConfig,
380387
userId,
381388
workflowRecord.workspaceId || undefined
382389
)
383390

391+
/**
392+
* Subscription handlers and pollers look `credentialId` up by id alone and
393+
* mint tokens as its owner, so the actor must be able to use it in the
394+
* workflow's workspace before anything is subscribed or saved.
395+
*/
396+
const requestedCredentialId = originalProviderConfig.credentialId
397+
if (requestedCredentialId != null && requestedCredentialId !== '') {
398+
/** The row stores the unresolved text, so only a literal id is what gets authorized. */
399+
if (
400+
typeof requestedCredentialId !== 'string' ||
401+
resolvedProviderConfig.credentialId !== requestedCredentialId
402+
) {
403+
return NextResponse.json(
404+
{ error: 'providerConfig.credentialId must be a literal credential id' },
405+
{ status: 400 }
406+
)
407+
}
408+
const credentialAccess = await authorizeCredentialUseForAuth(
409+
{ success: true, userId, authType: AuthType.SESSION },
410+
{ credentialId: requestedCredentialId, workflowId }
411+
)
412+
if (!credentialAccess.ok) {
413+
logger.warn(`[${requestId}] Webhook credential reference denied`, {
414+
userId,
415+
workflowId,
416+
credentialId: requestedCredentialId,
417+
reason: credentialAccess.error,
418+
})
419+
return NextResponse.json({ error: credentialAccess.error }, { status: 403 })
420+
}
421+
}
422+
384423
let externalSubscriptionCreated = false
385424
const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({
386425
id: targetWebhookId || generateShortId(),
@@ -389,7 +428,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
389428
providerConfig: providerConfigOverride,
390429
})
391430

392-
const userProvided = originalProviderConfig as Record<string, unknown>
431+
const userProvided = originalProviderConfig
393432
const configToSave: Record<string, unknown> = { ...userProvided }
394433

395434
if (targetWebhookId) {

0 commit comments

Comments
 (0)