Skip to content

Commit 3f60903

Browse files
icecrasher321claude
andcommitted
fix(execution): make the suspension gate fail closed on an undeclared userId
Review round 4 (Greptile P1 security). Round 2 keyed the ban candidate on `useAuthenticatedUserAsActor`, assuming that flag separates a live caller from a stored reference. It does not. The interactive resume route reads `access.auth?.userId` and passes that live resumer as `userId` while leaving the flag false on purpose — attribution is captured before the pause and must not move — so a suspended user could resume a paused run whose persisted attribution named a different, unsuspended actor. The distinction is per-caller and cannot be inferred, so it is now declared. `userIdIsStoredReference` defaults to false, which means an undeclared call site keeps blocking; only the three that genuinely pass a stored reference opt out: the webhook processor (the workflow owner), the deployed-chat route (the chat's creator), and table-cell dispatch (the owner, but only when nothing triggered it). Resume, manual, API, and async paths are candidates again. Withholding a suspended account's personal variables stays where it was, in `getExecutionEnvironment`, so the two concerns remain separable: a suspended stored reference does not block the run, and does not lend its credentials either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82f5f69 commit 3f60903

5 files changed

Lines changed: 69 additions & 45 deletions

File tree

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export const POST = withRouteHandler(
180180
const preprocessResult = await preprocessExecution({
181181
workflowId: deployment.workflowId,
182182
userId: deployment.userId,
183+
// Whoever deployed this chat, not whoever is talking to it.
184+
userIdIsStoredReference: true,
183185
triggerType: 'chat',
184186
executionId,
185187
requestId,

apps/sim/background/workflow-column-execution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,8 @@ async function runWorkflowAndWriteTerminal(
787787
workflowRecord,
788788
userId: payload.triggeredByUserId ?? workflowRecord.userId,
789789
useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId),
790+
// Falls back to the workflow owner when nobody triggered this.
791+
userIdIsStoredReference: !payload.triggeredByUserId,
790792
triggerType: 'workflow',
791793
checkDeployment: false,
792794
checkRateLimit: false,

apps/sim/lib/execution/preprocessing.test.ts

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -500,29 +500,41 @@ describe('preprocessExecution ban gate', () => {
500500
expect(mockCheckRateLimit).toHaveBeenCalledTimes(1)
501501
})
502502

503-
/** An authenticated caller becomes the actor, so one candidate covers them. */
504-
it('checks the authenticated caller as the actor', async () => {
505-
const result = await preprocessExecution({ ...baseOptions, useAuthenticatedUserAsActor: true })
503+
/** The default is the blocking one: an undeclared `userId` stays a candidate. */
504+
it('checks the actor and the caller-provided userId by default', async () => {
505+
const result = await preprocessExecution(baseOptions)
506506

507507
expect(result.success).toBe(true)
508508
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledTimes(1)
509-
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['owner-1'])
509+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1', 'owner-1'])
510510
})
511511

512512
/**
513-
* The one shape where the two genuinely differ: an upstream boundary captured
514-
* the attribution, so the actor comes from there while `userId` still names
515-
* the authenticated caller. Both are identities the run acts as.
513+
* Resume is the shape that must keep blocking: it passes the live
514+
* authenticated resumer as `userId` while attribution stays pinned to the
515+
* original actor across the pause, and deliberately leaves
516+
* `useAuthenticatedUserAsActor` false. Keying the gate on that flag excluded
517+
* exactly the person who just acted.
516518
*/
517-
it('checks both when a captured attribution names a different actor', async () => {
519+
it('checks a live resumer whose captured attribution names a different actor', async () => {
520+
mockGetActivelyBannedUserIds.mockImplementation(async (ids: string[]) =>
521+
ids.filter((id) => id === 'suspended-resumer')
522+
)
523+
518524
const result = await preprocessExecution({
519525
...baseOptions,
520-
useAuthenticatedUserAsActor: true,
521-
billingAttribution: { ...ORGANIZATION_ATTRIBUTION, actorUserId: 'delegated-actor-1' } as any,
526+
userId: 'suspended-resumer',
527+
billingAttribution: { ...ORGANIZATION_ATTRIBUTION, actorUserId: 'original-actor-1' } as any,
522528
})
523529

524-
expect(result.success).toBe(true)
525-
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['delegated-actor-1', 'owner-1'])
530+
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith([
531+
'original-actor-1',
532+
'suspended-resumer',
533+
])
534+
expect(result).toMatchObject({
535+
success: false,
536+
error: { statusCode: 403, message: 'Account suspended' },
537+
})
526538
})
527539

528540
it('excludes the "unknown" sentinel userId', async () => {
@@ -533,34 +545,25 @@ describe('preprocessExecution ban gate', () => {
533545
})
534546

535547
/**
536-
* Callers overload `userId`: an authenticated caller on a manual run, but a
537-
* stored pointer on a system-triggered one — the workflow owner from
538-
* `checkWebhookPreprocessing`, the chat's creator from the deployed-chat
539-
* route. Without `useAuthenticatedUserAsActor` gating it, the same ban
540-
* suspended a webhook while the schedule beside it kept running.
541-
*/
542-
it('ignores a stored-pointer userId when it is not the authenticated caller', async () => {
543-
const result = await preprocessExecution({ ...baseOptions, userId: 'creator-1' })
544-
545-
expect(result.success).toBe(true)
546-
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1'])
547-
expect(mockGetActivelyBannedUserIds.mock.calls[0][0]).not.toContain('creator-1')
548-
})
549-
550-
/**
551-
* The webhook shape specifically: `checkWebhookPreprocessing` passes the
552-
* workflow owner as `userId` with no `useAuthenticatedUserAsActor`, so a
553-
* banned owner must not take the webhook down.
548+
* The webhook and deployed-chat shape: `userId` names the workflow owner or
549+
* the chat's creator, so a ban on them must not take down automation their
550+
* teammates depend on. Those call sites declare it explicitly rather than the
551+
* gate inferring it.
554552
*/
555-
it('does not block a system-triggered run because the workflow owner is banned', async () => {
553+
it('skips a userId the caller declares a stored reference', async () => {
556554
mockGetActivelyBannedUserIds.mockImplementation(async (ids: string[]) =>
557555
ids.filter((id) => id === 'creator-1')
558556
)
559557

560-
const result = await preprocessExecution({ ...baseOptions, userId: 'creator-1' })
558+
const result = await preprocessExecution({
559+
...baseOptions,
560+
userId: 'creator-1',
561+
userIdIsStoredReference: true,
562+
})
561563

562564
expect(result.success).toBe(true)
563565
expect(mockGetActivelyBannedUserIds).toHaveBeenCalledWith(['billed-account-1'])
566+
expect(mockGetActivelyBannedUserIds.mock.calls[0][0]).not.toContain('creator-1')
564567
})
565568

566569
it('fails closed with 500 when the ban check errors', async () => {

apps/sim/lib/execution/preprocessing.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ export interface PreprocessExecutionOptions {
9797
triggerData?: SessionStartParams['triggerData']
9898
/** Use the authenticated user as actor for client executions and personal API keys. */
9999
useAuthenticatedUserAsActor?: boolean
100+
/**
101+
* Declares that `userId` names a stored reference — a workflow owner, a chat's
102+
* creator — rather than someone who just acted, so the suspension gate skips
103+
* it. Suspending one member must not take down the schedules, webhooks, and
104+
* deployed chats their teammates depend on merely because that person's name
105+
* sits on the row.
106+
*
107+
* Defaults to false so an unset call site keeps blocking. Withholding the
108+
* suspended account's personal variables is handled separately, in
109+
* {@link getExecutionEnvironment}.
110+
*/
111+
userIdIsStoredReference?: boolean
100112
/** Pre-fetched workflow row for caller context; preprocessing still re-checks active state. */
101113
workflowRecord?: WorkflowRecord
102114
/**
@@ -189,6 +201,7 @@ export async function preprocessExecution(
189201
loggingSession: providedLoggingSession,
190202
triggerData,
191203
useAuthenticatedUserAsActor = false,
204+
userIdIsStoredReference = false,
192205
workflowRecord: prefetchedWorkflowRecord,
193206
billingAttribution: providedBillingAttribution,
194207
executionType = 'sync',
@@ -452,23 +465,25 @@ export async function preprocessExecution(
452465
* Blocks when an identity this run actually acts as has an active ban or
453466
* blocked email domain.
454467
*
455-
* `userId` is only such an identity when `useAuthenticatedUserAsActor` says
456-
* so. Callers overload that parameter: it is an authenticated caller on a
457-
* manual or personal-key run, but a stored pointer everywhere else — the
458-
* workflow owner from `checkWebhookPreprocessing`, the chat's creator from
459-
* the deployed-chat route, the literal `'unknown'` from a schedule. Reading
460-
* it unconditionally made the same ban suspend a webhook while leaving the
461-
* schedule beside it running, for no reason a workspace could observe.
462-
* `workflow-column-execution` toggles the two together and is the clearest
463-
* statement of the rule.
468+
* `userId` is a candidate unless the caller declares it a stored reference.
469+
* The default is deliberately the blocking one: callers overload the
470+
* parameter, and only the caller knows which kind it passed, so a call site
471+
* that forgets to say must fail closed rather than silently admit a
472+
* suspended account.
473+
*
474+
* `useAuthenticatedUserAsActor` cannot stand in for that declaration, which
475+
* an earlier revision of this gate assumed. Resume passes the live
476+
* authenticated resumer as `userId` and leaves that flag false on purpose —
477+
* attribution is captured before the pause and must not move — so keying on
478+
* it excluded exactly the person who just acted.
464479
*
465-
* A stored pointer being banned must not take down work their teammates
480+
* A stored reference being banned must not take down work their teammates
466481
* still depend on — but it must not lend that person's credentials either,
467-
* which is why the executor drops a banned identity's personal namespace
468-
* rather than this gate blocking the whole run.
482+
* which is why {@link getExecutionEnvironment} drops a suspended identity's
483+
* personal namespace rather than this gate blocking the whole run.
469484
*/
470485
const banCandidateIds = [actorUserId]
471-
if (useAuthenticatedUserAsActor && userId && userId !== 'unknown' && userId !== actorUserId) {
486+
if (!userIdIsStoredReference && userId && userId !== 'unknown' && userId !== actorUserId) {
472487
banCandidateIds.push(userId)
473488
}
474489
try {

apps/sim/lib/webhooks/processor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,8 @@ export async function checkWebhookPreprocessing(
611611
const preprocessResult = await preprocessExecution({
612612
workflowId: foundWorkflow.id,
613613
userId: foundWorkflow.userId,
614+
// The workflow owner, not whoever sent this delivery — nobody sent it.
615+
userIdIsStoredReference: true,
614616
triggerType: 'webhook',
615617
executionId,
616618
requestId,

0 commit comments

Comments
 (0)