# 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. --- ## Contents 1. [Network binding model](#1-network-binding-model) 2. [Authentication](#2-authentication) 3. [Request‑origin trust & the tunnel caveat](#3-requestorigin-trust--the-tunnel-caveat) 4. [Recommended remote‑access setups](#4-recommended-remoteaccess-setups) 5. [File‑serving hardening](#5-fileserving-hardening) 6. [tmux launch hardening](#6-tmux-launch-hardening-cod31) 7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28) 8. [Multi‑instance isolation](#8-multiinstance-isolation) 9. [Transport security headers](#9-transport-security-headers) 10. [Docker container isolation](#10-docker-container-isolation) 11. [Quick reference](#11-quick-reference) --- ## Trust model **The security boundary is the network bind plus authentication — not the code Codeman runs.** Because sessions launch with `--dangerously-skip-permissions`, the web UI is by design a remote‑code‑execution surface for whoever is allowed to reach it. Everything below exists to control *who* that is. | Actor | Reaches the UI when… | Is granted | |-------|----------------------|------------| | Same‑machine user | Always (default loopback bind) | Full session control — the intended local‑use case. | | Authenticated remote client | Tunnel/LAN reachability **and** a valid password or session cookie | Full session control. | | Unauthenticated remote client | Only if you bind a non‑loopback host with no password | Full session control — the exact case every default and warning works to prevent. | | Clients behind a loopback‑connecting tunnel | A reverse tunnel terminates on `127.0.0.1` | Inherit `req.ip = 127.0.0.1`, so they hit the localhost‑only exemptions (§3) unless a password is set. | **Explicitly out of scope.** Codeman is access control for the operator console, not a sandbox for the code that console runs. It does **not** defend against: a compromised local user account (loopback is trusted), malicious contents in a workspace you deliberately open, or the breadth of filesystem a session's `workingDir` is pointed at (§5). --- ## 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=` — 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). While the **managed tunnel is running**, the hook‑event exemption additionally requires the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures) so misfiring hooks can never lock out the login path. 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 **only while no managed tunnel is running**. When Codeman's own tunnel is up, the exemption requires the per‑instance shared secret (`X-Codeman-Hook-Secret`, 256‑bit hex in `~/.codeman/hook-secret`, mode 0600, COD‑54). Local hook commands read the secret file at execution time (`$CODEMAN_HOOK_SECRET_FILE`, exported into every managed session), so they keep working — tunneled internet traffic can't know it. Even without the secret the impact is bounded: the route 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. ⚠️ The gate keys off the **managed** tunnel — an externally run loopback proxy (your own `cloudflared`, `tailscale serve`) is invisible to it, so the plain loopback exemption still applies there (prefer `tailscale serve`, which authenticates at the tailnet layer). Hook configs regenerated since COD‑54 always present the header, so a future release can require the secret unconditionally. - 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 gates everything except the (secret‑gated) hook exemption and is the documented practice; since COD‑55 enabling the managed tunnel **refuses** to start without it unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` explicitly acknowledges the exposure. Prefer `tailscale serve` (below), which authenticates at the tailnet layer so untrusted clients never reach the loopback port at all. ### Host‑header & Origin allowlist (DNS‑rebinding & CSRF defense) Since **0.9.5** an **always‑on** `onRequest` hook (`registerHostGuard`, `src/web/middleware/auth.ts`; policy in `src/web/network-auth-policy.ts`) runs **before** the auth pipeline in §2 and guards **every** request — including the localhost‑only exemptions above, SSE, the WebSocket upgrade, and static files. It closes the browser‑driven RCE path (DNS rebinding plus a cross‑site `text/plain` `POST`) that the loopback‑no‑password default otherwise exposed to any site the operator merely visits. - **Host allowlist (anti‑DNS‑rebinding).** The `Host` header is validated on **every** request, all methods. A custom domain rebound to `127.0.0.1` is rejected with `403 Forbidden: host not allowed` before any handler runs. Allowed: `localhost`; **any** IP literal (IPv4/IPv6 — a browser hitting a numeric address can't be a rebinding victim); the bind host; the suffixes `.ts.net`, `.trycloudflare.com`, `.cfargotunnel.com`; the hostname of the active Codeman‑managed tunnel; and anything in `CODEMAN_ALLOWED_HOSTS`. A missing/empty `Host` is rejected. - **Origin / CSRF guard.** On **state‑changing** methods (everything except `GET`/`HEAD`/`OPTIONS`) the `Origin` header must also pass the same allowlist, else `403 Forbidden: cross‑site request blocked`. A **missing `Origin` is allowed** (so `curl`, the CLI, and Claude Code hooks keep working); only a present‑but‑foreign origin — or the opaque `null` origin (sandboxed iframe) — is rejected. This blocks the cross‑site CSRF that could previously create sessions, trigger self‑update, or flip `tunnelEnabled`. - **Raw `text/plain` bodies.** The global `text/plain` content‑type parser no longer JSON‑parses bodies — it hands handlers the raw string (`/api/crash-diag` self‑parses its beacon payload). This removes the CORS "simple request" CSRF vector, where a cross‑site `fetch` with `Content-Type: text/plain` smuggled a JSON body into a write route with no preflight — defense‑in‑depth alongside the Origin guard. - **WebSocket upgrades.** The terminal WS upgrade (`src/web/routes/ws-routes.ts`) runs the **same** Host + Origin check and closes with code `4003` on failure (anti‑CSWSH). The policy is rebuilt per request from `buildHostPolicy(bindHost, tunnelManager.getUrl())`, so starting or stopping a tunnel at runtime updates the allowlist with no restart. > **Reverse‑proxy operators:** a custom proxy domain (e.g. `codeman.example.com`) > is **not** in the default allowlist and gets `403 host not allowed`. Add it via > `CODEMAN_ALLOWED_HOSTS` — comma‑separated, case‑insensitive; an exact hostname > matches only itself, while a leading‑dot entry (`.corp.internal`) matches the > bare domain **and** all subdomains. Behaviour is covered by > `test/network-host-guard.test.ts`. --- ## 4. Recommended remote‑access setups Ordered most‑to‑least recommended: ### A. Tailscale serve (recommended) Bind loopback, let Tailscale front it on your tailnet with a real cert: ```bash 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 ```bash export CODEMAN_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 ```bash export CODEMAN_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 `realpath`s 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 `