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:
arkon
2026-06-11 10:27:09 +02:00
parent f1c64994ad
commit f7ce8e4767
8 changed files with 144 additions and 31 deletions
+29 -8
View File
@@ -48,6 +48,10 @@ export class AttachmentRegistrationError extends Error {
}
}
/** Per-session attachment cap. Bounds memory against a client (or a
* prompt-injected magic-link flood) registering unbounded distinct paths. */
const MAX_ATTACHMENTS_PER_SESSION = 200;
class AttachmentRegistry {
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
@@ -58,6 +62,12 @@ class AttachmentRegistry {
this.recordsBySession.set(record.sessionId, records);
}
records.set(record.attachmentId, record);
// Evict oldest (insertion-order) entries beyond the cap.
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
const oldest = records.keys().next().value;
if (oldest === undefined) break;
records.delete(oldest);
}
}
get(sessionId: string, attachmentId: string): AttachmentRecord | undefined {
@@ -134,12 +144,22 @@ export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentReg
/** Options for {@link registerExternalAttachment}. */
export interface RegisterExternalAttachmentOptions {
/**
* The registering session's working directory. Required only to enforce
* workspace confinement when that mode is enabled
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`); ignored in
* the default blocklist mode.
* The registering session's working directory. Required to enforce workspace
* confinement — either when the global mode is enabled
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
* {@link forceWorkspaceConfinement} is set for this call.
*/
sessionWorkingDir?: string;
/**
* Force workspace confinement for THIS registration regardless of the global
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
* terminal output is attacker-influenceable (a prompt-injected session can
* print an arbitrary path), so passive magic links may only reference files
* inside the session workspace. Deliberate cross-workspace attachment still
* works through the explicit, Origin-guarded `POST /attachments` route and the
* `codeman attach` CLI (which POSTs directly when a session id is known).
*/
forceWorkspaceConfinement?: boolean;
}
export async function registerExternalAttachment(
@@ -162,10 +182,11 @@ export async function registerExternalAttachment(
// path before doing anything else.
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace) {
// Strict mode (opt-in, default OFF): the file MUST resolve inside the
// session's workspace. Strictly more restrictive than the blocklist —
// breaks cross-workspace attachment, which is why it is off by default.
if (guard.confineToWorkspace || options.forceWorkspaceConfinement) {
// Workspace-confined: the file MUST resolve inside the session's workspace.
// Applies when the global strict mode is on (opt-in, default OFF) OR when
// the caller forces it for this registration (the magic-link scanner — see
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
const workingDir = options.sessionWorkingDir;
if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
+3 -3
View File
@@ -13,8 +13,8 @@ import { createRequire } from 'module';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join } from 'node:path';
import { isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
import { getRalphLoop } from './ralph-loop.js';
@@ -34,7 +34,7 @@ function makeAttachmentMagicLink(filePath: string): string {
}
function readCodemanEnv(): Record<string, string> {
const envPath = join(homedir(), '.codeman', '.env');
const envPath = dataPath('.env');
try {
const text = readFileSync(envPath, 'utf-8');
const result: Record<string, string> = {};
+6 -2
View File
@@ -20,8 +20,12 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
// PNG stays on the image-popup path: it's the dominant screenshot format and the
// frontend only wires the `image:detected` popup today. The attachment-card UI
// that would consume `attachment:detected` for images is out of scope for this
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
/** Time to wait for file writes to stabilize (ms) */
+24 -6
View File
@@ -71,6 +71,18 @@ async function serveRawFile(
download?: boolean
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
)
);
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
@@ -116,14 +128,16 @@ function getAttachmentOr404(
* rejects any record outside the session workspace. Returns true (and sends a
* 403) when blocked.
*/
async function rejectIfSensitiveRecord(
async function resolveServableAttachmentPath(
reply: FastifyReply,
record: AttachmentRecord,
sessionWorkingDir?: string
): Promise<boolean> {
): Promise<string | null> {
let pathToCheck = record.filePath;
let resolved = false;
try {
pathToCheck = realpathSync(record.filePath);
resolved = true;
} catch {
// Fall back to the stored (already realpath-resolved at registration) path.
}
@@ -137,9 +151,12 @@ async function rejectIfSensitiveRecord(
if (blocked) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return true;
return null;
}
return false;
// Serve the freshly-resolved path, not the stored one: if a path component
// became a symlink after registration, the guard checked the resolved target
// but streaming record.filePath would follow the symlink to a swapped file.
return resolved ? pathToCheck : record.filePath;
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
@@ -486,10 +503,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const session = findSessionOrFail(ctx, id);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
if (await rejectIfSensitiveRecord(reply, record, session.workingDir)) return;
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
if (!servePath) return;
try {
await serveRawFile(reply, record.filePath, record.fileName, record.extension, download === 'true');
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
} catch (err) {
reply
.code(500)
+12 -3
View File
@@ -1268,13 +1268,22 @@ export class WebServer extends EventEmitter {
/**
* Register a terminal-requested external file as a live attachment and
* broadcast it. Triggered by the session's `attachmentRequested` event
* (codeman://attach magic links). Registration enforces the COD-53
* attachment-guard policy.
* (codeman://attach magic links). Because terminal output is
* attacker-influenceable (a prompt-injected session can print an arbitrary
* `codeman://attach?path=` link), the scanned path is FORCE-confined to the
* session workspace — passive magic links can't expose arbitrary host files.
* Deliberate cross-workspace attachment goes through the explicit,
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
* directly inside a managed session). Registration also enforces the COD-53
* blocklist as defense-in-depth.
*/
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const event = await registerExternalAttachment(sessionId, filePath, { sessionWorkingDir: session.workingDir });
const event = await registerExternalAttachment(sessionId, filePath, {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
this.broadcast(SseEvent.AttachmentDetected, event);
}