From f7b6bb8ce7c52bfdbc40f1f4ab7a9f304021495c Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 30 Mar 2026 14:50:33 -0400 Subject: [PATCH 1/4] local: add ?download=true to file-raw endpoint for Content-Disposition attachment --- src/web/routes/file-routes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 00ee8c70..f6537c4b 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void // Serve raw file content (for images/binary files) app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; + const { path: filePath, download } = req.query as { path?: string; download?: string }; const session = findSessionOrFail(ctx, id); if (!filePath) { @@ -293,6 +293,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void const content = await fs.readFile(resolvedPath); reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + if (download === 'true') { + const basename = filePath!.split('/').pop() || 'download'; + reply.header('Content-Disposition', `attachment; filename="${basename}"`); + } reply.send(content); } catch (err) { reply From d06a4432230a1786e384d5fdd32ed2bd8fd7888d Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 30 Mar 2026 14:53:23 -0400 Subject: [PATCH 2/4] local: add GET /api/download endpoint for arbitrary filesystem file downloads --- src/web/routes/file-routes.ts | 106 +++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index f6537c4b..9c35d76a 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 { join, basename as pathBasename } 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'; @@ -372,4 +373,107 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void const closed = fileStreamManager.closeStream(streamId); return { success: closed }; }); + + // Standalone file download -- any readable path on the filesystem. + // Protected by auth middleware (CODEMAN_PASSWORD) and a sensitive-path blocklist. + 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 } = req.query as { path?: string }; + + if (!filePath) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + return; + } + + // Require absolute path + if (!filePath.startsWith('/')) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be absolute')); + return; + } + + // Resolve to canonical path (follow symlinks, normalize ..) + let resolvedPath: string; + try { + resolvedPath = join(filePath); // normalize + const { realpathSync } = await import('node:fs'); + resolvedPath = realpathSync(resolvedPath); + } catch { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); + return; + } + + // 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.isDirectory()) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is a directory')); + 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); + reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + reply.header('Content-Disposition', `attachment; filename="${filename}"`); + reply.send(content); + } catch (err) { + reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); + } + }); } From 94087807a9c3ee633e528220eebc0408ca784c6f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 30 Mar 2026 14:55:02 -0400 Subject: [PATCH 3/4] local: add download button to file browser --- src/web/public/panels-ui.js | 5 +++++ src/web/public/styles.css | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 1226acf3..00cd7bca 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, { const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; + const downloadBtn = !isDir + ? `` + : ''; + html.push(`
${expandIcon} ${icon} ${escapeHtml(node.name)} ${sizeStr} + ${downloadBtn}
`); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7b4b9ef2..7b2eaab7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -6568,6 +6568,22 @@ kbd { .file-tree-item[data-depth="8"] { padding-left: 6.5rem; } .file-tree-item[data-depth="9"] { padding-left: 7.25rem; } +.file-tree-download { + display: none; + margin-left: auto; + padding: 0 4px; + color: #888; + text-decoration: none; + font-size: 12px; + flex-shrink: 0; +} +.file-tree-item:hover .file-tree-download { + display: inline; +} +.file-tree-download:hover { + color: #4fc3f7; +} + /* ========== File Preview Overlay ========== */ .file-preview-overlay { From fe23e0987359d5a8339015d1088006b1609b87b4 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 30 Mar 2026 15:13:55 -0400 Subject: [PATCH 4/4] local: fix 0-byte downloads by bypassing Fastify compression via reply.raw --- src/web/routes/file-routes.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 9c35d76a..227977f8 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -293,11 +293,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void }; const content = await fs.readFile(resolvedPath); - reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); if (download === 'true') { + // Bypass Fastify compression — write directly to raw response const basename = filePath!.split('/').pop() || 'download'; - reply.header('Content-Disposition', `attachment; filename="${basename}"`); + reply.raw.writeHead(200, { + 'Content-Type': mimeTypes[ext] || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${basename}"`, + 'Content-Length': content.length, + }); + reply.raw.end(content); + return; } + reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); reply.send(content); } catch (err) { reply @@ -467,9 +474,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void const filename = pathBasename(resolvedPath); const content = await fs.readFile(resolvedPath); - reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); - reply.header('Content-Disposition', `attachment; filename="${filename}"`); - reply.send(content); + // 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)