Skip to content

Commit c86d2fc

Browse files
fix(api): check folder containment before lock state; reject malformed version cursors
assertFolderMutable walks a folder's ancestor chain without filtering on workspace, so inspecting it before containment let a caller tell a locked folder in someone else's workspace (423) from a nonexistent one (400). Create and update now assert containment first, matching the ordering import-workflow.ts already uses. A version cursor that decodes to JSON without a numeric version filtered every row out and returned an empty page with nextCursor null, which reads as a clean end-of-list. Malformed cursors are now a 400.
1 parent bfec5b8 commit c86d2fc

6 files changed

Lines changed: 140 additions & 3 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ const {
1717
mockPerformDeleteWorkflow,
1818
mockAssertWorkflowMutable,
1919
mockAssertFolderMutable,
20+
mockAssertFolderInWorkspace,
2021
WorkflowLockedErrorMock,
2122
FolderLockedErrorMock,
23+
FolderNotFoundErrorMock,
2224
} = vi.hoisted(() => ({
2325
mockCheckRateLimit: vi.fn(),
2426
mockResolveWorkspaceAccess: vi.fn(),
@@ -27,12 +29,16 @@ const {
2729
mockPerformDeleteWorkflow: vi.fn(),
2830
mockAssertWorkflowMutable: vi.fn(),
2931
mockAssertFolderMutable: vi.fn(),
32+
mockAssertFolderInWorkspace: vi.fn(),
3033
WorkflowLockedErrorMock: class WorkflowLockedError extends Error {
3134
status = 423
3235
},
3336
FolderLockedErrorMock: class FolderLockedError extends Error {
3437
status = 423
3538
},
39+
FolderNotFoundErrorMock: class FolderNotFoundError extends Error {
40+
status = 400
41+
},
3642
}))
3743

3844
vi.mock('@/app/api/v1/middleware', () => ({
@@ -49,8 +55,10 @@ vi.mock('@sim/platform-authz/workflow', () => ({
4955
getActiveWorkflowRecord: mockGetActiveWorkflowRecord,
5056
assertWorkflowMutable: mockAssertWorkflowMutable,
5157
assertFolderMutable: mockAssertFolderMutable,
58+
assertFolderInWorkspace: mockAssertFolderInWorkspace,
5259
WorkflowLockedError: WorkflowLockedErrorMock,
5360
FolderLockedError: FolderLockedErrorMock,
61+
FolderNotFoundError: FolderNotFoundErrorMock,
5462
}))
5563

5664
vi.mock('@/lib/workflows/input-format', () => ({
@@ -139,6 +147,7 @@ describe('PATCH /api/v2/workflows/[id]', () => {
139147
mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
140148
mockAssertWorkflowMutable.mockResolvedValue(undefined)
141149
mockAssertFolderMutable.mockResolvedValue(undefined)
150+
mockAssertFolderInWorkspace.mockResolvedValue(undefined)
142151
mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED })
143152
})
144153

@@ -196,6 +205,40 @@ describe('PATCH /api/v2/workflows/[id]', () => {
196205
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
197206
})
198207

208+
it('400s a folder outside the workspace without ever reading its lock state', async () => {
209+
mockAssertFolderInWorkspace.mockRejectedValue(
210+
new FolderNotFoundErrorMock('Target folder not found')
211+
)
212+
const res = await callPatch({ folderId: 'fld-other-workspace' })
213+
214+
expect(res.status).toBe(400)
215+
expect((await res.json()).error.code).toBe('BAD_REQUEST')
216+
// Containment runs first, so a locked foreign folder cannot be told apart
217+
// from a nonexistent one by its status code.
218+
expect(mockAssertFolderMutable).not.toHaveBeenCalled()
219+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
220+
})
221+
222+
it('checks folder containment against the workflow workspace before mutability', async () => {
223+
const order: string[] = []
224+
mockAssertFolderInWorkspace.mockImplementation(async () => {
225+
order.push('containment')
226+
})
227+
mockAssertFolderMutable.mockImplementation(async () => {
228+
order.push('mutability')
229+
})
230+
231+
await callPatch({ folderId: 'fld-1' })
232+
233+
expect(order).toEqual(['containment', 'mutability'])
234+
expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1')
235+
})
236+
237+
it('skips the containment check on a rename that does not move the workflow', async () => {
238+
await callPatch({ name: 'Support Agent v2' })
239+
expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled()
240+
})
241+
199242
it('409s when the target name is taken in the destination folder', async () => {
200243
mockPerformUpdateWorkflow.mockResolvedValue({
201244
success: false,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { db } from '@sim/db'
22
import { workflowBlocks } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import {
5+
assertFolderInWorkspace,
56
assertFolderMutable,
67
assertWorkflowMutable,
78
FolderLockedError,
9+
FolderNotFoundError,
810
getActiveWorkflowRecord,
911
WorkflowLockedError,
1012
} from '@sim/platform-authz/workflow'
@@ -140,6 +142,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
140142
)
141143
if (access) return v2Error('NOT_FOUND', 'Workflow not found')
142144

145+
/**
146+
* Ownership before lock state: `assertFolderMutable` walks the folder's
147+
* ancestor chain without filtering on workspace, so checking it first would
148+
* let a caller distinguish a locked folder in someone else's workspace
149+
* (423) from one that simply does not exist (400).
150+
*/
151+
if (folderId) await assertFolderInWorkspace(folderId, workflowData.workspaceId)
143152
await assertWorkflowMutable(id)
144153
if (folderId !== undefined) await assertFolderMutable(folderId)
145154

@@ -180,6 +189,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
180189

181190
return v2Data(item, { rateLimit })
182191
} catch (error) {
192+
if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message)
183193
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
184194
return v2Error('LOCKED', error.message)
185195
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,23 @@ describe('GET /api/v2/workflows/[id]/versions', () => {
160160
expect(mockListWorkflowVersions).toHaveBeenCalledWith('wf-1')
161161
})
162162

163+
it('400s a structurally invalid cursor instead of silently truncating the list', async () => {
164+
// Decodes to valid JSON with no numeric `version` — the shape that would
165+
// otherwise filter every row out and report a clean end-of-list.
166+
const bogus = Buffer.from(JSON.stringify({ offset: 2 })).toString('base64')
167+
const res = await callGet(`?cursor=${encodeURIComponent(bogus)}`)
168+
169+
expect(res.status).toBe(400)
170+
expect((await res.json()).error.code).toBe('BAD_REQUEST')
171+
expect(mockListWorkflowVersions).not.toHaveBeenCalled()
172+
})
173+
174+
it('400s a cursor that is not decodable at all', async () => {
175+
const res = await callGet('?cursor=not-a-cursor')
176+
expect(res.status).toBe(400)
177+
expect(mockListWorkflowVersions).not.toHaveBeenCalled()
178+
})
179+
163180
it('pages with a version-keyed cursor', async () => {
164181
const first = await callGet('?limit=2')
165182
const firstBody = await first.json()

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,20 @@ export const GET = withRouteHandler(
6464
const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId)
6565
if (access) return v2Error('NOT_FOUND', 'Workflow not found')
6666

67+
/**
68+
* A cursor that decodes to anything other than a version number is
69+
* rejected rather than ignored: comparing every row against a missing
70+
* `version` yields an empty page with `nextCursor: null`, which reads to
71+
* the caller as a clean end-of-list while versions are still pending.
72+
*/
73+
const after = cursor ? decodeCursor<WorkflowVersionCursor>(cursor) : null
74+
if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) {
75+
return v2Error('BAD_REQUEST', 'Invalid cursor')
76+
}
77+
6778
const { versions: rows } = await listWorkflowVersions(id)
6879

69-
const cursorData = cursor ? decodeCursor<WorkflowVersionCursor>(cursor) : null
70-
const remaining = cursorData ? rows.filter((row) => row.version < cursorData.version) : rows
80+
const remaining = after ? rows.filter((row) => row.version < after.version) : rows
7181

7282
const hasMore = remaining.length > limit
7383
const page = remaining.slice(0, limit)

apps/sim/app/api/v2/workflows/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@ const {
1313
mockResolveWorkspaceAccess,
1414
mockPerformCreateWorkflow,
1515
mockAssertFolderMutable,
16+
mockAssertFolderInWorkspace,
1617
FolderLockedErrorMock,
18+
FolderNotFoundErrorMock,
1719
} = vi.hoisted(() => ({
1820
mockCheckRateLimit: vi.fn(),
1921
mockResolveWorkspaceAccess: vi.fn(),
2022
mockPerformCreateWorkflow: vi.fn(),
2123
mockAssertFolderMutable: vi.fn(),
24+
mockAssertFolderInWorkspace: vi.fn(),
2225
FolderLockedErrorMock: class FolderLockedError extends Error {
2326
status = 423
2427
},
28+
FolderNotFoundErrorMock: class FolderNotFoundError extends Error {
29+
status = 400
30+
},
2531
}))
2632

2733
vi.mock('@/app/api/v1/middleware', () => ({
@@ -35,7 +41,9 @@ vi.mock('@/lib/workflows/orchestration', () => ({
3541

3642
vi.mock('@sim/platform-authz/workflow', () => ({
3743
assertFolderMutable: mockAssertFolderMutable,
44+
assertFolderInWorkspace: mockAssertFolderInWorkspace,
3845
FolderLockedError: FolderLockedErrorMock,
46+
FolderNotFoundError: FolderNotFoundErrorMock,
3947
}))
4048

4149
vi.mock('@/app/api/v2/lib/gate', () => ({
@@ -98,6 +106,7 @@ describe('POST /api/v2/workflows', () => {
98106
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
99107
mockResolveWorkspaceAccess.mockResolvedValue(null)
100108
mockAssertFolderMutable.mockResolvedValue(undefined)
109+
mockAssertFolderInWorkspace.mockResolvedValue(undefined)
101110
mockPerformCreateWorkflow.mockResolvedValue({ success: true, workflow: CREATED })
102111
})
103112

@@ -157,6 +166,41 @@ describe('POST /api/v2/workflows', () => {
157166
expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
158167
})
159168

169+
it('400s a folder outside the workspace without ever reading its lock state', async () => {
170+
mockAssertFolderInWorkspace.mockRejectedValue(
171+
new FolderNotFoundErrorMock('Target folder not found')
172+
)
173+
const res = await callPost({ ...VALID_BODY, folderId: 'fld-other-workspace' })
174+
175+
expect(res.status).toBe(400)
176+
expect((await res.json()).error.code).toBe('BAD_REQUEST')
177+
// Containment runs first, so a locked foreign folder cannot be told apart
178+
// from a nonexistent one by its status code.
179+
expect(mockAssertFolderMutable).not.toHaveBeenCalled()
180+
expect(mockPerformCreateWorkflow).not.toHaveBeenCalled()
181+
})
182+
183+
it('checks folder containment before mutability', async () => {
184+
const order: string[] = []
185+
mockAssertFolderInWorkspace.mockImplementation(async () => {
186+
order.push('containment')
187+
})
188+
mockAssertFolderMutable.mockImplementation(async () => {
189+
order.push('mutability')
190+
})
191+
192+
await callPost({ ...VALID_BODY, folderId: 'fld-1' })
193+
194+
expect(order).toEqual(['containment', 'mutability'])
195+
expect(mockAssertFolderInWorkspace).toHaveBeenCalledWith('fld-1', 'workspace-1')
196+
})
197+
198+
it('skips the containment check when no folder is supplied', async () => {
199+
await callPost(VALID_BODY)
200+
expect(mockAssertFolderInWorkspace).not.toHaveBeenCalled()
201+
expect(mockAssertFolderMutable).toHaveBeenCalledWith(null)
202+
})
203+
160204
it('409s when the name is already taken in the target folder', async () => {
161205
mockPerformCreateWorkflow.mockResolvedValue({
162206
success: false,

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { db } from '@sim/db'
22
import { workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
4+
import {
5+
assertFolderInWorkspace,
6+
assertFolderMutable,
7+
FolderLockedError,
8+
FolderNotFoundError,
9+
} from '@sim/platform-authz/workflow'
510
import { getErrorMessage } from '@sim/utils/errors'
611
import { generateId } from '@sim/utils/id'
712
import { and, asc, eq, gt, isNull, or } from 'drizzle-orm'
@@ -180,6 +185,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
180185
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
181186
if (access) return v2WorkspaceAccessError(access)
182187

188+
/**
189+
* Ownership before lock state: `assertFolderMutable` walks the folder's
190+
* ancestor chain without filtering on workspace, so checking it first would
191+
* let a caller distinguish a locked folder in someone else's workspace
192+
* (423) from one that simply does not exist (400).
193+
*/
194+
if (folderId) await assertFolderInWorkspace(folderId, workspaceId)
183195
await assertFolderMutable(folderId ?? null)
184196

185197
const result = await performCreateWorkflow({
@@ -212,6 +224,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
212224

213225
return v2Data(item, { rateLimit, status: 201 })
214226
} catch (error) {
227+
if (error instanceof FolderNotFoundError) return v2Error('BAD_REQUEST', error.message)
215228
if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message)
216229

217230
logger.error(`[${requestId}] Workflow create error`, {

0 commit comments

Comments
 (0)