# 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. It covers the bind and nothing else: it does **not** let the managed Cloudflare tunnel start without a password, which has its own opt‑in (§2, the tunnel caveat). `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). The three web‑tab exemptions (§10b: the capability in the path, the `Referer` form, and the lost‑frame recovery page) sit in this same slot, ahead of the credential checks. 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 logins are tracked **per IP**: 10 failures (`AUTH_FAILURE_MAX`) → `429`, with a 15‑minute decay. Multi-user mode replaces that with three buckets, all with the same decay: a wider per-IP **pre-gate** of 50 failures (`AUTH_IP_FAILURE_MAX`), checked before the scrypt verify, which bounds scrypt cost from one source and tolerates many users behind one egress IP; a **per-(username, IP)** gate of 10 failures (`AUTH_FAILURE_MAX`), also checked before the verify, which is what caps verified guesses against one account from one source; and a **per-username** bucket, checked after the verify, that answers `429` to failed guesses from the 10th on, from any IP (the count is incremented before the check). The last two exist in multi-user mode only. So the wider per-IP cap does not widen guessing against any one account from one IP, and other IPs cannot lock an account out. 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. The two multi-user pre-verify gates are the exception: once the per-IP pre-gate or an account's (username, IP) gate trips, even a correct password from that IP gets `429` until the decay (for the pair gate, only for that account; the same account from another IP still logs in), which is why the per-IP cap is the widest. The per-username bucket stays after the verify on purpose: checked first, a username-wide lockout let throwaway IPs keep a known account (admin included) locked out even with the right password. Both pre-verify gates count a request the moment it passes them, before its verify starts, so a burst of concurrent attempts cannot all slip through on one stale count; a correct password then clears its slot with the rest, and a failure is not counted a second time. A verify still in flight therefore holds a slot, even one carrying the right password, but identical credentials from the same IP that arrive during it share that verify, its slot and its outcome, and skip both gates: they add no guess and no scrypt work. So a browser replaying its cached login on many parallel requests after a restart (cookie sessions live in memory) is not refused by the slots its own password filled. --- ## 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 a login (`CODEMAN_PASSWORD` or multi‑user mode) unless `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` acknowledges the exposure, or the request carries `acknowledgeUnauthTunnel: true` (what the browser sends after its confirm). The network acknowledgement (`CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK`) does **not** count: the installer writes it for every LAN install without a password, so it once turned a click on the welcome screen's tunnel link into a public, login‑free URL with no confirm. With no login the browser asks before every enable, whatever the server would accept, reading `loginConfigured` from `GET /api/tunnel/status`. 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. **The installer sets this up for you**: choose **Tailscale** at the network-access prompt, or retrofit an existing install with: ```bash bash ~/.codeman/app/install.sh tailscale ``` The guided flow installs Tailscale if needed, walks through login and the tailnet HTTPS-certificates toggle, and configures the equivalent of: ```bash codeman web # binds 127.0.0.1:3000 (plain HTTP is fine here) tailscale serve --bg 3000 # HTTPS at https://..ts.net ``` Only devices on your tailnet can reach it; Tailscale handles identity and terminates TLS with a real Let's Encrypt certificate (so PWA install and web push work). No app password and no `0.0.0.0` bind required. (This is the maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets the choice for automation. When `:443` on the node already belongs to another app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path` plus `--base-url`, which keeps the loopback bind and the same host guard) or on a second port rather than replacing it. The installer never runs `tailscale serve reset`, never touches serve mappings other than the one it created, never opens a `tailscale funnel` (public internet, a different risk class) and never advertises a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in and defaults to no, because the tailnet name is also the machine's SSH identity. ### 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. A local session has one more root while its agent works in a git worktree of the same repository OUTSIDE its folder (published as `outsideWorktree`). An absolute path inside that worktree is confined to it by `validateLocalSessionFilePath()`, and only while the worktree folder still `realpath`s to the path recorded for it. The edit route (`PUT /api/sessions/:id/file-content`) uses the same check, so a file in that worktree can be written as well as read. Codeman records such a worktree only after the session's own repository vouches for it, and never `/`, the home folder or above, a folder holding the working directory, or a folder at, inside or around a Docker case workspace (`src/live-worktree.ts`). It also refuses a folder that is itself a sensitive folder such as `.ssh`, and `validateLocalSessionFilePath()` never serves a file from the worktree that `isSensitivePath()` blocklists. In multi-user mode a regular owner's session also needs the owner's folder rule (their own space or the working directory); an admin's does not. It follows nothing while attachment workspace confinement is on, and nothing at, inside or around a blocked attachment tree. Codeman judges these rules when the pane changes directory, and the routes judge confinement and the blocked trees again on every request, so turning confinement on or blocking the folder stops serving it at once. The multi-user rule is judged only when the pane changes directory. On every request the routes also check that the folder still resolves to its recorded path. A relative path is still confined to the working directory alone. | Route | Cap | Notes | |-------|-----|-------| | `file-content` | 10 MB | text preview | | `file-raw` | 2 GB (`CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) | | `GET /api/download` | same cap | forced `attachment`; sensitive‑path blocklist; streamed, `Range`-aware | ### SVG / content‑type XSS A workspace `.svg` served inline as `image/svg+xml` is a stored‑XSS vector (SVG can carry `