Skip to content

Commit 7b8b7fb

Browse files
committed
fix(desktop): admit browser download saves before reading the body
PUT /api/desktop/tool/file buffered up to 100 MiB of the request body before the use case checked that the tool call exists, is a running browser_save_download claimed by the desktop, and belongs to the caller's run, so any signed-in user could force large reads with bogus toolCallIds. Add admitBrowserDownloadSave (same operation and binding resolution as saveBrowserDownload, no claim or audit), following the existing admitCreateWorkspaceFile pattern, and run it before the body read. The save use case still re-validates and claims atomically after the read, so a failed or oversized upload never burns the single-use claim.
1 parent 60d0e1c commit 7b8b7fb

4 files changed

Lines changed: 114 additions & 29 deletions

File tree

‎apps/sim/app/api/desktop/tool/file/route.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { OrchestrationError } from '@/lib/core/orchestration/types'
88

9-
const { mockRead, mockSave } = vi.hoisted(() => ({ mockRead: vi.fn(), mockSave: vi.fn() }))
9+
const { mockAdmit, mockRead, mockSave } = vi.hoisted(() => ({
10+
mockAdmit: vi.fn(),
11+
mockRead: vi.fn(),
12+
mockSave: vi.fn(),
13+
}))
1014

1115
vi.mock('@/lib/browser-agent/application/browser-file-transfer', () => ({
16+
admitBrowserDownloadSave: mockAdmit,
1217
readBrowserUploadFile: {
1318
operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' },
1419
execute: mockRead,
@@ -43,6 +48,7 @@ describe('/api/desktop/tool/file', () => {
4348
beforeEach(() => {
4449
vi.clearAllMocks()
4550
mockGetSession.mockResolvedValue(session)
51+
mockAdmit.mockResolvedValue(undefined)
4652
mockRead.mockResolvedValue({ file: { name: 'Q3 plan.pdf' }, content: Buffer.from('%PDF') })
4753
mockSave.mockResolvedValue({
4854
file: { name: 'report.csv', size: 3, folderPath: null, vfsNamespace: 'files' },
@@ -92,6 +98,21 @@ describe('/api/desktop/tool/file', () => {
9298
put('toolCallId=c&name=a.csv', new Uint8Array(1), { 'content-length': String(2 ** 31) })
9399
)
94100
expect(oversized.status).toBe(413)
101+
expect(mockAdmit).not.toHaveBeenCalled()
102+
expect(mockSave).not.toHaveBeenCalled()
103+
})
104+
105+
it('refuses an unadmitted save without reading its body', async () => {
106+
mockAdmit.mockRejectedValueOnce(
107+
new OrchestrationError('not_found', 'Browser file transfer not found')
108+
)
109+
const request = put('toolCallId=unknown&name=a.csv', new Uint8Array([1, 2, 3]))
110+
111+
const res = await PUT(request)
112+
113+
expect(res.status).toBe(404)
114+
expect(mockAdmit).toHaveBeenCalledWith(principal, { toolCallId: 'unknown', name: 'a.csv' })
115+
expect(request.bodyUsed).toBe(false)
95116
expect(mockSave).not.toHaveBeenCalled()
96117
})
97118

‎apps/sim/app/api/desktop/tool/file/route.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/api/server/routes'
1414
import { withRequestId } from '@/lib/api/server/routes/request-id'
1515
import {
16+
admitBrowserDownloadSave,
1617
readBrowserUploadFile,
1718
saveBrowserDownload,
1819
} from '@/lib/browser-agent/application/browser-file-transfer'
@@ -52,8 +53,9 @@ export const POST = defineInternalBinaryRoute({
5253
* PUT /api/desktop/tool/file?toolCallId=…&name=…
5354
*
5455
* Raw `withRouteHandler`: the body is the download's bytes, so it is admitted by declared length
55-
* and read under a byte ceiling only after the session is authenticated, then handed to the
56-
* application use case that binds it to its claimed `browser_save_download` call.
56+
* and read under a byte ceiling only after the session is authenticated and the application has
57+
* admitted the caller's claimed `browser_save_download` call, then handed to the use case that
58+
* re-validates and claims that call.
5759
*/
5860
export const PUT = withRouteHandler(async (request: NextRequest) => {
5961
let principal
@@ -73,6 +75,13 @@ export const PUT = withRouteHandler(async (request: NextRequest) => {
7375
}
7476
const parsed = await parseRequest(saveBrowserDownloadContract, request, {})
7577
if (!parsed.success) return parsed.response
78+
const { toolCallId, name } = parsed.data.query
79+
80+
try {
81+
await admitBrowserDownloadSave(principal, { toolCallId, name })
82+
} catch (error) {
83+
return saveErrorResponse(error)
84+
}
7685

7786
let content: Buffer
7887
try {
@@ -93,16 +102,21 @@ export const PUT = withRouteHandler(async (request: NextRequest) => {
93102
try {
94103
const { file } = await saveBrowserDownload.execute({
95104
principal,
96-
input: { toolCallId: parsed.data.query.toolCallId, name: parsed.data.query.name, content },
105+
input: { toolCallId, name, content },
97106
request,
98107
})
99108
return NextResponse.json({ path: workspaceFileVfsPath(file), name: file.name, size: file.size })
100109
} catch (error) {
101-
const response = internalFileErrorPolicies.concealContentAuthorization.project(error)
102-
if (!response) throw error
103-
return NextResponse.json(withRequestId(response.body), {
104-
status: response.status,
105-
headers: response.headers,
106-
})
110+
return saveErrorResponse(error)
107111
}
108112
})
113+
114+
/** Projects a typed download-save failure, rethrowing anything the policy does not classify. */
115+
function saveErrorResponse(error: unknown): NextResponse {
116+
const response = internalFileErrorPolicies.concealContentAuthorization.project(error)
117+
if (!response) throw error
118+
return NextResponse.json(withRequestId(response.body), {
119+
status: response.status,
120+
headers: response.headers,
121+
})
122+
}

‎apps/sim/lib/browser-agent/application/browser-file-transfer.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({
4747
}))
4848

4949
import {
50+
admitBrowserDownloadSave,
5051
readBrowserUploadFile,
5152
saveBrowserDownload,
5253
} from '@/lib/browser-agent/application/browser-file-transfer'
@@ -231,6 +232,28 @@ describe('browser file transfer use cases', () => {
231232
expect(mocks.createFile).not.toHaveBeenCalled()
232233
})
233234

235+
it('admits a download save without claiming, creating, or auditing, and refuses a foreign run', async () => {
236+
mocks.getAsyncToolCall.mockResolvedValue(
237+
claimedCall('browser_save_download', { downloadId: 'd1' })
238+
)
239+
await expect(
240+
admitBrowserDownloadSave(principal, { toolCallId: 'call-1', name: 'report.csv' })
241+
).resolves.toBeUndefined()
242+
243+
mocks.getRunSegment.mockResolvedValue({
244+
id: 'run-1',
245+
userId: 'someone-else',
246+
workspaceId: 'workspace-1',
247+
chatId: 'chat-1',
248+
})
249+
await expect(
250+
admitBrowserDownloadSave(principal, { toolCallId: 'call-1', name: 'report.csv' })
251+
).rejects.toMatchObject({ code: 'not_found' })
252+
expect(mocks.claimDownload).not.toHaveBeenCalled()
253+
expect(mocks.createFile).not.toHaveBeenCalled()
254+
expect(mocks.recordAudit).not.toHaveBeenCalled()
255+
})
256+
234257
it('falls back to the downloaded file name and never saves for an upload call', async () => {
235258
mocks.getAsyncToolCall.mockResolvedValueOnce(
236259
claimedCall('browser_save_download', { downloadId: 'd1' })

‎apps/sim/lib/browser-agent/application/browser-file-transfer.ts‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,39 +103,66 @@ export const readBrowserUploadFile = defineAuthorizedWorkspaceFileUseCase({
103103
},
104104
})
105105

106-
export interface SaveBrowserDownloadInput {
106+
export interface AdmitBrowserDownloadSaveInput {
107107
toolCallId: string
108108
/** The download's file name on this machine; the call's own `name` argument wins. */
109109
name: string
110+
}
111+
112+
export interface SaveBrowserDownloadInput extends AdmitBrowserDownloadSaveInput {
110113
content: Buffer
111114
}
112115

116+
async function resolveBrowserDownloadSaveContext({
117+
principal,
118+
input,
119+
}: {
120+
principal: Principal
121+
input: AdmitBrowserDownloadSaveInput
122+
}) {
123+
const binding = await loadBrowserFileTransferBinding(
124+
principal,
125+
input.toolCallId,
126+
'browser_save_download'
127+
)
128+
const workspace = await loadActiveWorkspaceContext(binding.workspaceId)
129+
if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
130+
const requestedName = binding.args.name
131+
return {
132+
...workspace,
133+
name:
134+
typeof requestedName === 'string' && requestedName.trim() ? requestedName.trim() : input.name,
135+
}
136+
}
137+
138+
const admitBrowserDownloadSaveUseCase = defineAuthorizedWorkspaceFileUseCase({
139+
operation: fileOperations.create,
140+
resolveContext: resolveBrowserDownloadSaveContext,
141+
async execute() {},
142+
})
143+
144+
/**
145+
* Admits a `browser_save_download` upload before its body is read, so a caller without a claimed,
146+
* running call of its own is refused without buffering the download. {@link saveBrowserDownload}
147+
* re-validates and claims the call atomically after the read.
148+
*/
149+
export async function admitBrowserDownloadSave(
150+
principal: Principal,
151+
input: AdmitBrowserDownloadSaveInput
152+
): Promise<void> {
153+
await admitBrowserDownloadSaveUseCase.execute({ principal, input })
154+
}
155+
113156
/** Stores a completed browser download as a workspace file for a claimed `browser_save_download` call. */
114157
export const saveBrowserDownload = defineAuthorizedWorkspaceFileUseCase({
115158
operation: fileOperations.create,
116-
async resolveContext({
159+
resolveContext: ({
117160
principal,
118161
input,
119162
}: {
120163
principal: Principal
121164
input: SaveBrowserDownloadInput
122-
}) {
123-
const binding = await loadBrowserFileTransferBinding(
124-
principal,
125-
input.toolCallId,
126-
'browser_save_download'
127-
)
128-
const workspace = await loadActiveWorkspaceContext(binding.workspaceId)
129-
if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
130-
const requestedName = binding.args.name
131-
return {
132-
...workspace,
133-
name:
134-
typeof requestedName === 'string' && requestedName.trim()
135-
? requestedName.trim()
136-
: input.name,
137-
}
138-
},
165+
}) => resolveBrowserDownloadSaveContext({ principal, input }),
139166
async execute({ principal, input, context }) {
140167
if (!(await claimBrowserDownloadSave(input.toolCallId))) {
141168
throw new OrchestrationError(

0 commit comments

Comments
 (0)