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('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/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.
) 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>
PR #84 added `src/web/public/image-input.js` (clipboard paste + drag-drop) but the CLAUDE.md frontend module table wasn't updated. Bumps the feature modules count from 4 to 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl+V in the terminal inserted the clipboard text twice. Right-click →
Paste inserted it once.
`_handleImagePaste()` appends a hidden contenteditable div, focuses it, and
reads the clipboard out of the paste event that lands there. Two separate
routes deliver that event for a single keypress. The function issues
`document.execCommand('paste')` itself, which in Firefox dispatches a
trusted paste event and then returns false, because the trap cancels the
event and the command never completes; Chromium and WebKit refuse that
command and dispatch nothing. The keydown's own default action delivers the
other, because xterm calls the custom key handler before its own `cancel()`,
so returning false never calls preventDefault. Firefox therefore ran the
trap's listener twice and both runs reached `terminal.paste()`. The
context-menu paste involves no keydown at all, which is why that path stayed
correct.
The trap now accepts the first paste event and cancels every later one, so
how many paste events a browser delivers no longer changes what the PTY
sees. Measured on a live install, one Ctrl+V each: Firefox two events and
two writes before this change, Chromium and WebKit one and one, and every
engine one write after it.
The `execCommand('paste')` call stays. Stripping it out also ends the
doubling, and all three engines still deliver one event without it, since
`trap.focus()` has already run when the key's default action resolves. It is
kept because the trap technique arrived in #84 for plain HTTP and for
mobile, and a desktop measurement says nothing about real iOS Safari or
Android Chrome: where a browser aims the default action at the element
focused when the keydown began, the command is the only route into the trap,
and the trap is the only place clipboard image blobs are read.
test/image-paste-trap.test.ts loads image-input.js into a `node:vm` context
with a fake document and fires two paste events at the trap. It covers text
and images, and fails on the old code with the text pasted twice and the
image uploaded twice.
Docs: the invariant goes into docs/architecture-invariants.md as a Terminal
paste section and into CLAUDE.md as a Frontend entry, both recording the
measured event counts and why the redundant call is still there. README.md
and the Keyboard Shortcuts and Input and Voice wiki pages gain a Ctrl+V row,
which all three tables were missing while listing every other clipboard
binding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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