feat: image paste (Ctrl+V) and drag-and-drop into terminal#84
Conversation
Clipboard paste (Ctrl+V) and drag-and-drop of image files into the
terminal. Images are saved to {workdir}/.claude-images/ and the
absolute path is inserted into the terminal input for Claude to read.
- POST /api/sessions/:id/paste-image endpoint (hand-parsed multipart)
- image-input.js mixin with paste trap technique (works on HTTP)
- Ctrl+V intercepted at xterm keyboard level, routes through hidden
contenteditable div to capture both image and text clipboard data
- Drag-and-drop on terminal container with visual overlay
- Session cleanup deletes .claude-images/ on destroy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ark0N
left a comment
There was a problem hiding this comment.
Security review
Verdict: unsafe to merge as-is. Two HIGH-severity issues, four MED, two LOW. Filenames are correctly server-regenerated and auth-gating is intact — the issues below are real but all addressable.
HIGH severity
1. Stored XSS via SVG upload. ALLOWED_IMAGE_EXTS and the Content-Type regex both accept .svg / image/svg+xml (src/web/routes/session-routes.ts:1446, 1513). The saved file lands under session.workingDir/.claude-images/, which is reachable via the existing GET /api/sessions/:id/file-raw?path=.claude-images/<f>.svg route in src/web/routes/file-routes.ts:240-308. That endpoint serves it with Content-Type: image/svg+xml, same-origin. CSP is script-src 'self' 'unsafe-inline' (src/web/middleware/auth.ts:162), so <script> embedded in the SVG executes with full Codeman API access via the session cookie. X-Content-Type-Options: nosniff does not block this — SVG is the declared MIME, not sniffed.
Exploit: attacker (authenticated, or anyone when CODEMAN_PASSWORD is unset and the instance is tunneled) POSTs an SVG containing <script>fetch('/api/sessions',{method:'DELETE'...})</script>, then induces any logged-in admin to open the file via the file browser. Same-origin script → drives every Codeman API.
Fix: drop .svg from the allowlist on session-routes.ts:1446 and the regex on :1513. If SVG must stay, serve it with Content-Disposition: attachment from a separate origin.
2. No CSRF protection. The cookie codeman_session is SameSite=lax (src/web/middleware/auth.ts:135). No Origin/Referer check, no CSRF token, no custom-header requirement on this state-changing endpoint. multipart/form-data is a "simple" CORS request type; a cross-origin <form enctype="multipart/form-data" target="_blank"> POST will carry the session cookie under SameSite=lax.
Exploit: malicious page auto-submits a form to https://<tunnel>/api/sessions/<id>/paste-image with an SVG payload. When an authenticated admin visits the page in another tab, the cookie is attached and the SVG is written. Chains with #1 → one-click full compromise.
Fix: require an Origin header match against an allowlist (or an X-Codeman-CSRF header that <form> cannot send) on state-changing routes.
MED severity
- No magic-byte validation. Extension and
Content-Typeare both attacker-supplied; the actual file bytes are never sniffed. Polyglot PNG/HTML files pass. Add a 16-byte header check (PNG89 50 4E 47, JPEGFF D8 FF, GIFGIF8[79], WebPRIFF…WEBP, BMPBM) and reject mismatches with 415. - Symlink write into arbitrary paths.
existsSync(imageDir)andmkdirSync(imageDir, {recursive:true})(session-routes.ts:1538-1540) both follow symlinks. If a process insideworkingDir(e.g., the agent itself, or a malicious dep) plants.claude-images → ~/.ssh/, subsequent paste-images write into~/.ssh/. Uselstatand verify the resolved path is still undersession.workingDir— mirrorvalidateSessionFilePathinsrc/web/route-helpers.ts:59-75. - Disk-fill DoS. No rate-limit on
/api/sessions/:id/paste-image, no aggregate cap, cleanup only onkillMux=true. An authenticated attacker can loop 10 MB POSTs. Add a per-IP token-bucket (e.g., 30/min) and periodic GC of files older than 7d. - Hand-rolled multipart parser.
indexOf(boundaryBuf)matches the literal anywhere, and the parser hard-codes\r\n\r\n/+2/-2offsets — LF-only clients silently corrupt the last byte. Replace with@fastify/multipart, or at minimum anchor the boundary search to\r\n--and cap the number of parts.
LOW severity
- Filename collision on same-ms uploads.
paste-${Date.now()}${ext}collides under concurrent paste from two tabs; last-write-wins silently. AppendrandomBytes(8).toString('hex'). - Bracketed-paste bypass. The Ctrl+V interceptor in
src/web/public/terminal-ui.js:86-92swallows all paste events and re-sends viasendInput(), stripping xterm's bracketed-paste markers. Claude CLI can no longer distinguish typed-vs-pasted input, weakening downstream prompt-injection defences. ReturntruefromcustomKeyEventHandlerwhen the clipboard contained no image, letting xterm's native paste flow handle text.
What's fine
- Saved filename is server-regenerated —
filename=is only read for.ext, so../../etc/passwdcannot escape the directory. - The streaming 10 MB cap correctly early-aborts rather than post-buffer checking.
- Auth gating: the route falls under the standard
onRequestmiddleware; no bypass list regression.
Recommended fix order
- Drop
.svgfrom the allowlist (session-routes.ts:1446, 1513). - Add Origin/Referer check (or CSRF token) for state-changing routes.
- Magic-byte validation against the first chunk before writing.
- Symlink check on
imageDirvialstat. - Rate-limit + periodic GC of
.claude-images/. - Append
randomBytes(8)to filenames. - Consider swapping the hand-rolled parser for
@fastify/multipart.
Drops .svg / image/svg+xml from the paste-image endpoint. SVGs are served as image/svg+xml via /api/sessions/:id/file-raw, same-origin, under a CSP that permits inline scripts — which would execute on view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SVG removed from allowlist (commit a60a9a7) closes the stored-XSS path. CSRF mitigation tracked separately — not blocking merge given current trust model.
Summary
Lets users paste or drop images into the terminal. The image is saved to
${workdir}/.claude-images/and the absolute file path is inserted at the cursor — Claude (or any other CLI that reads file paths from stdin) can then process the image without the user having to manually save and@-mention it.Two entry points:
Mixed-content paste (image + text together) works — both the image is saved as a file and the text is forwarded to the terminal.
Implementation
POST /api/sessions/:id/paste-image— hand-parsed multipart endpoint (no@fastify/multipartdependency). Saves under${session.workingDir}/.claude-images/img-${ts}.${ext}and returns the absolute path.src/web/public/image-input.js— new mixin that wires both paste and drop into the existing terminal:contenteditablediv which receives thepasteevent (this is the only reliable way to capture clipboard image data over plain HTTP, where the async Clipboard API requires a secure context). Both image bytes and text data from the same paste are extracted, then focus returns to xterm.image/*file on drop.src/web/routes/session-routes.ts— multipart parser (~100 lines) and the route handler. Body length is bounded to 10 MB and the image MIME type is validated against an allowlist (PNG/JPEG/WebP/GIF).src/web/server.ts— wiresimage-input.jsinto the static asset list and adds session-cleanup so.claude-images/is removed on session destroy (no orphan disk usage).src/web/public/{index.html,styles.css}— script tag + drop-overlay styling.The
.claude-images/directory is created lazily on first paste and is the only file the feature writes — no DB rows, no global state.Test plan
${workdir}/.claude-images/and its absolute path is typed into the terminal.text/plainmasquerading as image): rejected with 415..claude-images/directory removed.🤖 Generated with Claude Code