mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
COD-2 scope downloads to session workspace
This commit is contained in:
@@ -4,7 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
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 fs from 'node:fs/promises';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.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);
|
const closed = fileStreamManager.closeStream(streamId);
|
||||||
return { success: closed };
|
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);
|
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