SPB Git

Repository Public

security.md 17.9 KB 150 lines markdown

1# Security23Claude Remote puts a full shell of a cluster account behind a public HTTPS endpoint. This document states4what is protected, by which implemented control, which test covers each control, and what is left exposed.5Authentication details are in [authentication.md](authentication.md); the network path is in6[network-architecture.md](network-architecture.md).78## 1. Threat model910### Assets1112| Asset | Where | Consequence if compromised |13|---|---|---|14| Interactive shell / Claude Code session of the operator account on M5M36 | tmux sessions | Arbitrary code execution as that account; lateral movement to nodes that accept its SSH key |15| Claude Code account credentials (after `/login`) | macOS Keychain / `~/.claude` of that account | Use of the owner's Claude subscription |16| Project files | workspace root | Disclosure, tampering |17| Node SSH key | `~/.ssh` on M5M36 | Shell on the allow-listed compute nodes; git access (git-shell only) on the git server |18| Application secrets | `.env` (0600), PM2 environment, the mld manifest on the gateway | `CR_RUNTIME_TOKEN` (runtime control), `CR_SECRET_KEY` (TOTP secrets), database password, agentd token |19| Authentication database | PostgreSQL (loopback) | Password hash, encrypted TOTP secret, passkey public keys, session token hashes |20| Backups | `~/backups/claude-remote` + passphrase file | Offline copy of the database |2122### Actors2324| Actor | Capability assumed |25|---|---|26| Anonymous Internet client | Can send any request to `https://claude.spboucher.ai` |27| Hostile web page opened in the administrator's browser | Cross-site requests with ambient cookies, WebSocket attempts, framing attempts |28| Device on the home LAN | Can reach M5M36:8620 directly over plain HTTP |29| Another local process running as the same macOS account | Can read the account's files, including `.env` |30| Another local macOS account on M5M36 | Can connect to TCP ports; cannot read 0600/0700 files |31| Content processed inside a session (repository, web page, MCP output) | Can influence Claude Code (prompt injection) or run as code the administrator executes |32| The public gateway (BHS64) | Terminates TLS: sees all traffic in clear, sets `X-Forwarded-For` |3334There is one administrator. There are no roles and no multi-tenancy.3536### Trust boundaries37381. Internet → Caddy on the gateway (TLS).392. Gateway → `apps/server` over WireGuard (plain HTTP inside the tunnel).403. `apps/server` → `apps/runtime` over the unix socket (bearer token).414. `apps/runtime` → tmux → the session process (no boundary: same account).425. Session process → Claude Code hook endpoint (low-privilege hook token).436. M5M36 → other nodes (SSH, only hosts in `CR_EXEC_HOSTS`) and agentd (read-only telemetry).4445### Attack surface4647| Surface | Reachable by | Notes |48|---|---|---|49| `/api/v1/*` REST and WebSocket upgrades | Internet, LAN | 1 public route (`GET /health`), pre-auth login/setup routes, everything else authenticated |50| Static web application | Internet, LAN | No secrets; strict CSP |51| Runtime unix socket | Processes of the same account | Requires the bearer token |52| tmux socket | Processes of the same account | Stock tmux permissions |53| PostgreSQL | Loopback | Password authentication for role `claude_remote` |54| Claude Code hooks | Processes inside sessions | `POST /hooks/claude` only |5556## 2. Controls implemented, with the test that covers each5758Test files: `S/` = `apps/server/src/`, `R/` = `apps/runtime/src/`, `W/` = `apps/web/`.5960### Authentication and sessions6162| Control | Implementation | Test |63|---|---|---|64| Every API route requires a session with a completed second factor unless explicitly listed | global guard `S/security/guard.ts`; route table checked | `S/http.test.ts` — "every registered API route refuses unauthenticated callers…", "the declared auth levels match the documented public list" |65| Public health discloses only `{ok, version}` | `S/modules/system/index.ts` | `S/http.test.ts` — "health is public and discloses only {ok, version}" |66| One-time setup token (0600 file, never logged, constant-time compare, removed once used); setup is 404 once an administrator exists | `S/security/setup-token.ts`, `S/modules/auth/index.ts` | `S/modules/auth/auth.test.ts` — setup tests |67| Password ≥ 12 characters, argon2id (64 MiB, 3 passes) or scrypt fallback, dummy verification for unknown users | `S/security/password.ts` | `S/security/unit.test.ts`; `auth.test.ts` — "wrong password, unknown user and wrong TOTP all answer 401 invalid_credentials" |68| Mandatory second factor; a password alone cannot enrol a new factor once one exists | `assertMayEnrol` in `S/modules/auth/common.ts` | `auth.test.ts` — "a fresh admin without a second factor may only use enrolment endpoints", "with a second factor enrolled, the password alone cannot enrol another factor or reach the API" |69| TOTP (RFC 6238, ±1 step), secret sealed with AES-256-GCM bound to the user id, each code accepted once | `S/security/totp.ts`, `crypto.ts`, `checkTotp` | `unit.test.ts` (RFC vectors, replay); `auth.test.ts` — "codes cannot be replayed" |70| Passkeys (WebAuthn): RP ID = public hostname, exact origin, user verification required, server-side single-use challenges (5 min), counter check | `S/security/webauthn.ts`, `S/modules/auth/factors.ts` | `S/modules/auth/webauthn.test.ts` (12 tests, software authenticator) |71| Session cookie `__Host-cr_sid`: HttpOnly, Secure, SameSite=Strict, Path=/, no Domain; only the SHA-256 of the token is stored | `guard.ts`, `S/security/sessions.ts` | `S/prod.test.ts`; `auth.test.ts` — "logout revokes; only the sha256 of the token is stored" |72| Idle timeout 12 h, absolute 30 d, 15 min before the second factor; token and CSRF token rotated when the second factor completes | `sessions.ts` | `auth.test.ts` — "absolute expiry", "idle timeout (12 h) and sliding refresh", "…the session token is rotated on upgrade" |73| Brute-force throttling stored in the database: 5 failures per username, 10 per IP, 100 global per 15 min; progressive lock (60 s doubling, cap 15 min); a correct password does not reset the TOTP counter | `S/security/throttle.ts` | `auth.test.ts` — four throttling tests; `webauthn.test.ts` — "failed passkey verifications are throttled like passwords" |74| Request rate limits per minute: credential endpoints 20 per IP (shared bucket), anonymous `GET /auth/status` 240 per IP, `GET /health` 600 per IP, everything else 600 per session | `S/app.ts` | `S/rate-limit.test.ts` (6 tests), `prod.test.ts` |75| Password change revokes all other sessions; revoking a session closes its WebSockets at once (4401); open sockets re-check their session every 60 s | `S/live.ts`, `S/modules/terminal-ws.ts` | `auth.test.ts` — "password change revokes every other session…"; `S/modules/ws.test.ts` — "logout closes the terminal socket with 4401…", "a session revoked or expired behind our back is caught by the periodic re-check" |7677### Browser-side attacks7879| Control | Implementation | Test |80|---|---|---|81| CSRF: mutating requests need `X-CSRF-Token` and `Origin` equal to `CR_PUBLIC_ORIGIN`; pre-auth POSTs need the Origin and `application/json` | `guard.ts` | `auth.test.ts` — "mutations need the session CSRF token and the exact Origin", "rejects a wrong token (and the Origin / content-type rules apply before auth)" |82| WebSocket upgrades need the cookie, a completed second factor and the exact Origin; a refused upgrade is a plain HTTP answer | `guard.ts`, `terminal-ws.ts` | `ws.test.ts` — "refuses without a cookie (401), with a wrong or missing Origin (403), before the second factor (403)" |83| CSP without `'unsafe-inline'` for scripts: per-document SHA-256 hashes of the export's inline scripts; `object-src 'none'`, `frame-ancestors 'none'`, `frame-src 'self' blob:`; all assets self-hosted | `S/static.ts` | `http.test.ts` — "serves trailing-slash routes with a per-document CSP…"; `unit.test.ts` — "never allows unsafe-inline scripts"; `W/e2e/features.spec.ts` — "every route renders without console errors or CSP violations" |84| HSTS (2 years, includeSubDomains), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-store` on API responses | `app.ts`, `guard.ts`, `static.ts` | `http.test.ts` — "API responses are no-store and carry the hardening headers" |85| CSRF token kept in memory only; downloads served as attachments; Markdown preview renders no raw HTML; SVG not previewed as an image | `W/src/lib/api.ts`, `W/src/components/editor/previews.tsx`, `S/modules/files/index.ts` | `S/modules/sessions/sessions.test.ts` — "markup in titles round-trips as inert JSON"; otherwise not covered by a dedicated test |86| Service worker caches the static shell only; `/api/*` is never handled or cached | `W/public/sw.js` | `W/e2e/setup-offline.spec.ts` — "offline: the cached shell loads…; /api is never cached" |8788### Runtime boundary and host access8990| Control | Implementation | Test |91|---|---|---|92| Runtime listens only on a 0600 unix socket inside a 0700 directory; refuses to start on a live socket | `R/main.ts` | `R/main.test.ts` — "listens only on a 0600 unix socket inside a 0700 directory", "refuses to start a second runtime on a live socket" |93| Bearer token (≥ 32 characters, constant-time compare) on every runtime HTTP route and WebSocket upgrade | `R/auth.ts` | `main.test.ts` — "requires the bearer token on every HTTP route and WebSocket upgrade"; `R/sessions.test.ts` — "rejects an unauthenticated attach before any PTY exists" |94| Separate hook token accepted only on `POST /hooks/claude`; passed to `curl` through a file descriptor, not argv | `auth.ts`, `R/bin/cr-hook` | `R/hooks.test.ts` — "the hook token works on /hooks/claude and nowhere else" |95| Runtime secrets (`CR_RUNTIME_TOKEN`, `DATABASE_URL`, `CR_SECRET_KEY`, `CR_AGENTD_TOKEN`, all `CR_*`) are stripped from the environment of sessions and tasks and removed from the tmux global environment | `R/core-util.ts`, `R/tmux.ts` | `main.test.ts` — "strips runtime secrets and reserved names"; `sessions.test.ts` — "gives the session its id and hook access but none of the runtime secrets" |96| No permission bypass: `--dangerously-skip-permissions`, `--allow-dangerously-skip-permissions`, `--permission-mode bypassPermissions` refused; a `--resume` value may not start with `-`; conversation ids must match `^[A-Za-z0-9][A-Za-z0-9-]{7,63}$` | `R/sessions.ts`, `S/modules/sessions/index.ts` | `sessions.test.ts` (runtime) — "rejects every permission-bypass form", "refuses an option-like value after --resume"; `S/modules/sessions/sessions.test.ts` — "builds claude args server-side only…"; `unit.test.ts` — "claude args come only from validated fields" |97| File API confined to the configured workspace roots: no NUL, no absolute path, no `..`, realpath containment with trailing separator, no write through a symlink leaving the root, special files refused | `R/paths.ts` (and a syntactic pre-check in `S/paths.ts`) | `R/paths.test.ts` (29 tests); `R/fs.test.ts`; `unit.test.ts` — path rejection cases |98| Session and task working directories must be inside a workspace root | `assertInsideWorkspace` in `core-util.ts` | `R/tasks.test.ts` — "validates host allowlist, cwd and duplicates"; `sessions.test.ts` — "…bad specs" |99| Subprocesses use argv arrays, never a shell; git inspection disables fsmonitor and hooks; clone accepts only `https://`, `ssh://`, `git@host:path`, `gitsrv:` and never prompts | `R/fsx-exec.ts`, `R/git.ts` | `R/git.test.ts` |100| Remote task hosts must be in `CR_EXEC_HOSTS` (checked by server and runtime); `ssh -o BatchMode=yes` | `S/modules/tasks/index.ts`, `R/core-task-manager.ts` | `tasks.test.ts` — "validates host allowlist…", "remote host: runs ssh with the documented argv (fake ssh on PATH)"; `S/integration/real-runtime.test.ts` — tasks test |101| MCP env/header values are write-only: never returned, stored, logged or audited; targets are masked for display | `R/mcp.ts`, `R/fsx-mcp-config.ts`, `S/modules/skills-mcp/index.ts` | `R/mcp.test.ts`; `S/modules/misc.test.ts` — "proxies listings, caches them, and audits operations without secrets" |102| Skill operations only move, copy or delete files (no symlinks, ≤ 20 MiB, ≤ 2000 entries); skill code is never executed | `R/skills.ts` | `R/skills.test.ts` |103| Size limits: JSON body 6 MiB, WebSocket frame 1 MiB, read 2 MiB, write 5 MiB, upload 200 MiB, task log 256 MiB (configurable cap) | `app.ts`, `fs.ts`, `fsx-transfer.ts`, `core-task-manager.ts` | `http.test.ts`, `ws.test.ts` — "frames above 1 MiB are rejected", `misc.test.ts` — "upload size cap…", `tasks.test.ts` — "stops appending at the cap…" |104105### Logging, audit, data minimisation106107| Control | Implementation | Test |108|---|---|---|109| Logs never contain passwords, codes, cookies, CSRF tokens, commands, file content or terminal data | pino redaction in `app.ts` (`REDACT_PATHS`); runtime logs method + path only | `auth.test.ts` — "never logs passwords, codes, cookies or CSRF tokens" |110| Audit trail for authentication, session, file, task, project, skills and MCP operations, with actor, IP and outcome; no secrets or content | `S/audit.ts` | `auth.test.ts` — "audits auth events without secrets"; `misc.test.ts` — "audit is paginated newest-first" |111| Client IP taken from `X-Forwarded-For` only when the peer is a trusted proxy | Fastify `trustProxy` = `CR_TRUSTED_PROXIES` | `auth.test.ts` — "trusts X-Forwarded-For only from a trusted proxy peer" |112| Cluster view never forwards IP addresses, usernames, hostnames or serial numbers from agentd | `S/modules/cluster/mapping.ts` | `misc.test.ts` — "never leaks IP addresses, usernames, hostnames or serials" |113| Notifications are generic unless the user opts into titles; never terminal content | `S/modules/notifications/service.ts` | `sessions.test.ts` — "the hook message is shown only with notifications.detail = titles" |114| Production refuses to start without an https origin, `DATABASE_URL`, `CR_RUNTIME_TOKEN` and a 64-hex `CR_SECRET_KEY` | `S/config.ts` | `unit.test.ts` — configuration tests |115116### Verified on the production deployment (2026-11-10)117118Checked by hand right after deployment, not by an automated test: 10 API routes, a POST and a WebSocket119upgrade all answered 401 without a session; a POST with a foreign Origin answered 403; a wrong setup token120answered 401; the security headers were present; TLS was valid with HTTP→HTTPS redirect (308); data121directory, socket and token files were 0600/0700; the only listeners were the server on port 8620 and122PostgreSQL on loopback; the audit log recorded the real client address through the tunnel.123124## 3. Deviations and residual risks125126| # | Risk | Status |127|---:|---|---|128| 1 | **No dedicated OS identity.** Server, runtime, tmux and every session run under the existing cluster operator macOS account. A dedicated account was considered and rejected: it breaks the cluster's `mld`/PM2 conventions and Claude Code's Keychain login. Isolation is by process separation, the 0600 socket and path confinement of the *file API* — not by OS user. | Accepted deviation |129| 2 | **The terminal is a full shell of that account, by design.** Path confinement applies to the file API, the session's starting directory and the task working directory; it does not sandbox what a shell, Claude Code or a task does afterwards. Anyone who passes authentication has the account. | Inherent to the product |130| 3 | A process running as the same account can read `.env`, the runtime token and the tmux socket, and therefore control the runtime without going through the server. | Follows from 1 |131| 4 | The node's SSH key gives a full shell on the compute nodes listed in `CR_EXEC_HOSTS` (M3U96a, M3U96b). A compromised session can reach them. | Accepted; keep the list minimal |132| 5 | Prompt injection: content read by Claude Code can steer it. Mitigation is Claude Code's own permission prompts, which Claude Remote never bypasses; the laptop's auto-approve hook was deliberately not migrated. | Residual |133| 6 | The gateway terminates TLS and forwards plain HTTP through WireGuard; a compromise of the gateway exposes sessions and credentials in transit. | Residual (cluster design) |134| 7 | Port 8620 is reachable from the LAN over plain HTTP. Authentication is still required; login POSTs sent from that origin are refused (Origin ≠ `CR_PUBLIC_ORIGIN`) and the `Secure` cookie would not be accepted over HTTP, so a login cannot complete on that path — but credentials typed there would still travel in clear. | Residual; do not use the LAN URL |135| 8 | Path checks and use are not atomic (TOCTOU), as stated in `paths.ts`. | Accepted (single administrator, own workspace) |136| 9 | `style-src 'unsafe-inline'` is allowed (scripts are not). | Residual |137| 10 | Backups are encrypted with AES-256-CBC + PBKDF2 (confidentiality, no authentication tag) and stored on the same host as the passphrase file. | Residual; copy both elsewhere, separately |138| 11 | PM2 keeps each process's environment (including the secrets) in its own state under `~/.pm2`. Standard PM2 behaviour; file permissions on the node were not audited for it. | Not verified |139| 12 | TOTP uses HMAC-SHA1 (the interoperable default). | Accepted |140| 13 | No account recovery flow. Losing every second factor requires a manual database intervention (see [backup-recovery.md](backup-recovery.md)). | By design |141| 14 | Application-level rate limiting only; no IP allowlist, WAF or gateway-level basic auth in front. | Possible later without code changes |142| 15 | Security finding fixed before deployment: the first conversation-id pattern would have accepted a string starting with `--` as a `--resume` value. Fixed in both server and runtime; covered by the tests listed above. | Closed |143144## Limitations / not verified145146- No external penetration test or dependency audit was performed.147- Passkey flows were verified with a software authenticator, not a real browser or device.148- The 4429 backpressure close was unit-tested, not observed under real saturation.149- macOS application firewall state on M5M36 is not managed by this project.150
Files main
Menu

Theme