Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/sim/app/api/folders/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const updateFolderSchema = z.object({
isExpanded: z.boolean().optional(),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
isLocked: z.boolean().optional(),
})

// PUT - Update a folder
Expand All @@ -42,7 +43,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}

const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
const { name, color, isExpanded, parentId, sortOrder, isLocked } = validationResult.data

// Verify the folder exists
const existingFolder = await db
Expand All @@ -69,6 +70,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}

// If toggling isLocked, require admin permission
if (isLocked !== undefined && workspacePermission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required to lock/unlock folders' },
{ status: 403 }
)
}

// If folder is locked, only allow toggling isLocked and isExpanded (by admins)
if (existingFolder.isLocked && isLocked === undefined) {
// Allow isExpanded toggle on locked folders (UI collapse/expand)
const hasNonExpandUpdates =
name !== undefined ||
color !== undefined ||
parentId !== undefined ||
sortOrder !== undefined
if (hasNonExpandUpdates) {
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lock enforcement bypassed when isLocked included in request

Medium Severity

The lock enforcement check only fires when isLocked === undefined, so an admin can modify any field on a locked resource by also including isLocked in the request body (even re-sending the current value). For example, { isLocked: true, name: "modified" } sent to a locked folder skips the guard entirely — renaming succeeds while the folder stays locked. The same pattern applies to the workflow PUT handler at line 312. This contradicts the comments stating "only allow toggling isLocked."

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de71cd7. Configure here.


// Prevent setting a folder as its own parent or creating circular references
if (parentId && parentId === id) {
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
Expand All @@ -91,6 +113,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (parentId !== undefined) updates.parentId = parentId || null
if (sortOrder !== undefined) updates.sortOrder = sortOrder
if (isLocked !== undefined) updates.isLocked = isLocked

const [updatedFolder] = await db
.update(workflowFolder)
Expand Down Expand Up @@ -144,6 +167,10 @@ export async function DELETE(
)
}

if (existingFolder.isLocked) {
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
}

const result = await performDeleteFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/app/api/workflows/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const UpdateWorkflowSchema = z.object({
color: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
isLocked: z.boolean().optional(),
})

/**
Expand Down Expand Up @@ -182,6 +183,10 @@ export async function DELETE(
)
}

if (workflowData.isLocked) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}
Comment on lines +186 to +188
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Server-side folder cascade not enforced

The DELETE and PUT handlers only check workflowData.isLocked (the workflow's own flag) but not whether the workflow lives inside a locked folder. A client that knows a workflow's ID can bypass a folder-level lock entirely by calling the API directly.

For example:

  1. Admin locks folder F containing workflow W.
  2. Any editor calls DELETE /api/workflows/W directly.
  3. workflowData.isLocked is false, so the 403 guard is skipped and the deletion succeeds.

The same bypass exists in the PUT handler at line 312. The server-side logic should mirror the client-side isWorkflowEffectivelyLocked check by also looking up the workflow's folderId chain and checking isLocked on each ancestor.

The same issue is present in the PUT guard at line 312:

if (workflowData.isLocked && updates.isLocked === undefined) {
  return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}

Both of these should also check whether the workflow's folder (or any ancestor folder) is locked, either by fetching and walking the folder chain server-side or by adding an isEffectivelyLocked utility in the server context.


const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
Expand Down Expand Up @@ -288,12 +293,33 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}

// If toggling isLocked, require admin permission
if (updates.isLocked !== undefined) {
const adminAuth = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'admin',
})
if (!adminAuth.allowed) {
return NextResponse.json(
{ error: 'Admin access required to lock/unlock workflows' },
{ status: 403 }
)
}
}

// If workflow is locked, only allow toggling isLocked (by admins)
if (workflowData.isLocked && updates.isLocked === undefined) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}

const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) updateData.name = updates.name
if (updates.description !== undefined) updateData.description = updates.description
if (updates.color !== undefined) updateData.color = updates.color
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
if (updates.isLocked !== undefined) updateData.isLocked = updates.isLocked

if (updates.name !== undefined || updates.folderId !== undefined) {
const targetName = updates.name ?? workflowData.name
Expand Down
24 changes: 22 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useFolderMap } from '@/hooks/queries/folders'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
Expand Down Expand Up @@ -290,6 +292,8 @@ const WorkflowContent = React.memo(
isPlaceholderData: isWorkflowMapPlaceholderData,
} = useWorkflowMap(workspaceId)

const { data: folderMap } = useFolderMap(workspaceId)

const {
activeWorkflowId,
hydration,
Expand Down Expand Up @@ -608,7 +612,16 @@ const WorkflowContent = React.memo(

const { userPermissions, workspacePermissions, permissionsError } =
useWorkspacePermissionsContext()
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
const activeWorkflowMetadata = activeWorkflowId ? workflows[activeWorkflowId] : undefined
const isWorkflowLocked = useMemo(
() =>
activeWorkflowMetadata
? isWorkflowEffectivelyLocked(activeWorkflowMetadata, folderMap ?? {})
: false,
[activeWorkflowMetadata, folderMap]
)

/** Returns read-only permissions when viewing snapshot or locked workflow. */
const effectivePermissions = useMemo(() => {
if (currentWorkflow.isSnapshotView) {
return {
Expand All @@ -618,8 +631,15 @@ const WorkflowContent = React.memo(
canRead: userPermissions.canRead,
}
}
if (isWorkflowLocked) {
return {
...userPermissions,
canEdit: false,
canRead: userPermissions.canRead,
}
}
return userPermissions
}, [userPermissions, currentWorkflow.isSnapshotView])
}, [userPermissions, currentWorkflow.isSnapshotView, isWorkflowLocked])
const {
collaborativeBatchAddEdges,
collaborativeBatchRemoveEdges,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { generateId } from '@/lib/core/utils/uuid'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
Expand All @@ -27,10 +27,11 @@ import {
useExportFolder,
useExportSelection,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { isFolderEffectivelyLocked } from '@/hooks/use-effective-lock'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
Expand Down Expand Up @@ -71,6 +72,23 @@ export function FolderItem({
const selectedFolders = useFolderStore((state) => state.selectedFolders)
const isSelected = selectedFolders.has(folder.id)

const { data: folderMap } = useFolderMap(workspaceId)
const isEffectivelyLocked = useMemo(
() => isFolderEffectivelyLocked(folder.id, folderMap ?? {}),
[folder.id, folderMap]
)
const isDirectlyLocked = folder.isLocked ?? false
const isLockedByParent = isEffectivelyLocked && !isDirectlyLocked

const handleToggleLock = useCallback(() => {
updateFolderMutation.mutate({
workspaceId,
id: folder.id,
updates: { isLocked: !isDirectlyLocked },
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, folder.id, isDirectlyLocked])

const { canDeleteFolder, canDeleteWorkflows } = useCanDelete({ workspaceId })

const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
Expand Down Expand Up @@ -301,11 +319,12 @@ export function FolderItem({

const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (isEffectivelyLocked) return
e.preventDefault()
e.stopPropagation()
handleStartEdit()
},
[handleStartEdit]
[handleStartEdit, isEffectivelyLocked]
)

const handleClick = useCallback(
Expand Down Expand Up @@ -505,6 +524,9 @@ export function FolderItem({
>
{folder.name}
</span>
{isEffectivelyLocked && (
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
)}
<button
type='button'
aria-label='Folder options'
Expand Down Expand Up @@ -538,14 +560,22 @@ export function FolderItem({
showRename={!isMixedSelection && selectedFolders.size <= 1}
showDuplicate={true}
showExport={true}
disableRename={!userPermissions.canEdit}
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
disableCreate={
!userPermissions.canEdit || createWorkflowMutation.isPending || isEffectivelyLocked
}
disableCreateFolder={
!userPermissions.canEdit || createFolderMutation.isPending || isEffectivelyLocked
}
disableDuplicate={
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Locked state not checked for disableDuplicate on folders

Mirrors the issue in workflow-item.tsx. A locked folder can still be duplicated because isEffectivelyLocked is not included in the disableDuplicate condition.

Suggested change
disableDuplicate={
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent || isEffectivelyLocked}

!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
}
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
onToggleLock={handleToggleLock}
showLock={!isMixedSelection && selectedFolders.size <= 1}
disableLock={!userPermissions.canAdmin || isLockedByParent}
isLocked={isEffectivelyLocked}
/>

<DeleteModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import { Lock, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
Expand All @@ -25,13 +24,13 @@ import {
useExportSelection,
useExportWorkflow,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderMap } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

interface WorkflowItemProps {
workflow: WorkflowMetadata
Expand Down Expand Up @@ -174,28 +173,21 @@ export function WorkflowItem({
[workflow.id, workspaceId]
)

const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const isActiveWorkflow = workflow.id === activeWorkflowId

const isWorkflowLocked = useWorkflowStore(
useCallback(
(state) => {
if (!isActiveWorkflow) return false
const blockValues = Object.values(state.blocks)
if (blockValues.length === 0) return false
return blockValues.every((block) => block.locked)
},
[isActiveWorkflow]
)
const { data: folderMap } = useFolderMap(workspaceId)
const isEffectivelyLocked = useMemo(
() => isWorkflowEffectivelyLocked(workflow, folderMap ?? {}),
[workflow, folderMap]
)
const isLockedByFolder = isEffectivelyLocked && !workflow.isLocked

const handleToggleLock = useCallback(() => {
if (!isActiveWorkflow) return
const blocks = useWorkflowStore.getState().blocks
const blockIds = getWorkflowLockToggleIds(blocks, !isWorkflowLocked)
if (blockIds.length === 0) return
window.dispatchEvent(new CustomEvent('toggle-workflow-lock', { detail: { blockIds } }))
}, [isActiveWorkflow, isWorkflowLocked])
updateWorkflowMutation.mutate({
workspaceId,
workflowId: workflow.id,
metadata: { isLocked: !workflow.isLocked },
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, workflow.id, workflow.isLocked])

const isEditingRef = useRef(false)

Expand Down Expand Up @@ -359,11 +351,12 @@ export function WorkflowItem({

const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (isEffectivelyLocked) return
e.preventDefault()
e.stopPropagation()
handleStartEdit()
},
[handleStartEdit]
[handleStartEdit, isEffectivelyLocked]
)

const handleClick = useCallback(
Expand Down Expand Up @@ -447,6 +440,9 @@ export function WorkflowItem({
{workflow.name}
</div>
)}
{!isEditing && isEffectivelyLocked && (
<Lock className='h-3 w-3 flex-shrink-0 text-[var(--text-icon)]' />
)}
{!isEditing && <Avatars workflowId={workflow.id} />}
</div>
</div>
Expand Down Expand Up @@ -486,15 +482,15 @@ export function WorkflowItem({
showDuplicate={true}
showExport={true}
showColorChange={!isMixedSelection && selectedWorkflows.size <= 1}
disableRename={!userPermissions.canEdit}
disableRename={!userPermissions.canEdit || isEffectivelyLocked}
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Locked state not checked for disableDuplicate

The PR description explicitly states that "sidebar actions (rename, color, move, delete, duplicate) blocked" for locked workflows. However, disableDuplicate does not check isEffectivelyLocked, so any editor can still duplicate a locked workflow from the context menu.

Suggested change
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection || isEffectivelyLocked}

The same issue exists in folder-item.tsx at line 570:

disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent}

That should include || isEffectivelyLocked as well.

disableExport={!userPermissions.canEdit}
disableColorChange={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteSelection}
disableColorChange={!userPermissions.canEdit || isEffectivelyLocked}
disableDelete={!userPermissions.canEdit || !canDeleteSelection || isEffectivelyLocked}
onToggleLock={handleToggleLock}
showLock={isActiveWorkflow && !isMixedSelection && selectedWorkflows.size <= 1}
disableLock={!userPermissions.canAdmin}
isLocked={isWorkflowLocked}
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
disableLock={!userPermissions.canAdmin || isLockedByFolder}
isLocked={isEffectivelyLocked}
/>

<DeleteModal
Expand Down
Loading
Loading