diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d3f89..f9e580a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Security + +- **#109 P0** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is now gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. This prevents any LAN-reachable device from harvesting a working bearer credential from the unauthenticated `/health` endpoint. + ## v3.17.1 — 2026-05-31 ### Fix — code-audit P1/P2 hardening diff --git a/README.md b/README.md index cbc1d4b..66fb149 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ chmod +x ocp-connect ./ocp-connect ``` -**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically: +**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead: ```bash ./ocp-connect @@ -370,7 +370,7 @@ OCP Connect v1.3.0 The script automatically: - Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`) - Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux) -- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+) +- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional)) - Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups) - Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs) @@ -440,7 +440,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment. -**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin. +**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin. **Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection. @@ -889,7 +889,8 @@ Future `ocp update` invocations sync automatically. | `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | | `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) | | `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication | -| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). | +| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). | +| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. | | `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. | | `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. | | `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `/.claude/projects//`. Created automatically. | diff --git a/ocp-connect b/ocp-connect index bedd5d7..a6ced15 100755 --- a/ocp-connect +++ b/ocp-connect @@ -506,9 +506,11 @@ main() { echo "" # Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A). - # When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via - # /health.anonymousKey. If the user didn't pass --key, use it automatically so - # `ocp-connect ` works zero-config for OpenClaw multi-agent setups. + # The server advertises anonymousKey in /health ONLY when the admin has set + # PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so + # advertising exposes the shared key to any LAN-reachable device; issue #109). + # Localhost callers always receive it regardless. When the field is absent, + # ocp-connect falls back to anonymous access / interactive --key (step 3 below). if [[ -z "$key" ]]; then local anon_key anon_key=$(echo "$health_json" | python3 -c " diff --git a/server.mjs b/server.mjs index 0f0aeb0..5b573fb 100644 --- a/server.mjs +++ b/server.mjs @@ -278,6 +278,12 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true"; const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); const ADMIN_KEY = process.env.OCP_ADMIN_KEY || ""; const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || ""; +// When set to "1", advertise PROXY_ANONYMOUS_KEY in the public /health body so +// remote `ocp-connect` devices can zero-config auto-discover it (issue #12 §14 Path A). +// Default OFF: /health is unauthenticated, so advertising hands the shared key to any +// LAN-reachable device (issue #109 P0). Localhost callers always see it regardless, +// since localhost is already fully trusted by the auth path. +const ADVERTISE_ANON_KEY = process.env.PROXY_ADVERTISE_ANON_KEY === "1"; let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms // ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ─────────── @@ -1918,7 +1924,7 @@ const server = createServer(async (req, res) => { claudeBinary: CLAUDE, claudeBinaryOk: binaryOk, authMode: AUTH_MODE, - anonymousKey: PROXY_ANONYMOUS_KEY || null, + ...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: PROXY_ANONYMOUS_KEY || null } : {}), auth: authStatus, config: { timeout: TIMEOUT, diff --git a/test-features.mjs b/test-features.mjs index 0690aa5..0fd9476 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1540,6 +1540,29 @@ if (process.env.OCP_TUI_LIVE === "1") { }); } +// ── /health anonymousKey gate (issue #109) ────────────────────────────────── +// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied +// verbatim to avoid importing server.mjs (top-level server.listen() would +// start a live HTTP server, per the stream-JSON parser tests convention above). +console.log("\n/health anonymousKey gate (issue #109):"); + +// Replicate the gating predicate from server.mjs line ~286/1927: +// ...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: ... } : {}) +function shouldAdvertiseAnonKey(isLocalhost, advertise) { return isLocalhost || advertise; } + +test("(localhost=false, flag=false) → omit key", () => { + assert.equal(shouldAdvertiseAnonKey(false, false), false); +}); +test("(localhost=true, flag=false) → include key (localhost always exempt)", () => { + assert.equal(shouldAdvertiseAnonKey(true, false), true); +}); +test("(localhost=false, flag=true) → include key (opt-in set)", () => { + assert.equal(shouldAdvertiseAnonKey(false, true), true); +}); +test("(localhost=true, flag=true) → include key (both true)", () => { + assert.equal(shouldAdvertiseAnonKey(true, true), true); +}); + // ── Cleanup ── closeDb();