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);
});
});