docs: refresh README + document v0.9.5 security hardening

README: add a dedicated Security section (always-on Host/Origin
allowlist & DNS-rebinding defense, cross-site CSRF guard, raw
text/plain parser, WebSocket origin validation, XSS-escaped agent
output), plus an Orchestrator Loop section, Agent Teams, and a
More Features section (self-update, dual-CLI, effort/ultracode,
voice, image, gesture, multi-monitor, CJK). Correct stale stats
(tests 1435->2861, 13->15 route modules, 14->16 types, 9->10
config, server.ts 2697->2254, 9->18 frontend modules), fix the
keyboard-shortcut table to match the actual registry (drop the
unbound Ctrl+Enter/Ctrl+K), repoint a moved doc link, add
Orchestrator + self-update API rows, and add Orchestrator/Team
Watcher to the architecture diagram.

security-architecture.md: document the always-on Host-header &
Origin allowlist, text/plain hardening, WebSocket check, XSS
escaping, and CODEMAN_ALLOWED_HOSTS.

CLAUDE.md: add Host guard / CSRF guard rows + CODEMAN_ALLOWED_HOSTS.

security review report: add a remediation-status banner (the
pre-fix TL;DR now reads as v0.9.4 state; fixed in c669518).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-09 04:35:29 +02:00
co-authored by Claude Opus 4.8
parent 5adf044399
commit 8fc139d671
4 changed files with 168 additions and 20 deletions
+3 -1
View File
@@ -187,11 +187,13 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|-------|---------| |-------|---------|
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) | | **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` | | **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) |
| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` | | **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit | | **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter | | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) | | **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks) | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist | | **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+99 -15
View File
@@ -5,7 +5,7 @@
<h2 align="center">The missing control plane for AI coding agents</h2> <h2 align="center">The missing control plane for AI coding agents</h2>
<p align="center"> <p align="center">
<em>Agent Visualization &bull; Zero-Lag Input Overlay &bull; Mobile-First UI &bull; Respawn Controller &bull; Multi-Session Dashboard </em> <em>Agent Visualization &bull; Zero-Lag Input &bull; Autonomous Orchestrator &bull; Respawn Controller &bull; Mobile-First UI &bull; Hardened Security</em>
</p> </p>
<p align="center"> <p align="center">
@@ -13,7 +13,7 @@
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a> <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a> <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a> <a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-1435%20total-22c55e?style=flat-square" alt="Tests"> <img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
</p> </p>
<p align="center"> <p align="center">
@@ -34,7 +34,7 @@ You'll need at least one AI coding CLI installed — [Claude Code](https://docs.
```bash ```bash
codeman web codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session # Open http://localhost:3000 and start your first session
``` ```
<details> <details>
@@ -177,6 +177,8 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count - **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
- **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents) - **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents)
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
--- ---
## Zero-Lag Input Overlay ## Zero-Lag Input Overlay
@@ -214,6 +216,20 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
--- ---
## Orchestrator Loop
Beyond single-session respawn, the **Orchestrator** turns a high-level goal into a phased plan and drives it to completion across multiple agents — a state machine that runs `idle → planning → approval → executing → verifying → (replanning) → completed`.
- **Plan, then execute** — generates a phased plan from your goal and pauses for approval before touching anything; reject with feedback to regenerate
- **Per-phase verification gates** — each phase is verified before the next begins; on failure the orchestrator replans instead of barreling ahead
- **Multi-agent execution** — fans phases out to team agents / a task queue, coordinating work too big for one session
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
---
## Multi-Session Dashboard ## Multi-Session Dashboard
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management. Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
@@ -270,6 +286,20 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
--- ---
## More Features
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Dual-CLI** — run **Claude Code** or **OpenCode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
- **Gesture control** *(opt-in)* — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** *(macOS)* — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
---
## Remote Access — Cloudflare Tunnel ## Remote Access — Cloudflare Tunnel
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required. Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
@@ -391,6 +421,41 @@ When someone authenticates via QR, the desktop shows a notification toast with t
--- ---
## Security
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control *who* that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md).
### Network & access
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` *starts but prints a loud warning* with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers *immediately* even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
### Always-on browser hardening (v0.9.5)
These run for **every** request — before auth, even on the default no-password loopback install:
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A *missing* Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` env-prefix allowlist gates which settings each CLI can receive
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
### Supply chain & isolation
- **Pinned & verified deps** — security-sensitive transitive deps are forced to patched versions via npm `overrides`; lockfile integrity is checked on every commit/PR (all entries resolve to `registry.npmjs.org` with `sha512` hashes). Public assets are NUL-byte-scanned and `node --check`-validated in CI
- **Multi-instance isolation** — `CODEMAN_INSTANCE` scopes both the tmux socket (`-L codeman-<name>`) and data dir (`~/.codeman-<name>`) so two instances never attach each other's live sessions
> Mobile login uses single-use, 60-second QR tokens — see [QR Code Authentication](#qr-code-authentication) above for the full design (it addresses all 6 flaws from USENIX Security 2025's QR-login study).
---
## SSH Alternative (`sc`) ## SSH Alternative (`sc`)
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser: If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
@@ -407,24 +472,28 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
## Keyboard Shortcuts ## Keyboard Shortcuts
> Ctrl bindings also accept Cmd on macOS.
| Shortcut | Action | | Shortcut | Action |
|----------|--------| |----------|--------|
| `Ctrl+Enter` | Quick-start session | | `Ctrl/Cmd+W` | Kill active session |
| `Ctrl+W` | Close session | | `Ctrl/Cmd+Tab` | Next session |
| `Ctrl+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N | | `Alt+1`–`Alt+9` | Switch to tab N |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl+K` | Kill all sessions | | `Ctrl/Cmd+L` | Clear terminal |
| `Ctrl+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size | | `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input | | `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +/-` | Font size | | `Ctrl/Cmd +` / `-` | Font size |
| `Escape` | Close panels | | `Ctrl/Cmd+?` | Keyboard help |
| `Shift+Enter` | Insert newline (sent to terminal) |
| `Escape` | Close panels & modals |
--- ---
## API ## API
REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. A representative subset:
### Sessions ### Sessions
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
@@ -446,6 +515,14 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos | | `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking | | `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
### Orchestrator
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
| `GET` | `/api/orchestrator/status` | Current phase + progress |
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
### Subagents ### Subagents
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
@@ -460,6 +537,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/events` | SSE stream | | `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state | | `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks | | `POST` | `/api/hook-event` | Hook callbacks |
| `GET` | `/api/system/update/check` | Check for a new release |
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) | | `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats | | `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
@@ -481,11 +560,13 @@ flowchart TB
S1["Session (PTY)"] S1["Session (PTY)"]
S2["Session (PTY)"] S2["Session (PTY)"]
RC["Respawn Controller"] RC["Respawn Controller"]
ORC["Orchestrator Loop"]
end end
subgraph Detection["Detection Layer"] subgraph Detection["Detection Layer"]
RT["Ralph Tracker"] RT["Ralph Tracker"]
SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"] SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"]
TW["Team Watcher<br/><small>~/.claude/teams/*</small>"]
end end
subgraph Persistence["Persistence Layer"] subgraph Persistence["Persistence Layer"]
@@ -505,14 +586,17 @@ flowchart TB
SM --> S1 SM --> S1
SM --> S2 SM --> S2
SM --> RC SM --> RC
SM --> ORC
SM --> SS SM --> SS
S1 --> RT S1 --> RT
S1 --> SCR S1 --> SCR
S2 --> SCR S2 --> SCR
RC --> SCR RC --> SCR
ORC --> SCR
SCR --> CLI SCR --> CLI
SW --> BG SW --> BG
SW --> SSE SW --> SSE
TW --> SSE
``` ```
--- ---
@@ -537,13 +621,13 @@ The codebase went through a comprehensive 7-phase refactoring that eliminated go
| Phase | What changed | Impact | | Phase | What changed | Impact |
|-------|-------------|--------| |-------|-------------|--------|
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency | | **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
| **Route extraction** | `server.ts` split into 13 domain route modules + auth middleware + port interfaces | **−60%** server.ts LOC (6,736 → 2,697) | | **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
| **Domain splitting** | `types.ts` → 14 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files | | **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
| **Frontend modules** | `app.js` → 9 extracted modules (constants, mobile, voice, notifications, keyboard, CJK input, API, Ralph wizard, subagent windows) | **−24%** app.js LOC (15.2K → 11.5K) | | **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
| **Config consolidation** | ~70 scattered magic numbers → 9 domain-focused config files | Zero cross-file duplicates | | **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` | | **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
Full details: [`docs/code-structure-findings.md`](docs/code-structure-findings.md) Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
--- ---
@@ -1,5 +1,16 @@
# Codeman Security Review — 2026-06-09 # Codeman Security Review — 2026-06-09
> **⚠️ Remediation status (updated 2026‑06‑09):** the two CRITICALs and 5 of the 7
> HIGHs below were **fixed the same day in commit `c669518` (shipped as 0.9.5)** —
> an always‑on `Host`‑header + cross‑site `Origin` allowlist (`registerHostGuard`),
> a raw `text/plain` body parser, a WebSocket `Origin`/`Host` check, and
> HTML‑escaped subagent‑panel sinks. **The present‑tense "is exploitable" wording
> below describes the pre‑fix v0.9.4 state.** Still open: **H2** (the self‑updater
> trusts an unsigned git tag — needs signing infra) and dropping CSP
> `'unsafe-inline'` (needs a nonce migration; H4's escaping already neutralises the
> known XSS). Per‑finding breakdown in the *Implementation status* section below;
> regression tests in `test/network-host-guard.test.ts`.
**Scope:** whole codebase (branch `master`, v0.9.4). Adversarial multi-agent review: 10 dimension specialists → diverse-lens skeptic verification of every finding (HIGH/CRITICAL got 3 independent refutation passes) → completeness-critic sweep. 47 raw findings → **25 survived verification** (+1 from the critic). 22 were refuted (mostly "already inside the OS trust boundary" same-uid claims and doc-accuracy nits). Several exploits were **confirmed live** with `curl` against throwaway test ports. **Scope:** whole codebase (branch `master`, v0.9.4). Adversarial multi-agent review: 10 dimension specialists → diverse-lens skeptic verification of every finding (HIGH/CRITICAL got 3 independent refutation passes) → completeness-critic sweep. 47 raw findings → **25 survived verification** (+1 from the critic). 22 were refuted (mostly "already inside the OS trust boundary" same-uid claims and doc-accuracy nits). Several exploits were **confirmed live** with `curl` against throwaway test ports.
## TL;DR — the one thing that matters ## TL;DR — the one thing that matters
+55 -4
View File
@@ -177,8 +177,52 @@ requests as local:
up (it does not gate the hook‑event exemption, but it gates everything else and 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 is the documented practice). Prefer `tailscale serve` (below), which authenticates
at the tailnet layer so untrusted clients never reach the loopback port at all. 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. ### 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`.
--- ---
@@ -342,6 +386,12 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
(CDN fallback for a few libraries). `script-src` and `style-src` additionally (CDN fallback for a few libraries). `script-src` and `style-src` additionally
allow `'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the allow `'unsafe-inline'` — relevant to the SVG/HTML handling in §5, where the
`octet-stream` + `nosniff` download (not the CSP) is what blocks execution. `octet-stream` + `nosniff` download (not the CSP) is what blocks execution.
Because `'unsafe-inline'` is still present (removing it needs a nonce
migration), AI‑derived strings rendered into the subagent/activity panels are
HTML‑escaped at the injection sites (`escapeHtml` in
`src/web/public/constants.js`; sinks in `panels-ui.js` / `subagent-windows.js`)
so a hostile tool name or argument can't execute — defense‑in‑depth from the
2026‑06‑09 review (H4).
- `connect-src` allows `wss://api.deepgram.com` (streaming voice input). - `connect-src` allows `wss://api.deepgram.com` (streaming voice input).
- `img-src` allows `data:` and `blob:` (inline / generated images, QR codes). - `img-src` allows `data:` and `blob:` (inline / generated images, QR codes).
- `frame-ancestors 'self'`. - `frame-ancestors 'self'`.
@@ -367,6 +417,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|------------|--------| |------------|--------|
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth | | `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) | | `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) | | `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) | | `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation | | `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
@@ -379,9 +430,9 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
| Concern | File | | Concern | File |
|---------|------| |---------|------|
| Bind‑host classification, env‑flag parsing | `src/web/network-auth-policy.ts` | | Bind‑host classification, env‑flag parsing, Host/Origin allowlist (`buildHostPolicy` / `isAllowedRequestHost` / `isAllowedRequestOrigin`) | `src/web/network-auth-policy.ts` |
| Start‑and‑warn policy | `src/web/server.ts` (`WebServer.start()`) | | Start‑and‑warn policy | `src/web/server.ts` (`WebServer.start()`) |
| Auth pipeline, rate limiting, security headers, CORS | `src/web/middleware/auth.ts` | | Auth pipeline, rate limiting, security headers, CORS, Host/Origin guard (`registerHostGuard`) | `src/web/middleware/auth.ts` |
| File‑path containment (realpath‑before‑check) | `src/web/route-helpers.ts` (`validateSessionFilePath`) | | File‑path containment (realpath‑before‑check) | `src/web/route-helpers.ts` (`validateSessionFilePath`) |
| File routes, caps, SVG handling, download blocklist | `src/web/routes/file-routes.ts` | | File routes, caps, SVG handling, download blocklist | `src/web/routes/file-routes.ts` |
| Instance/socket/data‑dir scoping | `src/config/instance.ts` | | Instance/socket/data‑dir scoping | `src/config/instance.ts` |