mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user