Skip to content

Commit 623e286

Browse files
icecrasher321claude
andcommitted
fix(execution): enforce suspension on every personal-secret path, and align webhook cleanup
Review round 2 (Greptile P1 security, cubic P0 — both found the same gap). The suspension check sat next to the split-identity access lookups, so it was skipped by the single-identity shortcut above it. That shortcut is taken whenever the two identities coincide — which is exactly what happens when a custom-block publisher is also their workspace's billing account. The check now runs before the shortcut, unconditionally. Round 1's placement rested on "admission already cleared this identity", and that is not true everywhere: a custom-block child is admitted by `admitCustomBlockChildExecution`, which checks usage limits and nothing else, and a provider URL-validation challenge resolves its secret with no admission at all. Neither path has ever had a ban gate. Only the personal namespace is withheld. Workspace variables belong to the workspace rather than to a person, so they keep resolving and a suspended member's teammates keep working — the reason admission stopped blocking on this identity to begin with. Webhook cleanup now resolves through the same two-identity reader as delivery. Reading both slices as the owner let cleanup see a narrower selection than the delivery that created the subscription: a non-admin owner without a credential grant left `{{VAR}}` unresolved, the provider was handed the literal reference as its credential, and the non-fatal catch silently orphaned the subscription. `resolveBackgroundWebhookEnv` imports the billing reader statically. The dynamic import bought nothing — every boundary audit passes without it — and made each worker pay a cold module load on the first webhook resolution. Restores the `@sim/testing` mock-shape test, which pins that the default snapshot carries every field of the real one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eab8590 commit 623e286

5 files changed

Lines changed: 83 additions & 26 deletions

File tree

apps/sim/lib/environment/utils.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,35 @@ describe('getExecutionEnvironment', () => {
640640
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
641641
})
642642

643+
/**
644+
* The arrangement that slipped past a split-path-only check: a custom-block
645+
* publisher who is also their workspace's billing account makes both
646+
* identities equal, taking the single-identity shortcut. That path has no
647+
* admission gate at all — `admitCustomBlockChildExecution` checks usage limits
648+
* and nothing else — so the suspension has to be enforced here.
649+
*/
650+
it('withholds the personal namespace when both identities are the same suspended user', async () => {
651+
grantAdminTo('publisher-1')
652+
mockGetActivelyBannedUserIds.mockResolvedValue(['publisher-1'])
653+
queueTableRows(environment, [{ variables: { PUBLISHER_KEY: 'publisher-cipher' } }])
654+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
655+
656+
const snapshot = await getExecutionEnvironment('publisher-1', 'publisher-1', 'workspace-1')
657+
658+
expect(snapshot.personalDecrypted).toEqual({})
659+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
660+
})
661+
662+
/** A workspaceless run has no workspace slice either, so a suspended identity lends nothing. */
663+
it('resolves nothing personal for a suspended identity with no workspace', async () => {
664+
mockGetActivelyBannedUserIds.mockResolvedValue(['suspended-1'])
665+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
666+
667+
const snapshot = await getExecutionEnvironment('suspended-1', 'suspended-1', undefined)
668+
669+
expect(snapshot.personalDecrypted).toEqual({})
670+
})
671+
643672
/** The actor is cleared by admission, so only the personal identity is looked up. */
644673
it('does not re-check the execution actor for a ban', async () => {
645674
grantAdminTo('actor-1')

apps/sim/lib/environment/utils.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -490,14 +490,39 @@ export async function getExecutionEnvironment(
490490
return toWorkspaceOnlySnapshot(await getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId))
491491
}
492492

493+
/**
494+
* A suspended account lends nothing, from any path.
495+
*
496+
* Checked before the single-identity shortcut below rather than alongside the
497+
* access lookups, because "the caller already cleared this identity" does not
498+
* hold everywhere: a custom-block child is admitted by
499+
* `admitCustomBlockChildExecution`, which checks usage limits and nothing
500+
* else, and a provider URL-validation challenge resolves with no admission at
501+
* all. Behind the shortcut, a publisher who is also their workspace's billing
502+
* account made both identities equal and skipped the gate entirely — the one
503+
* arrangement where suspension was silently ignored.
504+
*
505+
* Only the personal namespace is withheld. Workspace variables belong to the
506+
* workspace rather than to a person, so they keep resolving and the runs a
507+
* suspended member's teammates depend on keep working — which is the whole
508+
* reason admission stopped blocking on this identity in the first place.
509+
*/
510+
if ((await getActivelyBannedUserIds([personalUserId])).length > 0) {
511+
logger.error('Personal-environment identity is suspended; resolving workspace variables only', {
512+
personalUserId,
513+
workspaceUserId,
514+
workspaceId,
515+
})
516+
return toWorkspaceOnlySnapshot(await getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId))
517+
}
518+
493519
if (!workspaceId || workspaceUserId === personalUserId) {
494520
return getPersonalAndWorkspaceEnv(personalUserId, workspaceId)
495521
}
496522

497-
const [actorAccess, personalAccess, suspendedPersonalIds] = await Promise.all([
523+
const [actorAccess, personalAccess] = await Promise.all([
498524
checkWorkspaceAccess(workspaceId, workspaceUserId),
499525
checkWorkspaceAccess(workspaceId, personalUserId),
500-
getActivelyBannedUserIds([personalUserId]),
501526
])
502527

503528
/**
@@ -509,23 +534,7 @@ export async function getExecutionEnvironment(
509534
throw new Error(`Workspace ${workspaceId} does not exist`)
510535
}
511536

512-
/**
513-
* A suspended account lends nothing, even when the run itself may continue.
514-
*
515-
* Admission blocks on the identities a run acts as and deliberately not on the
516-
* personal-variable fallback, so that suspending one member does not take down
517-
* the schedules, webhooks, and deployed chats their teammates depend on. But
518-
* "this run may continue" and "that person's private credentials may still be
519-
* used" are different questions, and a ban revokes neither workspace
520-
* membership nor the pointer naming them — so without this the run proceeds on
521-
* a suspended account's own keys.
522-
*
523-
* Only the personal identity is checked here; admission already cleared the
524-
* actor before execution reached this point.
525-
*/
526-
const personalIdentitySuspended = suspendedPersonalIds.length > 0
527-
528-
if (!personalAccess.hasAccess || personalIdentitySuspended) {
537+
if (!personalAccess.hasAccess) {
529538
if (!actorAccess.hasAccess) {
530539
logger.error('Neither execution identity can reach the workspace', {
531540
personalUserId,
@@ -536,9 +545,7 @@ export async function getExecutionEnvironment(
536545
}
537546

538547
logger.error(
539-
personalIdentitySuspended
540-
? 'Personal-environment identity is suspended; resolving workspace variables only'
541-
: 'Personal-environment identity cannot reach the workspace; resolving workspace variables only',
548+
'Personal-environment identity cannot reach the workspace; resolving workspace variables only',
542549
{ personalUserId, workspaceUserId, workspaceId }
543550
)
544551
return toWorkspaceOnlySnapshot(

apps/sim/lib/webhooks/env-resolver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isRecordLike } from '@sim/utils/object'
2+
import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attribution'
23
import { getEffectiveDecryptedEnv, getExecutionEnvironment } from '@/lib/environment/utils'
34
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
45

@@ -30,7 +31,6 @@ export async function resolveBackgroundWebhookEnv(
3031
return getEffectiveDecryptedEnv(workflowOwnerUserId)
3132
}
3233

33-
const { getWorkspaceBilledAccountUserId } = await import('@/lib/billing/core/billing-attribution')
3434
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
3535
if (!billedAccountUserId) {
3636
return getEffectiveDecryptedEnv(workflowOwnerUserId, workspaceId)

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { omit } from '@sim/utils/object'
44
import type { NextRequest } from 'next/server'
55
import {
6+
resolveBackgroundWebhookEnv,
67
resolveWebhookProviderConfig,
78
resolveWebhookRecordProviderConfig,
89
} from '@/lib/webhooks/env-resolver'
@@ -172,8 +173,15 @@ export async function createExternalWebhookSubscription(
172173

173174
/**
174175
* Clean up external webhook subscriptions for a webhook.
175-
* Resolves persisted `{{ENV_VAR}}` references with the workflow owner's
176-
* effective environment before invoking the provider.
176+
*
177+
* Resolves persisted `{{ENV_VAR}}` references the same way the delivery that
178+
* created the subscription resolved them — owner for personal variables, the
179+
* workspace billing account for workspace ones. Reading both slices as the owner
180+
* meant cleanup could see a narrower selection than execution did: a non-admin
181+
* owner without a credential grant for the referenced key left `{{VAR}}`
182+
* unresolved (`onMissing` defaults to `keep`), and the provider was then handed
183+
* the literal reference as its credential. Since the failure below is non-fatal
184+
* by default, that silently orphaned the subscription at the provider.
177185
*
178186
* By default, cleanup failure is logged but non-fatal for legacy best-effort callers.
179187
* Deployment outbox cleanup passes `throwOnError` so provider failures stay retryable.
@@ -197,10 +205,12 @@ export async function cleanupExternalWebhook(
197205
}
198206

199207
const workspaceId = typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined
208+
const envVars = await resolveBackgroundWebhookEnv(workflow.userId, workspaceId)
200209
const resolvedWebhook = await resolveWebhookRecordProviderConfig(
201210
webhook,
202211
workflow.userId,
203-
workspaceId
212+
workspaceId,
213+
{ envVars }
204214
)
205215

206216
await handler.deleteSubscription({

packages/testing/src/mocks/environment-utils.mock.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ describe('environment-utils mock', () => {
1010
resetEnvironmentUtilsMock()
1111
})
1212

13+
/**
14+
* The default must carry EVERY field of the real `EnvironmentResolutionSnapshot`,
15+
* including `personalOwners` and `workspaceUnredactedKeys`. A mirror that omits
16+
* one lets a mocked snapshot reach production code as `undefined` and fail
17+
* there instead of in the assertion, which is the opposite of what a default
18+
* should do.
19+
*/
1320
it('defaults model a user with no environment variables', async () => {
1421
await expect(environmentUtilsMock.getEnvironmentVariableKeys('user-1')).resolves.toEqual({
1522
variableNames: [],
@@ -21,16 +28,20 @@ describe('environment-utils mock', () => {
2128
workspaceEncrypted: {},
2229
personalDecrypted: {},
2330
workspaceDecrypted: {},
31+
personalOwners: {},
2432
conflicts: [],
2533
decryptionFailures: [],
34+
workspaceUnredactedKeys: [],
2635
})
2736
await expect(environmentUtilsMock.getEffectiveEnvironmentSnapshot('user-1')).resolves.toEqual({
2837
personalEncrypted: {},
2938
workspaceEncrypted: {},
3039
personalDecrypted: {},
3140
workspaceDecrypted: {},
41+
personalOwners: {},
3242
conflicts: [],
3343
decryptionFailures: [],
44+
workspaceUnredactedKeys: [],
3445
})
3546
await expect(
3647
environmentUtilsMock.getEffectiveEnvironmentVariableNames('user-1')

0 commit comments

Comments
 (0)