mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
COD-2 scope downloads to session workspace
This commit is contained in:
@@ -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<string, string> = {
|
||||
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)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user