Skip to content

Commit 5716bf0

Browse files
waleedlatif1claude
andcommitted
fix(workflows): enforce server-side cascade lock checks and close security gaps
- Split lock utilities into pure functions (lock.ts) and DB-backed functions (lock-db.ts) - Add cascade lock checks to all API routes: workflow CRUD, folder CRUD, state save, reorder/move - Add workflow lock check to Socket.IO collaborative operations - Block drag-and-drop for locked items and into locked folders - Fix lock bypass when isLocked is included alongside other fields in request body - Disable duplicate action for locked workflows and folders - Make isLocked required in WorkflowMetadata type (matches NOT NULL schema) - Add 16 unit tests for lock utility functions - Re-export pure functions from hooks/use-effective-lock.ts for client compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent beb8911 commit 5716bf0

File tree

20 files changed

+15750
-48
lines changed

20 files changed

+15750
-48
lines changed

apps/sim/app/academy/components/sandbox-canvas-provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export function SandboxCanvasProvider({
216216
workspaceId: SANDBOX_WORKSPACE_ID,
217217
sortOrder: 0,
218218
isSandbox: true,
219+
isLocked: false,
219220
}
220221

221222
useWorkflowStore.getState().replaceWorkflowState(workflowState)

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { captureServerEvent } from '@/lib/posthog/server'
9+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
910
import { performDeleteFolder } from '@/lib/workflows/orchestration'
1011
import { checkForCircularReference } from '@/lib/workflows/utils'
1112
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -78,15 +79,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
7879
)
7980
}
8081

81-
// If folder is locked, only allow toggling isLocked and isExpanded (by admins)
82-
if (existingFolder.isLocked && isLocked === undefined) {
83-
// Allow isExpanded toggle on locked folders (UI collapse/expand)
84-
const hasNonExpandUpdates =
82+
// If folder is effectively locked, only allow isLocked toggle (admin) and isExpanded toggle
83+
const effectivelyLocked = await isFolderEffectivelyLockedDb(id)
84+
if (effectivelyLocked) {
85+
const hasNonAllowedUpdates =
8586
name !== undefined ||
8687
color !== undefined ||
8788
parentId !== undefined ||
8889
sortOrder !== undefined
89-
if (hasNonExpandUpdates) {
90+
if (hasNonAllowedUpdates) {
9091
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
9192
}
9293
}
@@ -167,7 +168,8 @@ export async function DELETE(
167168
)
168169
}
169170

170-
if (existingFolder.isLocked) {
171+
const effectivelyLocked = await isFolderEffectivelyLockedDb(id)
172+
if (effectivelyLocked) {
171173
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
172174
}
173175

apps/sim/app/api/folders/reorder/route.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { isFolderEffectivelyLocked } from '@/lib/workflows/lock'
910
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1011

1112
const logger = createLogger('FolderReorderAPI')
@@ -44,7 +45,12 @@ export async function PUT(req: NextRequest) {
4445

4546
const folderIds = updates.map((u) => u.id)
4647
const existingFolders = await db
47-
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
48+
.select({
49+
id: workflowFolder.id,
50+
workspaceId: workflowFolder.workspaceId,
51+
parentId: workflowFolder.parentId,
52+
isLocked: workflowFolder.isLocked,
53+
})
4854
.from(workflowFolder)
4955
.where(inArray(workflowFolder.id, folderIds))
5056

@@ -58,6 +64,37 @@ export async function PUT(req: NextRequest) {
5864
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
5965
}
6066

67+
// Build folder map for cascade lock checks
68+
const allFolders = await db
69+
.select({
70+
id: workflowFolder.id,
71+
parentId: workflowFolder.parentId,
72+
isLocked: workflowFolder.isLocked,
73+
})
74+
.from(workflowFolder)
75+
.where(eq(workflowFolder.workspaceId, workspaceId))
76+
77+
const folderMap: Record<string, { id: string; parentId: string | null; isLocked: boolean }> = {}
78+
for (const f of allFolders) {
79+
folderMap[f.id] = f
80+
}
81+
82+
// Block if any source folder or destination parent is effectively locked
83+
for (const update of validUpdates) {
84+
if (isFolderEffectivelyLocked(update.id, folderMap)) {
85+
return NextResponse.json(
86+
{ error: 'Cannot move or reorder a locked folder' },
87+
{ status: 403 }
88+
)
89+
}
90+
if (update.parentId && isFolderEffectivelyLocked(update.parentId, folderMap)) {
91+
return NextResponse.json(
92+
{ error: 'Cannot move folders into a locked folder' },
93+
{ status: 403 }
94+
)
95+
}
96+
}
97+
6198
await db.transaction(async (tx) => {
6299
for (const update of validUpdates) {
63100
const updateData: Record<string, unknown> = {

apps/sim/app/api/workflows/[id]/route.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { captureServerEvent } from '@/lib/posthog/server'
10+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1011
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
1112
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
1213
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
@@ -183,7 +184,10 @@ export async function DELETE(
183184
)
184185
}
185186

186-
if (workflowData.isLocked) {
187+
const isLocked =
188+
workflowData.isLocked ||
189+
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
190+
if (isLocked) {
187191
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
188192
}
189193

@@ -308,9 +312,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
308312
}
309313
}
310314

311-
// If workflow is locked, only allow toggling isLocked (by admins)
312-
if (workflowData.isLocked && updates.isLocked === undefined) {
313-
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
315+
// If workflow is effectively locked, only allow isLocked toggle (by admins)
316+
const effectivelyLocked =
317+
workflowData.isLocked ||
318+
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
319+
if (effectivelyLocked) {
320+
const hasNonLockUpdates =
321+
updates.name !== undefined ||
322+
updates.description !== undefined ||
323+
updates.color !== undefined ||
324+
updates.folderId !== undefined ||
325+
updates.sortOrder !== undefined
326+
if (hasNonLockUpdates) {
327+
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
328+
}
314329
}
315330

316331
const updateData: Record<string, unknown> = { updatedAt: new Date() }

apps/sim/app/api/workflows/[id]/state/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
loadWorkflowFromNormalizedTables,
1313
saveWorkflowToNormalizedTables,
1414
} from '@/lib/workflows/persistence/utils'
15+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1516
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
1617
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
1718
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
@@ -199,6 +200,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
199200
)
200201
}
201202

203+
// Check if workflow is effectively locked (directly or via folder cascade)
204+
const isLocked =
205+
workflowData.isLocked ||
206+
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
207+
if (isLocked) {
208+
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
209+
}
210+
202211
// Sanitize custom tools in agent blocks before saving
203212
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
204213
state.blocks as Record<string, BlockState>

apps/sim/app/api/workflows/reorder/route.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from '@sim/db'
2-
import { workflow } from '@sim/db/schema'
2+
import { workflow, workflowFolder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { eq, inArray } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { isFolderEffectivelyLocked, isWorkflowEffectivelyLocked } from '@/lib/workflows/lock'
910
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1011

1112
const logger = createLogger('WorkflowReorderAPI')
@@ -44,7 +45,12 @@ export async function PUT(req: NextRequest) {
4445

4546
const workflowIds = updates.map((u) => u.id)
4647
const existingWorkflows = await db
47-
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
48+
.select({
49+
id: workflow.id,
50+
workspaceId: workflow.workspaceId,
51+
isLocked: workflow.isLocked,
52+
folderId: workflow.folderId,
53+
})
4854
.from(workflow)
4955
.where(inArray(workflow.id, workflowIds))
5056

@@ -58,6 +64,41 @@ export async function PUT(req: NextRequest) {
5864
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
5965
}
6066

67+
// Build folder map for cascade lock checks
68+
const allFolders = await db
69+
.select({
70+
id: workflowFolder.id,
71+
parentId: workflowFolder.parentId,
72+
isLocked: workflowFolder.isLocked,
73+
})
74+
.from(workflowFolder)
75+
.where(eq(workflowFolder.workspaceId, workspaceId))
76+
77+
const folderMap: Record<string, { id: string; parentId: string | null; isLocked: boolean }> = {}
78+
for (const f of allFolders) {
79+
folderMap[f.id] = f
80+
}
81+
82+
// Build workflow lookup for lock checks
83+
const workflowLookup = new Map(existingWorkflows.map((w) => [w.id, w]))
84+
85+
// Block if any source workflow or destination folder is effectively locked
86+
for (const update of validUpdates) {
87+
const wf = workflowLookup.get(update.id)
88+
if (wf && isWorkflowEffectivelyLocked(wf, folderMap)) {
89+
return NextResponse.json(
90+
{ error: 'Cannot move or reorder a locked workflow' },
91+
{ status: 403 }
92+
)
93+
}
94+
if (update.folderId && isFolderEffectivelyLocked(update.folderId, folderMap)) {
95+
return NextResponse.json(
96+
{ error: 'Cannot move workflows into a locked folder' },
97+
{ status: 403 }
98+
)
99+
}
100+
}
101+
61102
await db.transaction(async (tx) => {
62103
for (const update of validUpdates) {
63104
const updateData: Record<string, unknown> = {

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId
326326
workspaceId,
327327
folderId: null,
328328
sortOrder,
329+
isLocked: false,
329330
}
330331
const queryClient = getQueryClient()
331332
const key = workflowKeys.list(workspaceId, 'active')

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ export function FolderItem({
498498
onClick={handleClick}
499499
onKeyDown={handleKeyDown}
500500
onContextMenu={handleContextMenu}
501-
draggable={!isEditing && !dragDisabled}
501+
draggable={!isEditing && !dragDisabled && !isEffectivelyLocked}
502502
onDragStart={handleDragStart}
503503
onDragEnd={handleDragEnd}
504504
{...hoverHandlers}
@@ -592,7 +592,10 @@ export function FolderItem({
592592
!userPermissions.canEdit || createFolderMutation.isPending || isEffectivelyLocked
593593
}
594594
disableDuplicate={
595-
!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
595+
!userPermissions.canEdit ||
596+
isDuplicatingSelection ||
597+
!hasExportableContent ||
598+
isEffectivelyLocked
596599
}
597600
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
598601
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ export function WorkflowItem({
423423
'hover-hover:bg-[var(--surface-hover)]',
424424
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
425425
)}
426-
draggable={!isEditing && !dragDisabled}
426+
draggable={!isEditing && !dragDisabled && !isEffectivelyLocked}
427427
onDragStart={handleDragStart}
428428
onDragEnd={handleDragEnd}
429429
onClick={handleClick}
@@ -509,7 +509,7 @@ export function WorkflowItem({
509509
showExport={true}
510510
showColorChange={!isMixedSelection && selectedWorkflows.size <= 1}
511511
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
512-
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
512+
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection || isEffectivelyLocked}
513513
disableExport={!userPermissions.canEdit}
514514
disableColorChange={!userPermissions.canEdit || isEffectivelyLocked}
515515
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
22
import { createLogger } from '@sim/logger'
33
import { useParams } from 'next/navigation'
44
import { getFolderPath } from '@/lib/folders/tree'
5+
import { isFolderEffectivelyLocked } from '@/lib/workflows/lock'
56
import { useReorderFolders } from '@/hooks/queries/folders'
67
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
78
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
@@ -408,6 +409,15 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
408409

409410
try {
410411
const destinationFolderId = getDestinationFolderId(indicator)
412+
413+
// Block drops into locked folders
414+
if (destinationFolderId) {
415+
const folders = getFolderMap(workspaceId)
416+
if (folders && isFolderEffectivelyLocked(destinationFolderId, folders)) {
417+
return
418+
}
419+
}
420+
411421
const validFolderIds = folderIds.filter((id) => canMoveFolderTo(id, destinationFolderId))
412422
if (workflowIds.length === 0 && validFolderIds.length === 0) {
413423
return

0 commit comments

Comments
 (0)