mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
fix(attachments): harden registry + close magic-link injection vector
Security (MAJOR): the terminal-output codeman://attach scanner registered any
matching path server-side with no user confirmation and broadcast the rawUrl
over SSE. Terminal output is attacker-influenceable (a prompt-injected session
can print an arbitrary path), so on the default no-auth deployment this was an
arbitrary host-file (png/pdf/docx/pptx/md/txt) read primitive reachable by any
SSE client. Magic-link registration is now force-confined to the session
workspace (forceWorkspaceConfinement) regardless of the global confine setting;
deliberate cross-workspace attach still works through the explicit,
Origin-guarded POST /attachments route and 'codeman attach' (which POSTs
directly inside a managed session). Documented in security-architecture.md.
Regression (MAJOR): .png was rerouted from the image-popup path to
attachment:detected, which has no frontend consumer — silently breaking the
dropped/pasted-screenshot popup. PNG stays on image:detected; only pdf/docx/pptx
(which never had a popup) emit attachment:detected.
Also:
- raw route streams the freshly-resolved path, not the stored one, so a
post-registration symlink swap can't redirect the stream (TOCTOU).
- 50MB cap on the attachment raw route, matching file-raw / download.
- per-session attachment registry cap (200) to bound the POST path.
- CLI reads creds via dataPath('.env'), honoring CODEMAN_INSTANCE.
Tests: forced-confinement reject/allow cases; PNG popup-path assertions updated.
This commit is contained in:
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit attachment:detected for .png files', () => {
|
||||
it('should emit image:detected (popup) for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('attachment:detected', handler);
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
@@ -161,14 +161,11 @@ describe('ImageWatcher', () => {
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.extension).toBe('png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
@@ -51,7 +51,11 @@ vi.mock('../../src/file-stream-manager.js', () => ({
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
|
||||
import {
|
||||
attachmentRegistry,
|
||||
registerExternalAttachment,
|
||||
type AttachmentRecord,
|
||||
} from '../../src/attachment-registry.js';
|
||||
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
@@ -321,4 +325,34 @@ describe('file-routes attachment path guard (COD-53)', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.fileName).toBe('jira-autoloop-questions.md');
|
||||
});
|
||||
|
||||
// ===== Magic-link scan path: FORCED workspace confinement =====
|
||||
// The terminal-output `codeman://attach` scanner registers with
|
||||
// forceWorkspaceConfinement: true so a prompt-injected session printing an
|
||||
// arbitrary path can't expose a host file, even though global confine is OFF.
|
||||
describe('forced workspace confinement (magic-link scan path)', () => {
|
||||
it('rejects an out-of-workspace path even when global confinement is OFF', async () => {
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||
await expect(
|
||||
registerExternalAttachment('test-session-mlc', '/home/someone/secret/report.pdf', {
|
||||
sessionWorkingDir: '/tmp/test-workdir',
|
||||
forceWorkspaceConfinement: true,
|
||||
})
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
attachmentRegistry.clearSession('test-session-mlc');
|
||||
});
|
||||
|
||||
it('allows an in-workspace path on the forced path', async () => {
|
||||
const inside = '/tmp/test-workdir/sub/report.pdf';
|
||||
mockedRealpathSync.mockReturnValue(inside as never);
|
||||
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||
const event = await registerExternalAttachment('test-session-mlc', inside, {
|
||||
sessionWorkingDir: '/tmp/test-workdir',
|
||||
forceWorkspaceConfinement: true,
|
||||
});
|
||||
expect(event.fileName).toBe('report.pdf');
|
||||
attachmentRegistry.clearSession('test-session-mlc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user