Skip to content

Commit 19d8b02

Browse files
committed
fix(tools): close boundary review gaps
1 parent efceaf6 commit 19d8b02

6 files changed

Lines changed: 451 additions & 57 deletions

File tree

apps/sim/lib/execution/payloads/materialization.server.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,46 @@ describe('readUserFileContent', () => {
8585
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
8686
})
8787

88+
it.each(['profile-pictures', 'og-images', 'workspace-logos'] as const)(
89+
'authorizes actorless reads from the trusted public %s context',
90+
async (context) => {
91+
const publicFile: UserFile = {
92+
id: 'public-file',
93+
name: 'public.png',
94+
url: '',
95+
size: 6,
96+
type: 'image/png',
97+
key: `${context}/public.png`,
98+
context,
99+
}
100+
mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer: Buffer.from('public') })
101+
102+
await expect(readUserFileContent(publicFile, { encoding: 'text' })).resolves.toBe('public')
103+
104+
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
105+
expect(mockResolveWorkspaceFile).not.toHaveBeenCalled()
106+
}
107+
)
108+
109+
it('does not let an actorless caller relabel a private key as public', async () => {
110+
const relabeledFile: UserFile = {
111+
id: 'private-file',
112+
name: 'private.txt',
113+
url: '',
114+
size: 7,
115+
type: 'text/plain',
116+
key: 'workspace/workspace-1/private.txt',
117+
context: 'og-images',
118+
}
119+
120+
await expect(readUserFileContent(relabeledFile, { encoding: 'text' })).rejects.toThrow(
121+
'File context does not match its storage key.'
122+
)
123+
124+
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
125+
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
126+
})
127+
88128
it('authorizes workspace files with the preserved actorless deployment principal', async () => {
89129
const principal = {
90130
kind: 'delegated' as const,

apps/sim/lib/execution/payloads/materialization.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
bufferToBase64,
2424
inferContextFromKey,
2525
isGeneratedDocumentSourceType,
26+
isPublicStorageContext,
2627
} from '@/lib/uploads/utils/file-utils'
2728
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
2829
import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
@@ -274,6 +275,10 @@ export async function assertUserFileContentAccess(
274275
return
275276
}
276277

278+
if (isPublicStorageContext(context)) {
279+
return
280+
}
281+
277282
if (context === 'workspace' && options.principal && options.workspaceId) {
278283
const principal =
279284
options.principal.kind === 'delegated'

apps/sim/lib/internal/file/operations.test.ts

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,34 @@ function workspaceFile(id: string, ownerUserId = 'user-1') {
216216
}
217217
}
218218

219+
function actorlessDeploymentPrincipal(workspaceId = 'workspace-1') {
220+
return {
221+
kind: 'delegated' as const,
222+
serviceId: 'executor' as const,
223+
workspaceId,
224+
delegationId: 'delegation-1',
225+
audience: 'sim:workspace-files',
226+
issuedAt: new Date(Date.now() - 1_000),
227+
expiresAt: new Date(Date.now() + 60_000),
228+
delegationContext: {
229+
kind: 'workflow_execution' as const,
230+
workflowId: 'workflow-1',
231+
executionId: 'execution-1',
232+
principal: {
233+
kind: 'system' as const,
234+
serviceId: 'schedule' as const,
235+
workspaceId,
236+
workflowId: 'workflow-1',
237+
},
238+
currentWorkflow: {
239+
workflowId: 'workflow-1',
240+
mode: 'deployment' as const,
241+
deploymentVersionId: 'deployment-1',
242+
},
243+
},
244+
}
245+
}
246+
219247
describe('file manage operations', () => {
220248
beforeEach(() => {
221249
vi.clearAllMocks()
@@ -746,31 +774,7 @@ describe('file manage operations', () => {
746774

747775
it('decompresses a canonical workspace archive for an actorless deployed execution', async () => {
748776
const archiveBuffer = Buffer.from('archive-bytes')
749-
const principal = {
750-
kind: 'delegated' as const,
751-
serviceId: 'executor' as const,
752-
workspaceId: 'workspace-1',
753-
delegationId: 'delegation-1',
754-
audience: 'sim:workspace-files',
755-
issuedAt: new Date(Date.now() - 1_000),
756-
expiresAt: new Date(Date.now() + 60_000),
757-
delegationContext: {
758-
kind: 'workflow_execution' as const,
759-
workflowId: 'workflow-1',
760-
executionId: 'execution-1',
761-
principal: {
762-
kind: 'system' as const,
763-
serviceId: 'schedule' as const,
764-
workspaceId: 'workspace-1',
765-
workflowId: 'workflow-1',
766-
},
767-
currentWorkflow: {
768-
workflowId: 'workflow-1',
769-
mode: 'deployment' as const,
770-
deploymentVersionId: 'deployment-1',
771-
},
772-
},
773-
}
777+
const principal = actorlessDeploymentPrincipal()
774778
mockDownloadFileFromStorage.mockResolvedValue(archiveBuffer)
775779
mockGetWorkspaceFile.mockResolvedValue({
776780
...workspaceFile('archive'),
@@ -812,13 +816,39 @@ describe('file manage operations', () => {
812816

813817
expect(response.status).toBe(200)
814818
expect(mockResolveEffectiveWorkspacePermission).not.toHaveBeenCalled()
815-
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
819+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'archive', {
820+
throwOnError: true,
821+
})
816822
expect(mockDecompressArchiveBufferToWorkspaceFiles).toHaveBeenCalledWith(
817823
archiveBuffer,
818824
expect.objectContaining({ principal, workspaceId: 'workspace-1' })
819825
)
820826
})
821827

828+
it('rejects an actorless deployment principal bound to a different workspace', async () => {
829+
const response = await executeFileManageOperation(
830+
fileManageBodySchema.parse({
831+
operation: 'decompress',
832+
workspaceId: 'workspace-1',
833+
fileId: 'archive',
834+
}),
835+
{
836+
principal: actorlessDeploymentPrincipal('workspace-2'),
837+
workspaceId: 'workspace-1',
838+
attributedUserId: 'workspace-owner',
839+
workflowId: 'workflow-1',
840+
executionId: 'execution-1',
841+
headers: new Headers(),
842+
requestId: 'request-cross-workspace',
843+
}
844+
)
845+
846+
expect(response.status).toBe(403)
847+
expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
848+
expect(mockDownloadFileFromStorage).not.toHaveBeenCalled()
849+
expect(mockDecompressArchiveBufferToWorkspaceFiles).not.toHaveBeenCalled()
850+
})
851+
822852
it('omits source scope when canonical files have different owners', async () => {
823853
mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) =>
824854
workspaceFile(fileId, fileId === 'file-1' ? 'user-1' : 'user-2')

apps/sim/lib/uploads/utils/file-utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,11 @@ const PUBLIC_STORAGE_CONTEXTS = new Set<StorageContext>([
788788
'workspace-logos',
789789
])
790790

791+
/** Whether a trusted storage context is world-readable. */
792+
export function isPublicStorageContext(context: StorageContext): boolean {
793+
return PUBLIC_STORAGE_CONTEXTS.has(context)
794+
}
795+
791796
/**
792797
* Resolve the storage context for a stored file from its trusted key prefix.
793798
*
@@ -811,7 +816,7 @@ export function resolveTrustedFileContext(key: string, context?: string): Storag
811816
try {
812817
return inferContextFromKey(key)
813818
} catch (error) {
814-
if (context && !PUBLIC_STORAGE_CONTEXTS.has(context as StorageContext)) {
819+
if (context && !isPublicStorageContext(context as StorageContext)) {
815820
return context as StorageContext
816821
}
817822
throw error

scripts/check-tool-request-boundary.test.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,15 @@ describe('tool self-hop audit', () => {
5151
})
5252

5353
it('rejects a compound same-origin URL constructor path', () => {
54-
const audit = auditRequest(`
55-
url: (params) => new URL('/api/tools/' + params.id, params.baseUrl).toString()
54+
const audit = auditToolSelfHops(`
55+
import { getBaseUrl } from '@/lib/core/utils/urls'
56+
const tool = {
57+
id: 'test_tool',
58+
request: {
59+
url: (params) => new URL('/api/tools/' + params.id, getBaseUrl()).toString(),
60+
method: 'POST',
61+
},
62+
}
5663
`)
5764

5865
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
@@ -96,10 +103,81 @@ describe('tool self-hop audit', () => {
96103
})
97104

98105
it('rejects a same-origin URL constructor', () => {
99-
const audit = auditRequest(`
100-
url: (params) => {
101-
const url = new URL('/api/tools/test', params.baseUrl)
102-
return url.toString()
106+
const audit = auditToolSelfHops(`
107+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
108+
const tool = {
109+
id: 'test_tool',
110+
request: {
111+
url: () => {
112+
const url = new URL('/api/tools/test', getInternalApiBaseUrl())
113+
return url.toString()
114+
},
115+
method: 'POST',
116+
},
117+
}
118+
`)
119+
120+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
121+
})
122+
123+
it('rejects a same-origin path passed through a local URL helper', () => {
124+
const audit = auditToolSelfHops(`
125+
import { getBaseUrl as getSimOrigin } from '@/lib/core/utils/urls'
126+
function providerUrl(path, host) {
127+
return new URL(path, host).toString()
128+
}
129+
const simOrigin = getSimOrigin()
130+
const tool = {
131+
id: 'test_tool',
132+
request: {
133+
url: () => providerUrl('/api/tools/test', simOrigin),
134+
method: 'POST',
135+
},
136+
}
137+
`)
138+
139+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
140+
})
141+
142+
it('rejects a same-origin path forwarded through nested local helpers', () => {
143+
const audit = auditToolSelfHops(`
144+
import { getBaseUrl } from '@/lib/core/utils/urls'
145+
function providerUrl(path, host) {
146+
return new URL(path, host).toString()
147+
}
148+
function buildUrl(path) {
149+
return providerUrl(path, getBaseUrl())
150+
}
151+
const tool = {
152+
id: 'test_tool',
153+
request: { url: () => buildUrl('/api/tools/test'), method: 'POST' },
154+
}
155+
`)
156+
157+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
158+
})
159+
160+
it('rejects a same-origin path concatenated with the Sim origin', () => {
161+
const audit = auditToolSelfHops(`
162+
import { getBaseUrl } from '@/lib/core/utils/urls'
163+
const tool = {
164+
id: 'test_tool',
165+
request: { url: () => getBaseUrl() + '/api/tools/test', method: 'POST' },
166+
}
167+
`)
168+
169+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
170+
})
171+
172+
it('rejects a same-origin path passed through a known imported URL builder', () => {
173+
const audit = auditToolSelfHops(`
174+
import { buildAPIUrl as buildSimUrl } from '@/executor/utils/http'
175+
const tool = {
176+
id: 'test_tool',
177+
request: {
178+
url: () => buildSimUrl('/api/tools/test').toString(),
179+
method: 'POST',
180+
},
103181
}
104182
`)
105183

@@ -141,4 +219,21 @@ describe('tool self-hop audit', () => {
141219

142220
expect(audit.violations).toEqual([])
143221
})
222+
223+
it('allows an API-shaped provider path resolved against an external origin', () => {
224+
const audit = auditToolSelfHops(`
225+
function providerUrl(path, host) {
226+
return new URL(path, host).toString()
227+
}
228+
const tool = {
229+
id: 'test_tool',
230+
request: {
231+
url: () => providerUrl('/api/messages', 'https://provider.example.com'),
232+
method: 'POST',
233+
},
234+
}
235+
`)
236+
237+
expect(audit.violations).toEqual([])
238+
})
144239
})

0 commit comments

Comments
 (0)