Files
Codeman/docs/security-architecture.md
T
arkonandClaude Opus 4.8 a8e0e2a343 chore: release 0.9.0 — security hardening + warn-don't-block network policy
Release 0.9.0 covering the merged security/reliability PRs (#106 deps/
supply-chain, #107 auth/network, #108 test stability, #110 tmux cwd) plus:

- Network policy: a non-loopback bind without CODEMAN_PASSWORD now STARTS
  with a loud warning (3 ways to secure) instead of refusing to start.
  Loopback stays the safe default. --allow-unauthenticated-network just
  acknowledges (terser note). (src/web/server.ts start())
- Post-install security note explaining the loopback default + safe exposure.
- New docs/security-architecture.md documenting the full model (binding,
  auth pipeline, tunnel req.ip caveat, file-serving, supply-chain, isolation,
  recommended setups). CLAUDE.md Security section + gotcha updated.
- Updated auth-security test: asserts warn-and-start (not throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:29:47 +02:00

14 KiB
Raw Blame History

Security Architecture

This document describes Codeman's security model: how it decides who may reach the web UI, how requests are authenticated, how the file-serving and tmux layers are hardened, and the recommended ways to expose an instance safely.

Codeman spawns and drives Claude/OpenCode CLIs with --dangerously-skip-permissions. Anyone who can reach an unauthenticated instance can run arbitrary commands as your user. The defaults below are chosen so that a fresh install is safe on the machine it runs on, while remote access is an explicit, guided opt‑in.

TL;DR — Codeman binds loopback only (127.0.0.1) by default, so out of the box it is reachable only from the same machine and needs no password. To reach it from elsewhere, either put it behind an authenticated tunnel (tailscale serve / cloudflared) or bind a wider host and set CODEMAN_PASSWORD. If you bind a non‑loopback host with no password, Codeman still starts but prints a loud warning telling you how to secure it.


1. Network binding model

Setting Default Source
Bind host 127.0.0.1 (loopback) --host / CODEMAN_HOST → WebServer ctor
Port 3000 --port / CODEMAN_PORT
TLS off (--https to enable) --https

Bind host classification

isLoopbackBindHost() (src/web/network-auth-policy.ts) decides whether a bind host is loopback-only. It returns true for:

  • localhost
  • any IPv4 in 127.0.0.0/8 (e.g. 127.0.0.1, 127.42.0.9)
  • IPv6 loopback ::1 (bracketed [::1] and the long form 0:0:0:0:0:0:0:1)
  • IPv4‑mapped loopback ::ffff:127.*

It returns false for 0.0.0.0, :: (all interfaces), LAN IPs, and hostnames. The classification is fail‑safe in the dangerous direction: any host that is not provably loopback is treated as non‑loopback (it never mistakes 0.0.0.0 for loopback). Shorthand forms like 127.1 or integer/octal IPs classify as non‑loopback (you'll get a warning, not a silent wide‑open bind) — use 127.0.0.1 for an unambiguous loopback bind.

Startup policy (the "warn, don't block" rule)

At WebServer.start():

Bind host CODEMAN_PASSWORD Behavior
loopback (default) unset Start. Safe — reachable only from this machine.
loopback set Start. Auth required even locally.
non‑loopback set Start. Auth protects the open bind.
non‑loopback unset Start + LOUD warning listing how to secure it.
non‑loopback unset, --allow-unauthenticated-network Start + terse acknowledged note.

History: an earlier iteration (unreleased COD‑29) refused to start on a non‑loopback bind without a password. That surprised setups that "just worked" before, so 0.9.0 changed it to start‑and‑warn. Loopback is still the safe default; the warning (with three concrete fixes) replaces the hard failure.

The warning points at three ways to secure the instance:

  1. CODEMAN_PASSWORD=<password> — turns on HTTP Basic auth (see §2).
  2. --host 127.0.0.1 + an authenticated tunnel (cloudflared / tailscale serve).
  3. --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 — explicitly accept the risk (downgrades the warning to a one‑line note). This flag is only an acknowledgement; it does not change reachability.

CODEMAN_API_URL (used by hooks/child processes) is always derived as a loopback address (0.0.0.0/localhost/::1 → 127.0.0.1) so in‑process hooks reach the server over loopback regardless of the public bind.


2. Authentication

Auth is optional and controlled by env vars captured at startup:

  • CODEMAN_USERNAME (default admin when only a password is set)
  • CODEMAN_PASSWORD

When CODEMAN_PASSWORD is unset, no auth is enforced — which is why the default loopback bind matters. The auth pipeline (src/web/middleware/auth.ts, onRequest hook) runs in this order:

  1. Localhost‑only exemptions (always first): POST /api/hook-event and the QR /q/ short‑code path are exempt when req.ip is loopback (see §3).
  2. Session cookie check — a valid codeman_session cookie short‑circuits to allow.
  3. HTTP Basic check — correct credentials short‑circuit to allow and clear that IP's failure counter.
  4. Rate‑limit gate — if neither cookie nor credentials passed and the IP is locked out, return 429 with a Retry-After header.
  5. Otherwise return 401, incrementing the IP's failure counter.

Session cookies

On successful Basic auth the server issues codeman_session, an opaque server‑side token (randomBytes(32)), valid 24h with auto‑extend and device context for the audit log. Tokens are not client‑signed — they're validated by presence in a server‑side map, so they cannot be forged offline.

Rate limiting / lockout recovery

Failed auth is tracked per IP: 10 failures → 429, with a 15‑minute decay. The QR path has its own separate limiter.

The lockout check sits after the cookie/credential checks (step 4, not first). This is deliberate: a user with a valid cookie or correct password recovers immediately even while an attacker is hammering the same IP — important because all traffic through a tunnel shares one source IP (loopback). Wrong credentials are still counted and still hit the 429 at the threshold, so brute‑force protection is unchanged.


3. Request‑origin trust & the tunnel caveat

req.ip is derived from the TCP socket only — Fastify runs with trustProxy: false, so X-Forwarded-For / X-Real-IP / Forwarded are ignored. A remote client cannot forge req.ip to 127.0.0.1.

However, a reverse tunnel that connects to the server over loopback (e.g. cloudflared --url http://localhost:3000) makes every tunneled request arrive with req.ip = 127.0.0.1. The localhost‑only exemptions then treat those requests as local:

  • POST /api/hook-event — auth‑exempt for loopback. Bounded impact: it is HookEventSchema‑validated and requires a valid in‑memory sessionId; it can drive respawn signals, SSE broadcasts, push notifications, and transcript watching — not arbitrary terminal input or file reads. It is a session‑disruption / notification‑spoofing surface, not RCE.
  • QR /q/ — still protected by its own short‑code brute‑force limiter (10 failures / 60s against a 62⁶ space).

Mitigation: set CODEMAN_PASSWORD whenever a loopback‑connecting tunnel is up (it does not gate the hook‑event exemption, but it gates everything else and is the documented practice). Prefer tailscale serve (below), which authenticates at the tailnet layer so untrusted clients never reach the loopback port at all. A future hardening could gate the hook‑event exemption on a shared secret while a tunnel is active.


Ordered most‑to‑least recommended:

Bind loopback, let Tailscale front it on your tailnet with a real cert:

codeman web --https            # binds 127.0.0.1:3000
tailscale serve --bg https / http://127.0.0.1:3000

Only devices on your tailnet can reach it; Tailscale handles identity. No app password and no 0.0.0.0 bind required. (This is the maintainer's production setup.)

B. Authenticated cloudflared tunnel + password

export CODEMAN_PASSWORD=<password>
codeman web --https
cloudflared tunnel --url https://localhost:3000

Always set CODEMAN_PASSWORD here — the tunnel connects over loopback, so the hook‑event exemption (§3) would otherwise be reachable from the public URL.

C. Direct LAN bind + password

export CODEMAN_PASSWORD=<password>
codeman web --https --host 0.0.0.0

Exposes the port on all interfaces; the password is the only thing protecting it.

Avoid

--host 0.0.0.0 without a password. Codeman will start (and warn), but anyone on the network can control your Claude sessions. Never re‑expose 0.0.0.0 without a password.


5. File‑serving hardening

Three routes serve workspace files; all require a valid sessionId and run the shared path validator validateSessionFilePath() (src/web/route-helpers.ts): it realpaths the target before the boundary check and rejects anything that escapes the session working directory (.., absolute paths, and symlinks that resolve outside). The realpath‑before‑check ordering closes the validation‑time TOCTOU window.

Route Cap Notes
file-content 10 MB text preview
file-raw 50 MB inline MIME map; X-Content-Type-Options: nosniff on all responses
POST /api/download 50 MB forced attachment; sensitive‑path blocklist

SVG / content‑type XSS

A workspace .svg served inline as image/svg+xml is a stored‑XSS vector (SVG can carry <script>, same‑origin = full session control). file-raw therefore serves .svg as application/octet-stream + Content-Disposition: attachment + nosniff. With global nosniff + CSP default-src 'self', other text types (.html, .xml, …) that fall through to octet-stream are not rendered as HTML either. Trusted QR/welcome SVGs are injected from API JSON (innerHTML), not via file-raw, so they are unaffected.

Download sensitive‑path blocklist

/api/download additionally refuses a blocklist of sensitive paths (/etc/shadow, ~/.ssh/, .env, *credentials*, .aws/credentials, …). This is defense‑in‑depth, not the primary boundary — the realpath containment is the control.

Known limitation — workingDir scope

The file‑route boundary is the session's workingDir, and POST /api/sessions currently accepts an arbitrary absolute workingDir (validated as "exists + is a directory"). A session created with workingDir=/ can therefore read files across the filesystem within that boundary. This is pre‑existing across all file routes and not widened by the recent changes. Recommended follow‑up: constrain workingDir to an allowlist (e.g. under the cases dir / $HOME).


6. tmux launch hardening (COD‑31)

New sessions and respawns launch the tmux server/pane from a stable /tmp (TMUX_LAUNCH_CWD) and then cd into the real workspace inside the pane, against the live mount table:

respawn-pane -k -c /tmp -t <session> bash -c "cd <workingDir> && <cmd>"

This avoids a class of failures on FUSE/rclone‑mounted workspaces where a transient mount blip at launch poisons tmux's long‑lived cwd and crashes new-session. Safety properties:

  • Fail‑safe cwd: the command is cd "<dir>" && <cmd> — if cd fails the CLI does not run in /tmp; the pane dies with a visible error instead.
  • No injection: workingDir passes isValidWorkingDir (absolute, rejects ;&|$\(){}<>'"and newlines and..) and isValidPath`, and is double‑quoted in the pane command. Paths with spaces work; metacharacters are rejected before reaching the shell.
  • It does not change which tmux socket is targeted, so instance isolation (§8) is preserved.

7. Supply‑chain & build‑asset hardening (COD‑28)

  • Dependency advisories: security‑sensitive ranges are bumped to patched versions, and overrides force patched transitive deps (picomatch, basic-ftp, fast-uri, flatted). test/dependency-security.test.ts asserts these stay patched in the lockfile.
  • Lockfile integrity: npm run check:lockfile (CI on every push/PR) fails on drift between package.json and package-lock.json. All lockfile entries resolve to registry.npmjs.org with sha512 integrity hashes.
  • Public‑asset checker: npm run check:public-assets (scripts/check-public-assets.mjs) scans src/web/public/** for literal NUL bytes and runs node --check on every .js file (syntax validation), plus a Prettier pass on maintained files. It uses execFileSync with argv arrays (no shell), so filenames/content cannot inject commands; node --check only parses, never executes. Large hand‑formatted/generated assets (app.js, the gesture bundle, vendored libs) are .prettierignored for the style pass, but the NUL + syntax checks still cover them.

8. Multi‑instance isolation

The tmux socket (tmux -L codeman[-<instance>]) and data dir (~/.codeman[-<instance>]) are process‑wide and shared by every Codeman on the machine, derived from CODEMAN_INSTANCE (src/config/instance.ts). A second instance on the same socket discovers and attaches PTYs to the first instance's live sessions. To run instances side by side, give each a distinct CODEMAN_INSTANCE (scopes both dir + socket), or set CODEMAN_TMUX_SOCKET + CODEMAN_DATA_DIR individually. CODEMAN_INSTANCE defaults to empty = the production layout (~/.codeman, -L codeman, port 3000).


9. Transport security headers

registerSecurityHeaders applies on every response:

  • Content-Security-Policy: default-src 'self' (widened only for /gesture/ assets when CODEMAN_GESTURE=1, to load self‑hosted MediaPipe)
  • X-Content-Type-Options: nosniff
  • X-Frame-Options
  • Strict-Transport-Security when served over HTTPS
  • CORS restricted to localhost origins

10. Quick reference

Env / flag Effect
CODEMAN_PASSWORD (+ CODEMAN_USERNAME) Enable HTTP Basic auth
--host / CODEMAN_HOST Bind host (default 127.0.0.1)
--allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK Acknowledge an unauthenticated non‑loopback bind (downgrades the warning)
--https Enable TLS (adds HSTS)
CODEMAN_INSTANCE Scope tmux socket + data dir for isolation
CODEMAN_GESTURE=1 Make the gesture overlay available (widens CSP)

Audit log: session lifecycle and server start are recorded in ~/.codeman/session-lifecycle.jsonl.