security(paste-image): harden against 7 findings from PR #84 review (#90)

Hardens `/api/sessions/:id/paste-image` against the seven findings flagged in the dismissed security review on #84. Each commit addresses one finding.

- LOW: Collision-free filenames (`paste-${ts}-${rand4}${ext}`)
- MED: Symlink check on image dir (`lstat` + non-recursive mkdir + `O_EXCL|O_NOFOLLOW`)
- MED: Magic-byte validation (PNG/JPEG/GIF/WebP/BMP)
- HIGH: CSRF protection (Origin/Referer match req.host; non-browser clients send `X-Codeman-CSRF`)
- MED: Swap hand-rolled multipart parser to @fastify/multipart with `limits: { fileSize: 10MB, files: 1, fields: 4 }`
- MED: Rate limit (30/min per IP+session) + hourly GC of `paste-*` files older than 7d
- LOW: Use `terminal.paste(text)` instead of `sendInput(text)` so bracketed-paste markers survive

Co-authored-by: Aamer Akhter <aakhter@gmail.com>
This commit is contained in:
aakhter
2026-05-19 10:36:05 +02:00
committed by GitHub
parent 7752325c90
commit 101cee0cec
6 changed files with 2319 additions and 61 deletions
+26
View File
@@ -32,6 +32,8 @@ import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
@@ -229,6 +231,7 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -539,6 +542,18 @@ export class WebServer extends EventEmitter {
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
// Multipart parsing (used by paste-image). Replaces a hand-rolled
// boundary scanner that had several edge-case bugs: literal boundary
// anywhere in body was a match, LF-only clients silently corrupted the
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
await this.app.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB per file
files: 1, // paste-image only ever sends one file
fields: 4, // small headroom for accompanying form fields
},
});
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
this.app.get('/', async (_req, reply) => {
@@ -1541,6 +1556,12 @@ export class WebServer extends EventEmitter {
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
@@ -1894,6 +1915,11 @@ export class WebServer extends EventEmitter {
// Set stopping flag to prevent new timer creation during shutdown
this.sse.setStopping();
if (this._pasteImageGcStop) {
this._pasteImageGcStop();
this._pasteImageGcStop = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();