SPB Git

Repository Public

security.md 17.9 KB

# Security

Claude Remote puts a full shell of a cluster account behind a public HTTPS endpoint. This document states what is protected, by which implemented control, which test covers each control, and what is left exposed. Authentication details are in authentication.md; the network path is in network-architecture.md.

# 1. Threat model

# Assets

Asset Where Consequence if compromised
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
Claude Code account credentials (after /login) macOS Keychain / ~/.claude of that account Use of the owner's Claude subscription
Project files workspace root Disclosure, tampering
Node SSH key ~/.ssh on M5M36 Shell on the allow-listed compute nodes; git access (git-shell only) on the git server
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
Authentication database PostgreSQL (loopback) Password hash, encrypted TOTP secret, passkey public keys, session token hashes
Backups ~/backups/claude-remote + passphrase file Offline copy of the database

# Actors

Actor Capability assumed
Anonymous Internet client Can send any request to https://claude.spboucher.ai
Hostile web page opened in the administrator's browser Cross-site requests with ambient cookies, WebSocket attempts, framing attempts
Device on the home LAN Can reach M5M36:8620 directly over plain HTTP
Another local process running as the same macOS account Can read the account's files, including .env
Another local macOS account on M5M36 Can connect to TCP ports; cannot read 0600/0700 files
Content processed inside a session (repository, web page, MCP output) Can influence Claude Code (prompt injection) or run as code the administrator executes
The public gateway (BHS64) Terminates TLS: sees all traffic in clear, sets X-Forwarded-For

There is one administrator. There are no roles and no multi-tenancy.

# Trust boundaries

  1. Internet → Caddy on the gateway (TLS).
  2. Gateway → apps/server over WireGuard (plain HTTP inside the tunnel).
  3. apps/server → apps/runtime over the unix socket (bearer token).
  4. apps/runtime → tmux → the session process (no boundary: same account).
  5. Session process → Claude Code hook endpoint (low-privilege hook token).
  6. M5M36 → other nodes (SSH, only hosts in CR_EXEC_HOSTS) and agentd (read-only telemetry).

# Attack surface

Surface Reachable by Notes
/api/v1/* REST and WebSocket upgrades Internet, LAN 1 public route (GET /health), pre-auth login/setup routes, everything else authenticated
Static web application Internet, LAN No secrets; strict CSP
Runtime unix socket Processes of the same account Requires the bearer token
tmux socket Processes of the same account Stock tmux permissions
PostgreSQL Loopback Password authentication for role claude_remote
Claude Code hooks Processes inside sessions POST /hooks/claude only

# 2. Controls implemented, with the test that covers each

Test files: S/ = apps/server/src/, R/ = apps/runtime/src/, W/ = apps/web/.

# Authentication and sessions

Control Implementation Test
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"
Public health discloses only {ok, version} S/modules/system/index.ts S/http.test.ts — "health is public and discloses only {ok, version}"
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
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"
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"
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"
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)
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"
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"
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"
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
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"

# Browser-side attacks

Control Implementation Test
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)"
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)"
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"
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"
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
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"

# Runtime boundary and host access

Control Implementation Test
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"
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"
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"
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"
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"
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
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"
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
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
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"
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
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…"

# Logging, audit, data minimisation

Control Implementation Test
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"
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"
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"
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"
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"
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

# Verified on the production deployment (2026-11-10)

Checked by hand right after deployment, not by an automated test: 10 API routes, a POST and a WebSocket upgrade all answered 401 without a session; a POST with a foreign Origin answered 403; a wrong setup token answered 401; the security headers were present; TLS was valid with HTTP→HTTPS redirect (308); data directory, socket and token files were 0600/0700; the only listeners were the server on port 8620 and PostgreSQL on loopback; the audit log recorded the real client address through the tunnel.

# 3. Deviations and residual risks

# Risk Status
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
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
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
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
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
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)
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
8 Path checks and use are not atomic (TOCTOU), as stated in paths.ts. Accepted (single administrator, own workspace)
9 style-src 'unsafe-inline' is allowed (scripts are not). Residual
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
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
12 TOTP uses HMAC-SHA1 (the interoperable default). Accepted
13 No account recovery flow. Losing every second factor requires a manual database intervention (see backup-recovery.md). By design
14 Application-level rate limiting only; no IP allowlist, WAF or gateway-level basic auth in front. Possible later without code changes
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

# Limitations / not verified

  • No external penetration test or dependency audit was performed.
  • Passkey flows were verified with a software authenticator, not a real browser or device.
  • The 4429 backpressure close was unit-tested, not observed under real saturation.
  • macOS application firewall state on M5M36 is not managed by this project.
Files main
Menu

Theme