Skip to content

Commit a76b928

Browse files
authored
fix(security): bind copilot chat attachment keys to their owner (#6179)
* fix(security): bind copilot chat attachment keys to their owner `POST /api/copilot/chat` accepted a client-supplied `fileAttachments[].key` and passed it straight into `trackChatUpload`, which wrote it into the `workspace_files` ownership binding with no permission check and no key-ownership validation. That binding is what `verifyFileAccess` and the Files feature resolve authorization from, so any workspace member — including read-only — could hand in another member's key and have their file re-parented to a private chat: removed from the Files listing, from folders and from download-by-id, and destroyable through the chat-delete FK cascade. The sibling register route already enforced these invariants; the copilot path did not. - `trackChatUpload` now rejects keys that do not address the target workspace, only re-links a chat-upload row the caller already owns (matched by row id, not by raw key), and only mints a new binding when the key has no prior record at all — including soft-deleted ones, which the partial active-key unique index would otherwise let it insert over. - Minting a new binding verifies the object exists in storage, matching `registerUploadedWorkspaceFile`. - `buildCopilotRequestPayload` gates attachment tracking on write/admin, the same grant the upload routes that issue these keys require. * fix(security): make the chat-upload ownership check atomic with its write The ownership lookup and the binding UPDATE were separate statements, and the UPDATE matched on the captured row id alone. A concurrent `materialize_file` sets `context='workspace'` and clears `chatId` on that same row, so the tracking write still matched and dragged the saved file back into chat scope — hiding it from every workspace-file listing and re-exposing it to the chat-delete cascade, with materialize's storage-usage increment left stranded. Re-assert every ownership predicate in the UPDATE so the statement is itself the atomic check. The lookup now only decides UPDATE-vs-INSERT and is no longer load-bearing for authorization, which also makes the existing `updated.length === 0` fail-closed branch correct rather than dead. * fix(uploads): tolerate transient storage probes and reject unowned keys with 403 Follow-ups from a backward-compatibility audit of the attachment-key hardening. The existence probe is hygiene, not authorization — the key-format and no-prior-record guards already carry that, and a binding to a nonexistent object grants nothing readable. But `headObject` rethrows non-404 provider errors, so a transient 5xx or throttle would drop a legitimate attachment. Only a definitive not-found now rejects; a thrown error logs and proceeds on the ownership guards. This path is reached solely by >50MB multipart uploads, the one flow that persists no metadata row at upload time. The stage route mapped an ownership rejection to a 500. It is a client error; return 403 instead. * fix(security): compare-and-swap the chat binding on chat uploads Two overlapping chat requests could both observe the same claimable row with `chat_id IS NULL` and both satisfy the update predicate, so the later write silently moved the upload to its own chat — taking over the delete-cascade lifecycle of a file the first chat had already bound. Scope the update to `chat_id IS NULL OR chat_id = <target>` so the statement is a compare-and-swap: the loser matches zero rows and fails closed. The resolver applies the same rule so the update-vs-insert decision stays coherent. This also makes an upload bind to exactly one chat, matching the 409 the sibling `local-files/stage` route already returns for the same case; verified no client flow relinks a key across chats (drafts are per-chat, every retry path replays under a pinned chat id, forking copies blobs to fresh keys). * refactor(copilot): gate attachment tracking with the shared permission predicate `permissionSatisfies` is the documented single source of truth for permission comparisons and exists to replace hand-written `=== 'admin' || === 'write'` ladders. `userPermission` is typed `string` for legacy reasons, so narrow it with `isPermissionType` first — an unrecognized value fails the gate instead of ranking below every level. Behavior is unchanged for all three levels. Imported from the dependency-free `/predicates` subpath rather than `/workspace`, which would pull `@sim/db` onto the chat request path.
1 parent e90378a commit a76b928

5 files changed

Lines changed: 507 additions & 32 deletions

File tree

apps/sim/app/api/mothership/local-files/stage/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
} from '@/lib/copilot/request/http'
1414
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
1515
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16-
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
16+
import {
17+
trackChatUpload,
18+
WorkspaceFileKeyOwnershipError,
19+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1720
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1821

1922
const logger = createLogger('StageLocalFileUploadAPI')
@@ -95,6 +98,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9598
uploadPath: `uploads/${encodeVfsSegment(displayName)}`,
9699
})
97100
} catch (error) {
101+
if (error instanceof WorkspaceFileKeyOwnershipError) {
102+
// The caller supplied a key they may not bind — a client error, not ours.
103+
logger.warn('Rejected chat upload staging for an unowned storage key', {
104+
error: error.message,
105+
})
106+
return NextResponse.json({ error: 'Storage key is not available' }, { status: 403 })
107+
}
98108
logger.error('Failed to stage local file upload', error)
99109
return createInternalServerErrorResponse('Failed to stage local file upload')
100110
}

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import { workflowsUtilsMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription } = vi.hoisted(() => ({
8-
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
9-
mockGetHighestPrioritySubscription: vi.fn(),
10-
}))
7+
const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription, mockTrackChatUpload } =
8+
vi.hoisted(() => ({
9+
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
10+
mockGetHighestPrioritySubscription: vi.fn(),
11+
mockTrackChatUpload: vi.fn(),
12+
}))
1113

1214
vi.mock('@/lib/billing/core/subscription', () => ({
1315
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
@@ -104,6 +106,10 @@ vi.mock('@/tools/params', () => ({
104106
createUserToolSchema: mockCreateUserToolSchema,
105107
}))
106108

109+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
110+
trackChatUpload: mockTrackChatUpload,
111+
}))
112+
107113
import {
108114
buildCopilotRequestPayload,
109115
buildIntegrationToolSchemas,
@@ -209,6 +215,53 @@ describe('buildIntegrationToolSchemas', () => {
209215
describe('buildCopilotRequestPayload', () => {
210216
beforeEach(() => {
211217
vi.clearAllMocks()
218+
mockTrackChatUpload.mockResolvedValue({ displayName: 'payroll.xlsx' })
219+
})
220+
221+
describe('file attachment tracking', () => {
222+
const attachmentParams = {
223+
message: 'hi',
224+
userId: 'mallory',
225+
userMessageId: 'msg-1',
226+
mode: 'agent',
227+
model: 'claude-opus-4-8',
228+
workspaceId: 'ws-1',
229+
chatId: 'chat-1',
230+
fileAttachments: [
231+
{ id: 'a1', key: 'workspace/ws-1/1731000000000-ab12cd34-payroll.xlsx', size: 1 },
232+
],
233+
}
234+
235+
/**
236+
* Tracking writes `workspace_files` rows. A read-only member reaching the
237+
* chat endpoint must not gain that write through an attachment.
238+
*/
239+
it.each(['read', undefined])('does not track attachments for permission %s', async (perm) => {
240+
await buildCopilotRequestPayload(
241+
{ ...attachmentParams, userPermission: perm },
242+
{ selectedModel: 'claude-opus-4-8' }
243+
)
244+
245+
expect(mockTrackChatUpload).not.toHaveBeenCalled()
246+
})
247+
248+
it.each(['write', 'admin'])('tracks attachments for permission %s', async (perm) => {
249+
await buildCopilotRequestPayload(
250+
{ ...attachmentParams, userPermission: perm },
251+
{ selectedModel: 'claude-opus-4-8' }
252+
)
253+
254+
expect(mockTrackChatUpload).toHaveBeenCalledWith(
255+
'ws-1',
256+
'mallory',
257+
'chat-1',
258+
'workspace/ws-1/1731000000000-ab12cd34-payroll.xlsx',
259+
expect.anything(),
260+
expect.anything(),
261+
1,
262+
'msg-1'
263+
)
264+
})
212265
})
213266

214267
it('passes workspaceContext through to the Go request payload', async () => {

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BrowserKnownSession } from '@sim/browser-protocol'
22
import { createLogger } from '@sim/logger'
3+
import { isPermissionType, permissionSatisfies } from '@sim/platform-authz/predicates'
34
import { toError } from '@sim/utils/errors'
45
import { LRUCache } from 'lru-cache'
56
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
@@ -333,10 +334,25 @@ export async function buildCopilotRequestPayload(
333334
const effectiveMode = mode === 'agent' ? 'build' : mode
334335
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
335336

336-
// Track uploaded files in the DB and build context tags instead of base64 inlining
337+
// Track uploaded files in the DB and build context tags instead of base64 inlining.
338+
// Tracking writes `workspace_files` rows, so it needs the same write grant the
339+
// upload routes that issue these keys already require — reaching the chat
340+
// endpoint with `read` must not confer a file-write capability.
337341
const uploadContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = []
342+
// `userPermission` is typed `string` for legacy reasons, so narrow it before
343+
// comparing — an unrecognized value must fail the gate, not rank below it.
344+
const canWriteWorkspaceFiles =
345+
isPermissionType(params.userPermission) && permissionSatisfies(params.userPermission, 'write')
338346
if (chatId && params.workspaceId && fileAttachments && fileAttachments.length > 0) {
339-
for (const f of fileAttachments) {
347+
if (!canWriteWorkspaceFiles) {
348+
logger.warn('Dropping chat file attachments without workspace write access', {
349+
chatId,
350+
workspaceId: params.workspaceId,
351+
attachmentCount: fileAttachments.length,
352+
})
353+
}
354+
const trackableAttachments = canWriteWorkspaceFiles ? fileAttachments : []
355+
for (const f of trackableAttachments) {
340356
const filename = (f.filename ?? f.name ?? 'file') as string
341357
const mediaType = (f.media_type ?? f.mimeType ?? 'application/octet-stream') as string
342358
try {

0 commit comments

Comments
 (0)