From 4c28b37a59daa67e388d07948319a0f02921b305 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 24 May 2026 22:58:28 -0400 Subject: [PATCH] fix(stacks): require stack:read on file explorer GET routes (#1200) The four file-explorer GET endpoints (list, content, download, permissions) previously relied on auth alone. Their write-side siblings required stack:edit, so the read path was the only file-explorer surface without an explicit capability check. The shipped roles all carry stack:read globally so behaviour is unchanged today, but adding the guard prevents a future role definition from silently inheriting unrestricted file reads, and it brings the file-explorer reads in line with gitSources and stackActivity which already gate on stack:read. The frontend Files tab trigger, panel, and the anatomy-panel "Open Files" affordance now render only when the user holds stack:read for the active stack. An effect canonicalises activeTab back to 'compose' if the user lands on 'files' without permission, so a denied user cannot end up staring at an empty panel. --- .../src/__tests__/stack-files-routes.test.ts | 35 +++++++++++++++++++ backend/src/routes/stacks.ts | 4 +++ .../components/EditorLayout/EditorView.tsx | 25 ++++++++----- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index be876d6d..292e865a 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -595,4 +595,39 @@ describe('permission gating', () => { .send({ mode: 0o644 }); expect(res.status).toBe(403); }); + + it('viewer with global stack:read can GET /files', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files`) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('viewer with global stack:read can GET /files/content', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/content`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(typeof res.body.content).toBe('string'); + }); + + it('viewer with global stack:read can GET /files/download', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/download`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toMatch(/attachment/); + }); + + it('viewer with global stack:read can GET /files/permissions', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/files/permissions`) + .query({ path: 'compose.yaml' }) + .set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(typeof res.body.octal).toBe('string'); + }); }); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 40b7bbac..f3f7d5c5 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1370,6 +1370,7 @@ function isSafeUploadFilename(rawName: string): boolean { stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (relPath !== '' && !isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); @@ -1388,6 +1389,7 @@ stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { @@ -1415,6 +1417,7 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { @@ -1583,6 +1586,7 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; const relPath = getRelPath(req); if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); if (!isValidRelativeStackPath(relPath)) { diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index e3f6ddde..b99aafec 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -319,6 +319,13 @@ export function EditorView({ const safeContent = content || ''; const safeEnvContent = envContent || ''; const isRunning = safeContainers.some(c => c.State === 'running'); + const canRead = can('stack:read', 'stack', stackName); + + useEffect(() => { + if (activeTab === 'files' && !canRead) { + setActiveTab('compose'); + } + }, [activeTab, canRead, setActiveTab]); return ( @@ -712,12 +719,14 @@ export function EditorView({ .env - - - - Files - - + {canRead && ( + + + + Files + + + )} @@ -801,7 +810,7 @@ export function EditorView({
- {activeTab === 'files' ? ( + {activeTab === 'files' && canRead ? ( { setEditingCompose(true); setActiveTab('compose'); }} - onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} + onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined} onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} canEdit={can('stack:edit', 'stack', stackName)}