diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index c9b495c1..d27418ef 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -4,7 +4,8 @@ */ import { FastifyInstance } from 'fastify'; -import { join } from 'node:path'; +import { basename as pathBasename, join } from 'node:path'; +import { homedir } from 'node:os'; import fs from 'node:fs/promises'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; @@ -380,4 +381,108 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void const closed = fileStreamManager.closeStream(streamId); return { success: closed }; }); + // Session-scoped file download. + // Uses the same realpath-based workspace boundary as file preview/raw routes; + // the sensitive-path blocklist remains defense-in-depth, not the primary boundary. + const SENSITIVE_PATTERNS: RegExp[] = [ + /^\/etc\/shadow$/, + /^\/etc\/gshadow$/, + /^\/etc\/master\.passwd$/, + new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`), + /\/\.env$/, + /\/\.env\./, + /\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i, + /\/\.aws\/credentials$/, + /\/\.gcloud\/credentials\.db$/, + /\/\.docker\/config\.json$/, + ]; + + function isSensitivePath(absPath: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath)); + } + + app.get('/api/download', async (req, reply) => { + const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string }; + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + if (!sessionId) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter')); + return; + } + + const session = findSessionOrFail(ctx, sessionId); + const validated = validateSessionFilePath(session.workingDir, filePath); + if (!validated) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); + return; + } + const { resolvedPath } = validated; + + // Check sensitive path blocklist + if (isSensitivePath(resolvedPath)) { + reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked')); + return; + } + + try { + const stat = await fs.stat(resolvedPath); + + if (!stat.isFile()) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file')); + return; + } + + // 50MB size limit + const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024; + if (stat.size > MAX_DOWNLOAD_SIZE) { + reply + .code(400) + .send( + createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)` + ) + ); + return; + } + + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + pdf: 'application/pdf', + json: 'application/json', + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + xml: 'application/xml', + zip: 'application/zip', + gz: 'application/gzip', + tar: 'application/x-tar', + }; + + const filename = pathBasename(resolvedPath); + const content = await fs.readFile(resolvedPath); + // Bypass Fastify compression — write directly to raw response + reply.raw.writeHead(200, { + 'Content-Type': mimeTypes[ext] || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': content.length, + }); + reply.raw.end(content); + return; + } catch (err) { + reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); + } + }); } diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index bb657733..d0f9a6f4 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -363,4 +363,63 @@ describe('file-routes', () => { expect(body.success).toBe(false); }); }); + + // ========== GET /api/download ========== + + describe('GET /api/download', () => { + it('requires a sessionId to scope downloads', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`, + }); + + expect(res.statusCode).toBe(400); + }); + + it('downloads files scoped to the session working directory', async () => { + const content = Buffer.from('download content'); + mockedReadFile.mockResolvedValue(content as never); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-disposition']).toContain('filename="report.txt"'); + expect(res.body).toBe('download content'); + }); + + it('rejects absolute paths outside the session working directory', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`, + }); + + expect(res.statusCode).toBe(404); + }); + + it('rejects symlink targets that escape the session working directory', async () => { + mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent( + '/tmp/test-workdir/link.log' + )}`, + }); + + expect(res.statusCode).toBe(404); + }); + + it('blocks sensitive files even when they are inside the session working directory', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`, + }); + + expect(res.statusCode).toBe(403); + }); + }); });