fix: harden stack file explorer operations (#1028)

* fix: harden stack file explorer operations

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
Anso
2026-05-12 15:49:51 -04:00
committed by GitHub
parent 19cdb3681d
commit 69b6ac1f3b
8 changed files with 327 additions and 39 deletions
@@ -7,6 +7,8 @@
* PUT /:stackName/files/content (Skipper+)
* DELETE /:stackName/files (Skipper+)
* POST /:stackName/files/folder (Skipper+)
* PATCH /:stackName/files/rename (Skipper+)
* PUT /:stackName/files/permissions (Skipper+)
*
* Covers: auth gating, tier gating (Community vs paid), input validation,
* upload size limit, and happy-path 204/200 responses.
@@ -98,6 +100,30 @@ describe('GET /api/stacks/:stackName/files', () => {
expect(names).toContain('.env');
});
it('emits diagnostic logs only when developer_mode is enabled', async () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(debugSpy).not.toHaveBeenCalledWith(
expect.stringContaining('[Files:diag]'),
expect.anything(),
);
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('[Files:diag]'),
expect.anything(),
);
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
});
it('returns 400 for an invalid stack name containing path traversal', async () => {
const res = await request(app)
.get('/api/stacks/../evil/files')
@@ -188,6 +214,14 @@ describe('GET /api/stacks/:stackName/files/download', () => {
expect(res.status).toBe(403);
});
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
it('streams the file for a paid tier user', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
@@ -225,6 +259,27 @@ describe('POST /api/stacks/:stackName/files/upload', () => {
expect(res.status).toBe(400);
});
it('rejects upload filenames with path separators', async () => {
const boundary = '----sencho-test-boundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="../evil.txt"',
'Content-Type: text/plain',
'',
'data',
`--${boundary}--`,
'',
].join('\r\n');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.set('Content-Type', `multipart/form-data; boundary=${boundary}`)
.send(body);
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid filename');
});
it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => {
// 26 MB buffer
const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61);
@@ -291,6 +346,15 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
expect(res.status).toBe(400);
});
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.set('Cookie', adminCookie)
.send({ content: 'hello' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
it('returns 204 and writes the file for a paid tier admin', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
@@ -304,6 +368,36 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
});
});
// ── PATCH /:stackName/files/rename ───────────────────────────────────────────
describe('PATCH /api/stacks/:stackName/files/rename', () => {
it('returns 409 ALREADY_EXISTS when destination exists', async () => {
await fs.writeFile(path.join(stacksDir, STACK, 'rename-source.txt'), 'source');
await fs.writeFile(path.join(stacksDir, STACK, 'rename-target.txt'), 'target');
const res = await request(app)
.patch(`/api/stacks/${STACK}/files/rename`)
.set('Cookie', adminCookie)
.send({ from: 'rename-source.txt', to: 'rename-target.txt' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('ALREADY_EXISTS');
});
});
// ── PUT /:stackName/files/permissions ────────────────────────────────────────
describe('PUT /api/stacks/:stackName/files/permissions', () => {
it('returns 400 INVALID_PATH for invalid chmod modes', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie)
.send({ mode: 0o1000 });
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
});
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
describe('DELETE /api/stacks/:stackName/files', () => {
@@ -444,4 +538,21 @@ describe('permission gating', () => {
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('viewer receives 403 from PATCH /files/rename', async () => {
const res = await request(app)
.patch(`/api/stacks/${STACK}/files/rename`)
.set('Cookie', viewerCookie)
.send({ from: 'compose.yaml', to: 'compose-renamed.yaml' });
expect(res.status).toBe(403);
});
it('viewer receives 403 from PUT /files/permissions', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/permissions`)
.query({ path: 'compose.yaml' })
.set('Cookie', viewerCookie)
.send({ mode: 0o644 });
expect(res.status).toBe(403);
});
});
+106 -8
View File
@@ -111,6 +111,7 @@ export async function resolveAllEnvFilePaths(nodeId: number, stackName: string):
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
preservePath: true,
});
function getRelPath(req: Request): string {
@@ -821,7 +822,14 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
// ── File explorer endpoints ──
type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE';
type FsErrorCode =
| 'INVALID_PATH'
| 'SYMLINK_ESCAPE'
| 'IS_DIRECTORY'
| 'NOT_EMPTY'
| 'NOT_FOUND'
| 'TOO_LARGE'
| 'ALREADY_EXISTS';
function sendFsError(
res: Response,
@@ -839,23 +847,61 @@ function sendFsError(
if (e.code === 'NOT_EMPTY') {
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'EEXIST') {
return res.status(409).json({ error: e.message, code: 'ALREADY_EXISTS' satisfies FsErrorCode });
}
if (e.code === 'ENOTDIR') {
return res.status(400).json({ error: 'Target path is not a directory', code: 'INVALID_PATH' satisfies FsErrorCode });
}
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
console.error(`[files] ${fallback}:`, e.message);
console.error(`[files] ${fallback}:`, sanitizeForLog(e.message));
return res.status(500).json({ error: fallback });
}
function logFileOperation(level: 'info' | 'warn', message: string, details: Record<string, unknown>): void {
const cleaned = Object.fromEntries(
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
);
const log = level === 'warn' ? console.warn : console.log;
log(`[Files] ${message}`, cleaned);
}
function fsErrorCode(err: unknown): string {
const code = (err as NodeJS.ErrnoException & { code?: unknown }).code;
return typeof code === 'string' ? code : 'UNKNOWN';
}
function logFileDiag(message: string, details: Record<string, unknown>): void {
if (DatabaseService.getInstance().getGlobalSettings().developer_mode !== '1') return;
const cleaned = Object.fromEntries(
Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]),
);
console.debug(`[Files:diag] ${message}`, cleaned);
}
function isSafeUploadFilename(rawName: string): boolean {
if (!rawName || rawName === '.' || rawName === '..') return false;
if (rawName.includes('\0') || rawName.includes('/') || rawName.includes('\\')) return false;
if (/^[a-zA-Z]:/.test(rawName) || path.isAbsolute(rawName)) return false;
return path.basename(rawName) === rawName;
}
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId });
try {
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
logFileDiag('list complete', { stackName, relPath, nodeId: req.nodeId, entries: entries.length, elapsedMs: Date.now() - startedAt });
return res.json(entries);
} catch (err: unknown) {
logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to list directory');
}
});
@@ -867,10 +913,22 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
logFileDiag('read complete', {
stackName,
relPath,
nodeId: req.nodeId,
binary: result.binary,
oversized: result.oversized,
size: result.size,
elapsedMs: Date.now() - startedAt,
});
return res.json(result);
} catch (err: unknown) {
logFileOperation('warn', 'read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to read file');
}
});
@@ -879,9 +937,12 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
@@ -890,14 +951,16 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
result.stream.on('error', (streamErr) => {
console.error('[files] stream error:', streamErr);
console.error('[files] stream error:', sanitizeForLog(getErrorMessage(streamErr, 'unknown')));
if (!res.headersSent) res.status(500).end();
else res.destroy();
});
req.on('close', () => result.stream.destroy());
logFileDiag('download stream opened', { stackName, relPath, nodeId: req.nodeId, size: result.size, elapsedMs: Date.now() - startedAt });
result.stream.pipe(res);
return;
} catch (err: unknown) {
logFileOperation('warn', 'download failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to download file');
}
});
@@ -924,15 +987,20 @@ stacksRouter.post(
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const safeName = path.basename(req.file.originalname);
if (!safeName || safeName === '.' || safeName === '..') {
const originalName = req.file.originalname;
if (!isSafeUploadFilename(originalName)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName;
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
const startedAt = Date.now();
logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size });
try {
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size });
logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'upload failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
}
},
@@ -943,17 +1011,23 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { content } = req.body as { content?: unknown };
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
const startedAt = Date.now();
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') });
try {
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
logFileOperation('info', 'write complete', { nodeId: req.nodeId });
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'write failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to write file');
}
});
@@ -968,10 +1042,15 @@ stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) =>
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
const startedAt = Date.now();
logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
logFileOperation('info', 'delete complete', { nodeId: req.nodeId, recursive });
logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'delete failed', { nodeId: req.nodeId, recursive, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to delete path');
}
});
@@ -985,10 +1064,15 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
logFileOperation('info', 'mkdir complete', { nodeId: req.nodeId });
logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'mkdir failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to create folder');
}
});
@@ -1010,10 +1094,15 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons
if (!isValidRelativeStackPath(to)) {
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId });
try {
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
logFileOperation('info', 'rename complete', { nodeId: req.nodeId });
logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'rename failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to rename');
}
});
@@ -1025,10 +1114,14 @@ stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Resp
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const startedAt = Date.now();
logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId });
try {
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
logFileDiag('permissions read complete', { stackName, relPath, nodeId: req.nodeId, mode: result.octal, elapsedMs: Date.now() - startedAt });
return res.json(result);
} catch (err: unknown) {
logFileOperation('warn', 'permissions read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to read permissions');
}
});
@@ -1046,10 +1139,15 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp
if (typeof mode !== 'number') {
return res.status(400).json({ error: '"mode" must be a number' });
}
const startedAt = Date.now();
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode });
try {
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
logFileOperation('info', 'chmod complete', { nodeId: req.nodeId, mode });
logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
return res.status(204).send();
} catch (err: unknown) {
logFileOperation('warn', 'chmod failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
return sendFsError(res, err, 'Failed to set permissions');
}
});