Skip to content

Commit 9d8756c

Browse files
committed
fix(files): require chat ownership for output file access; duplicate error toast
1 parent 6e48db6 commit 9d8756c

5 files changed

Lines changed: 134 additions & 11 deletions

File tree

apps/sim/app/api/files/authorization.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,69 @@ describe('public-context access (profile-pictures / og-images / workspace-logos)
210210
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
211211
})
212212
})
213+
214+
/**
215+
* Chat-scoped `output` files belong to a private chat: workspace membership
216+
* alone must NOT grant access — the caller must also be the row's owner.
217+
* These lock in the fix for the member-reads-another-member's-outputs leak,
218+
* on both the explicit-context path (view route) and the inferred-workspace
219+
* path (serve route — output keys share the workspace key shape).
220+
*/
221+
describe('chat output file ownership (verifyFileAccess)', () => {
222+
const OUTPUT_KEY = 'workspace/ws-1/1780000000-abcd-generated-image.png'
223+
const outputRow = {
224+
workspaceId: 'ws-1',
225+
userId: 'owner-user',
226+
context: 'output',
227+
deletedAt: null,
228+
}
229+
230+
beforeEach(() => {
231+
vi.clearAllMocks()
232+
})
233+
234+
it('denies a workspace member who is not the owning chat user (explicit output context)', async () => {
235+
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
236+
mockGetUserEntityPermissions.mockResolvedValue('read')
237+
238+
await expect(verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'output')).resolves.toBe(
239+
false
240+
)
241+
})
242+
243+
it('denies a non-owner member when context is inferred as workspace (serve path)', async () => {
244+
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
245+
mockGetUserEntityPermissions.mockResolvedValue('read')
246+
247+
await expect(
248+
verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'workspace')
249+
).resolves.toBe(false)
250+
})
251+
252+
it('grants the owning chat user with workspace membership', async () => {
253+
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
254+
mockGetUserEntityPermissions.mockResolvedValue('read')
255+
256+
await expect(verifyFileAccess(OUTPUT_KEY, 'owner-user', undefined, 'output')).resolves.toBe(
257+
true
258+
)
259+
})
260+
261+
it('still denies the owner without workspace membership (left the workspace)', async () => {
262+
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
263+
mockGetUserEntityPermissions.mockResolvedValue(null)
264+
265+
await expect(verifyFileAccess(OUTPUT_KEY, 'owner-user', undefined, 'output')).resolves.toBe(
266+
false
267+
)
268+
})
269+
270+
it('leaves plain workspace files on membership-only auth', async () => {
271+
mockGetFileMetadataByKey.mockResolvedValue({ ...outputRow, context: 'workspace' })
272+
mockGetUserEntityPermissions.mockResolvedValue('read')
273+
274+
await expect(
275+
verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'workspace')
276+
).resolves.toBe(true)
277+
})
278+
})

apps/sim/app/api/files/authorization.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ function workspacePermissionSatisfies(
6060
return permissionSatisfies(permission, requireWrite ? 'write' : 'read')
6161
}
6262

63+
/**
64+
* Chat-scoped `output` rows belong to a PRIVATE chat: workspace membership
65+
* alone is not enough — the caller must also be the row's owner (stamped from
66+
* the chat owner at creation and re-stamped on fork/duplicate). Without this,
67+
* any workspace member who learns a file id or storage key could read another
68+
* member's agent outputs, even though listing outputs requires chat ownership.
69+
* Non-output contexts pass through unchanged.
70+
*/
71+
function outputOwnershipSatisfied(
72+
record: { context: string; uploadedBy: string },
73+
userId: string
74+
): boolean {
75+
return record.context !== 'output' || record.uploadedBy === userId
76+
}
77+
6378
/**
6479
* Lookup workspace file by storage key from database
6580
* @param key Storage key to lookup
@@ -68,15 +83,17 @@ function workspacePermissionSatisfies(
6883
async function lookupWorkspaceFileByKey(
6984
key: string,
7085
options?: { includeDeleted?: boolean }
71-
): Promise<{ workspaceId: string; uploadedBy: string } | null> {
86+
): Promise<{ workspaceId: string; uploadedBy: string; context: string } | null> {
7287
try {
7388
const { includeDeleted = false } = options ?? {}
7489
// Priority 1: Check new workspaceFiles table. Look up by key across
7590
// WORKSPACE_FILE_LOOKUP_CONTEXTS (`workspace` + `output`): both share the
76-
// `workspace/<id>/...` key shape and authorize by workspace membership. Filtering
77-
// to `workspace` alone made `output` files unservable (broken previews); scoping to
78-
// this explicit set (rather than dropping the filter) keeps outputs servable while
79-
// leaving upload (`mothership`) authorization and the owner-scoped contexts untouched.
91+
// `workspace/<id>/...` key shape. `workspace` rows authorize by workspace
92+
// membership; `output` rows additionally require ownership (see
93+
// outputOwnershipSatisfied). Filtering to `workspace` alone made `output`
94+
// files unservable (broken previews); scoping to this explicit set (rather
95+
// than dropping the filter) keeps outputs servable while leaving upload
96+
// (`mothership`) authorization and the owner-scoped contexts untouched.
8097
const fileRecord = await getFileMetadataByKey(key, WORKSPACE_FILE_LOOKUP_CONTEXTS, {
8198
includeDeleted,
8299
})
@@ -85,6 +102,7 @@ async function lookupWorkspaceFileByKey(
85102
return {
86103
workspaceId: fileRecord.workspaceId || '',
87104
uploadedBy: fileRecord.userId,
105+
context: fileRecord.context,
88106
}
89107
}
90108

@@ -107,6 +125,7 @@ async function lookupWorkspaceFileByKey(
107125
return {
108126
workspaceId: legacyFile.workspaceId,
109127
uploadedBy: legacyFile.uploadedBy,
128+
context: 'workspace',
110129
}
111130
}
112131
} catch (legacyError) {
@@ -181,8 +200,15 @@ export async function verifyFileAccess(
181200
return true
182201
}
183202

184-
// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
185-
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
203+
// 1. Workspace / mothership / chat-output files: check database first (most
204+
// reliable for both local and cloud). `output` shares the workspace key
205+
// shape; explicitly routing it here (instead of letting it fall through to
206+
// verifyRegularFileAccess) keeps its ownership rule applied on every path.
207+
if (
208+
inferredContext === 'workspace' ||
209+
inferredContext === 'mothership' ||
210+
inferredContext === 'output'
211+
) {
186212
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite)
187213
}
188214

@@ -242,6 +268,14 @@ async function verifyWorkspaceFileAccess(
242268
// Priority 1: Check database (most reliable, works for both local and cloud)
243269
const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey)
244270
if (workspaceFileRecord) {
271+
if (!outputOwnershipSatisfied(workspaceFileRecord, userId)) {
272+
logger.warn('Chat output file access denied: caller is not the owning chat user', {
273+
userId,
274+
workspaceId: workspaceFileRecord.workspaceId,
275+
cloudKey,
276+
})
277+
return false
278+
}
245279
const permission = await getUserEntityPermissions(
246280
userId,
247281
'workspace',
@@ -671,6 +705,14 @@ async function verifyRegularFileAccess(
671705
// This handles legacy files that might not have metadata
672706
const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey)
673707
if (workspaceFileRecord) {
708+
if (!outputOwnershipSatisfied(workspaceFileRecord, userId)) {
709+
logger.warn('Chat output file access denied: caller is not the owning chat user', {
710+
userId,
711+
workspaceId: workspaceFileRecord.workspaceId,
712+
cloudKey,
713+
})
714+
return false
715+
}
674716
const permission = await getUserEntityPermissions(
675717
userId,
676718
'workspace',

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const GET = withRouteHandler(
5151
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
5252
}
5353

54-
const file = await getPreviewableWorkspaceFile(workspaceId, fileId)
54+
const file = await getPreviewableWorkspaceFile(workspaceId, fileId, session.user.id)
5555
if (!file) {
5656
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
5757
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
Workflow,
3737
} from '@sim/emcn/icons'
3838
import { createLogger } from '@sim/logger'
39+
import { getErrorMessage } from '@sim/utils/errors'
3940
import { MoreHorizontal, Pin } from 'lucide-react'
4041
import Link from 'next/link'
4142
import { useParams, usePathname, useRouter } from 'next/navigation'
@@ -982,6 +983,9 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
982983
useFolderStore.getState().clearChatSelection()
983984
navigateToPage(`/workspace/${workspaceId}/chat/${result.id}`)
984985
},
986+
onError: (error) => {
987+
toast.error(getErrorMessage(error, 'Failed to duplicate chat'))
988+
},
985989
}
986990
)
987991
}, [navigateToPage, workspaceId])

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -989,16 +989,19 @@ export async function getWorkspaceFile(
989989
* Fetch a single file record by id for PREVIEW, including the chat-scoped `output`
990990
* context (agent-generated outputs) that never appears in the workspace Files list.
991991
* Returns the same shape as {@link listWorkspaceFiles} so the resource panel can
992-
* render an output that {@link getWorkspaceFile}/list would miss. Authorization
993-
* (workspace membership) is the caller's responsibility.
992+
* render an output that {@link getWorkspaceFile}/list would miss. Workspace
993+
* membership is the caller's responsibility; chat-output OWNERSHIP is enforced
994+
* here — `output` rows belong to a private chat, so only the owning chat's user
995+
* may resolve them (non-owners get null, indistinguishable from a missing id).
994996
*
995997
* `mothership` chat uploads are intentionally not included here — surfacing uploads
996998
* through this preview path is out of scope for the outputs feature (see
997999
* outputs-vfs-followups.md #2/#7) and can be added later if wanted.
9981000
*/
9991001
export async function getPreviewableWorkspaceFile(
10001002
workspaceId: string,
1001-
fileId: string
1003+
fileId: string,
1004+
requestingUserId: string
10021005
): Promise<WorkspaceFileRecord | null> {
10031006
try {
10041007
const [file] = await db
@@ -1015,6 +1018,14 @@ export async function getPreviewableWorkspaceFile(
10151018
.limit(1)
10161019

10171020
if (!file) return null
1021+
if (file.context === 'output' && file.userId !== requestingUserId) {
1022+
logger.warn('Chat output preview denied: caller is not the owning chat user', {
1023+
fileId,
1024+
workspaceId,
1025+
requestingUserId,
1026+
})
1027+
return null
1028+
}
10181029
return mapSingleWorkspaceFileRecord(file, workspaceId)
10191030
} catch (error) {
10201031
logger.error(`Failed to get previewable workspace file ${fileId}:`, error)

0 commit comments

Comments
 (0)