Skip to content

Commit 0fed07b

Browse files
committed
fix(folders): close TOCTOU race in reorder writes and reject empty batches
Two more real findings, both confirmed against actual code: - performReorderFolders validated every folder/parent at read time but the transactional write only filtered by id, with no re-check that the row (or a target parent) hadn't been concurrently soft-deleted in the window between validation and the write. A folder deleted mid-flight by a concurrent request could still get its sortOrder/parentId mutated, and a parent deleted mid-flight could leave an active folder pointing at a deleted one. Added a write-time deletedAt recheck on each row update (rolling back the whole transaction via a thrown error if any row no longer matches) and a fresh active-parent recheck inside the same transaction before any writes happen. - The reorder contract had no lower bound on `updates`, so an empty array passed validation and crashed the route (validUpdates[0] was undefined) instead of failing cleanly. Added .min(1) to the contract schema, matching the .max(1000) bound already added and the .min(1) precedent already used elsewhere in this codebase for bulk-item arrays. Added regression tests for both.
1 parent 829f3b8 commit 0fed07b

3 files changed

Lines changed: 95 additions & 11 deletions

File tree

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('PUT /api/folders/reorder', () => {
3939
const mockWhere = vi.fn()
4040
const mockLimit = vi.fn()
4141
const mockTxUpdate = vi.fn()
42+
const mockTxSelect = vi.fn()
4243

4344
beforeEach(() => {
4445
vi.clearAllMocks()
@@ -50,10 +51,17 @@ describe('PUT /api/folders/reorder', () => {
5051
mockFrom.mockReturnValue({ where: mockWhere })
5152

5253
mockTxUpdate.mockReturnValue({
53-
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
54+
set: vi.fn().mockReturnValue({
55+
where: vi.fn().mockReturnValue({
56+
returning: vi.fn().mockResolvedValue([{ id: 'folder-1' }]),
57+
}),
58+
}),
59+
})
60+
mockTxSelect.mockReturnValue({
61+
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
5462
})
5563
mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
56-
cb({ update: mockTxUpdate })
64+
cb({ update: mockTxUpdate, select: mockTxSelect })
5765
)
5866
})
5967

@@ -205,6 +213,50 @@ describe('PUT /api/folders/reorder', () => {
205213
expect(mockDb.transaction).not.toHaveBeenCalled()
206214
})
207215

216+
it('rejects an empty updates array', async () => {
217+
// Regression test: an empty `updates` array previously passed contract
218+
// validation and crashed the route (validUpdates[0] was undefined) instead
219+
// of failing cleanly.
220+
const req = createMockRequest('PUT', { workspaceId: 'workspace-123', updates: [] })
221+
222+
const response = await PUT(req)
223+
224+
expect(response.status).toBe(400)
225+
expect(mockDb.transaction).not.toHaveBeenCalled()
226+
})
227+
228+
it('rolls back the whole batch when a folder is concurrently deleted before the write', async () => {
229+
// Regression test: the transactional write previously only filtered by id,
230+
// so a folder soft-deleted between validation and the transaction could
231+
// still have its sortOrder/parentId mutated. The write-time recheck should
232+
// find no matching row and roll back rather than silently applying it.
233+
mockWhere
234+
.mockReturnValueOnce([
235+
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
236+
])
237+
.mockReturnValueOnce([{ id: 'folder-1', parentId: null }])
238+
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
239+
240+
mockTxUpdate.mockReturnValue({
241+
set: vi.fn().mockReturnValue({
242+
where: vi.fn().mockReturnValue({
243+
returning: vi.fn().mockResolvedValue([]), // row no longer matches (deletedAt set)
244+
}),
245+
}),
246+
})
247+
248+
const req = createMockRequest('PUT', {
249+
workspaceId: 'workspace-123',
250+
updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }],
251+
})
252+
253+
const response = await PUT(req)
254+
255+
expect(response.status).toBe(400)
256+
const data = await response.json()
257+
expect(data.error).toBe('One or more folders were not found')
258+
})
259+
208260
it('rejects a batch that would form a cycle', async () => {
209261
mockWhere
210262
.mockReturnValueOnce([

apps/sim/lib/api/contracts/folders.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const reorderFoldersBodySchema = z.object({
8080
parentId: z.string().nullable().optional(),
8181
})
8282
)
83+
.min(1, 'At least one folder must be provided')
8384
.max(1000, 'At most 1000 folders can be reordered at once'),
8485
})
8586

apps/sim/lib/folders/orchestration.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -822,16 +822,47 @@ export async function performReorderFolders(
822822
return { success: false, updated: 0, error: firstParentError.error }
823823
}
824824

825-
await db.transaction(async (tx) => {
826-
for (const update of validUpdates) {
827-
const updateData: Record<string, unknown> = {
828-
sortOrder: update.sortOrder,
829-
updatedAt: new Date(),
825+
try {
826+
await db.transaction(async (tx) => {
827+
// Re-check each target parent is still active inside the transaction --
828+
// assertFolderParentValid above only reads at validation time, so a parent
829+
// concurrently soft-deleted before this transaction opens could otherwise
830+
// leave an active folder pointing at a deleted one.
831+
if (targetParentIds.length > 0) {
832+
const activeParents = await tx
833+
.select({ id: folderTable.id })
834+
.from(folderTable)
835+
.where(and(inArray(folderTable.id, targetParentIds), isNull(folderTable.deletedAt)))
836+
if (activeParents.length !== targetParentIds.length) {
837+
throw new Error('Parent folder not found')
838+
}
830839
}
831-
if (update.parentId !== undefined) updateData.parentId = update.parentId || null
832-
await tx.update(folderTable).set(updateData).where(eq(folderTable.id, update.id))
833-
}
834-
})
840+
841+
for (const update of validUpdates) {
842+
const updateData: Record<string, unknown> = {
843+
sortOrder: update.sortOrder,
844+
updatedAt: new Date(),
845+
}
846+
if (update.parentId !== undefined) updateData.parentId = update.parentId || null
847+
848+
// Re-check deletedAt at write time (not just the validation read above) --
849+
// without this, a folder concurrently soft-deleted between validation and
850+
// this transaction could still have its sortOrder/parentId mutated. Throwing
851+
// rolls back the whole transaction rather than silently applying a partial
852+
// batch, matching this function's fail-whole-batch behavior on other errors.
853+
const [updated] = await tx
854+
.update(folderTable)
855+
.set(updateData)
856+
.where(and(eq(folderTable.id, update.id), isNull(folderTable.deletedAt)))
857+
.returning({ id: folderTable.id })
858+
if (!updated) {
859+
throw new Error('One or more folders were not found')
860+
}
861+
}
862+
})
863+
} catch (error) {
864+
return { success: false, updated: 0, error: toError(error).message }
865+
}
835866

836867
return { success: true, updated: validUpdates.length }
837868
}

0 commit comments

Comments
 (0)