mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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>
320 lines
14 KiB
Markdown
320 lines
14 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## 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=<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=<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 `<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 `.prettierignore`d 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`.
|