Skip to content

Commit b076d86

Browse files
committed
improvement(webhooks): validate credential id shape once before authorization
1 parent 17128ac commit b076d86

2 files changed

Lines changed: 30 additions & 41 deletions

File tree

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

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,9 @@ describe('POST /api/webhooks polling configuration', () => {
357357
})
358358
})
359359

360+
const CREDENTIAL_ALLOWED = { ok: true, workspaceId: 'workspace-1' }
361+
const CREDENTIAL_DENIED = { ok: false, error: 'You do not have access to this credential.' }
362+
360363
/** Mocks an actor with write access to `workflow-1` and no provider side effects. */
361364
function setupUpsertMocks(): void {
362365
vi.clearAllMocks()
@@ -539,7 +542,7 @@ describe('POST /api/webhooks credential references', () => {
539542
})
540543

541544
it('saves a credential the actor can use in the workflow workspace', async () => {
542-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
545+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
543546
queueCreatePathRows()
544547

545548
const response = await POST(upsertRequest({ credentialId: 'own-credential' }))
@@ -578,7 +581,7 @@ describe('POST /api/webhooks credential references', () => {
578581
* authorized and kept, while a stored `userId` is never carried forward.
579582
*/
580583
it('authorizes and keeps the stored credential on a re-save that omits it', async () => {
581-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
584+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
582585
queueUpdatePathRows(true, { credentialId: 'stored-credential', userId: 'stored-user' })
583586

584587
const response = await POST(upsertRequest({ eventType: 'record.created' }))
@@ -596,10 +599,7 @@ describe('POST /api/webhooks credential references', () => {
596599
})
597600

598601
it('refuses a re-save whose stored credential the actor cannot use', async () => {
599-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({
600-
ok: false,
601-
error: 'You do not have access to this credential.',
602-
})
602+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_DENIED)
603603
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
604604

605605
const response = await POST(upsertRequest({ eventType: 'record.created' }))
@@ -616,10 +616,7 @@ describe('POST /api/webhooks credential references', () => {
616616
it.each([null, ''])(
617617
'still authorizes the stored credential when a re-save sends credentialId %j',
618618
async (credentialId) => {
619-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({
620-
ok: false,
621-
error: 'You do not have access to this credential.',
622-
})
619+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_DENIED)
623620
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
624621
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
625622

@@ -638,9 +635,7 @@ describe('POST /api/webhooks credential references', () => {
638635
/** Rotation without recreation never touches the old credential, so it needs no access to it. */
639636
it('rotates the credential without access to the stored one when nothing is recreated', async () => {
640637
mocks.authorizeCredentialUseForAuth.mockImplementation(async (_auth, { credentialId }) =>
641-
credentialId === 'new-credential'
642-
? { ok: true, workspaceId: 'workspace-1' }
643-
: { ok: false, error: 'You do not have access to this credential.' }
638+
credentialId === 'new-credential' ? CREDENTIAL_ALLOWED : CREDENTIAL_DENIED
644639
)
645640
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
646641

@@ -657,7 +652,7 @@ describe('POST /api/webhooks credential references', () => {
657652

658653
/** Recreation cleans up the previous subscription with the stored credential. */
659654
it('authorizes both credentials when a rotation recreates the subscription', async () => {
660-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
655+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
661656
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
662657
queueUpdatePathRows(true, { credentialId: 'stored-credential' })
663658

@@ -675,7 +670,7 @@ describe('POST /api/webhooks credential references', () => {
675670
* when the request omits it, and a `userId` echoed back by the provider is not saved.
676671
*/
677672
it('authorizes the stored credential and drops userId when an omitting re-save recreates', async () => {
678-
mocks.authorizeCredentialUseForAuth.mockResolvedValue({ ok: true, workspaceId: 'workspace-1' })
673+
mocks.authorizeCredentialUseForAuth.mockResolvedValue(CREDENTIAL_ALLOWED)
679674
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
680675
mocks.createExternalWebhookSubscription.mockResolvedValue({
681676
updatedProviderConfig: { externalId: 'subscription-2', userId: 'stored-user' },
@@ -691,7 +686,6 @@ describe('POST /api/webhooks credential references', () => {
691686
workflowId: 'workflow-1',
692687
})
693688
const savedConfig = dbChainMockFns.set.mock.calls.at(-1)?.[0].providerConfig
694-
expect(savedConfig.userId).toBeUndefined()
695689
expect(savedConfig).toEqual({ eventType: 'record.created', externalId: 'subscription-2' })
696690
})
697691

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

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -459,38 +459,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
459459
nextConfig: resolvedProviderConfig,
460460
})
461461

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-
470462
/**
471463
* Subscription handlers, pollers, and subscription cleanup look `credentialId`
472464
* up by id alone and mint tokens as its owner, so every credential this save
473465
* acts with must be usable by the actor in the workflow's workspace before
474-
* anything is subscribed, cleaned up, or saved. That is the requested
475-
* credential, plus the stored one when the save uses it: merged back because
476-
* the request omits `credentialId`, or used to clean up the previous
477-
* subscription on recreation.
466+
* anything is subscribed, cleaned up, or saved: the requested one, and the
467+
* stored one when it is merged back (the request omits `credentialId`) or
468+
* cleans up the previous subscription (recreation).
478469
*/
479470
const usesStoredCredential =
480471
existingWebhook && (shouldRecreateSubscription || !('credentialId' in originalProviderConfig))
481-
const credentialIds = new Set(
482-
[
483-
originalProviderConfig.credentialId,
484-
usesStoredCredential ? existingWebhook.providerConfig?.credentialId : undefined,
485-
].filter((id) => id != null && id !== '')
486-
)
487-
for (const credentialId of credentialIds) {
488-
if (typeof credentialId !== 'string') {
489-
return NextResponse.json(
490-
{ error: 'providerConfig.credentialId must be a literal credential id' },
491-
{ status: 400 }
492-
)
493-
}
472+
const credentialIds = [
473+
originalProviderConfig.credentialId,
474+
usesStoredCredential ? existingWebhook.providerConfig?.credentialId : undefined,
475+
].filter((id) => id != null && id !== '')
476+
477+
/** The row stores the unresolved text, so only a literal credential id can be authorized. */
478+
if (
479+
resolvedProviderConfig.credentialId !== originalProviderConfig.credentialId ||
480+
!credentialIds.every((id): id is string => typeof id === 'string')
481+
) {
482+
return NextResponse.json(
483+
{ error: 'providerConfig.credentialId must be a literal credential id' },
484+
{ status: 400 }
485+
)
486+
}
487+
488+
for (const credentialId of new Set(credentialIds)) {
494489
const credentialAccess = await authorizeCredentialUseForAuth(
495490
{ success: true, userId, authType: AuthType.SESSION },
496491
{ credentialId, workflowId }

0 commit comments

Comments
 (0)