From 1843d9fae08a632b75863534b622fe3b1a432130 Mon Sep 17 00:00:00 2001 From: Matt MacDougall Date: Mon, 22 Jun 2026 00:41:00 -0400 Subject: [PATCH 1/2] harden: fail-closed API auth, narrow container exposure, patch deps Security hardening of the control plane: - API auth now fails closed: an unset CONTROLPANE_API_AUTH_TOKEN returns 503 instead of allowing all traffic. Skipping auth requires the explicit CONTROLPANE_API_AUTH_DISABLED=1 opt-out. - Token comparison is now constant-time (hmac.compare_digest). - HTTP no longer accepts tokens via query string (they leak into logs/history); WebSocket query tokens are retained out of browser necessity. - WebSocket relay fails closed the same way as HTTP. - docker-compose: bind the API to loopback (127.0.0.1), pass through the auth token/disable vars, and mount only the managed runtime repos instead of the whole home tree. - Tighten dashboard CSP connect-src (drop broad https:/wss: wildcards). - Bump next 14.2.12 -> 14.2.35 (fixes CVE-2025-29927 and other 14.2.x advisories). - CI self-check opts out of auth explicitly; docs updated to match. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/controlplane-smoke.yml | 4 + apps/controlplane-api/README.md | 3 +- apps/controlplane-api/controlplane_api/app.py | 47 +++++--- .../controlplane_api/config.py | 9 ++ apps/controlplane-dashboard/next.config.mjs | 2 +- apps/controlplane-dashboard/package-lock.json | 101 ++++++++++-------- apps/controlplane-dashboard/package.json | 2 +- docker-compose.controlplane.yml | 13 ++- docs/current-state.md | 15 +-- 9 files changed, 128 insertions(+), 68 deletions(-) diff --git a/.github/workflows/controlplane-smoke.yml b/.github/workflows/controlplane-smoke.yml index 736f127..2e6da85 100644 --- a/.github/workflows/controlplane-smoke.yml +++ b/.github/workflows/controlplane-smoke.yml @@ -24,6 +24,10 @@ jobs: run: pip install -r ./apps/controlplane-api/requirements.txt - name: Run control-plane API self-check + env: + # The self-check exercises endpoint wiring, not auth. Opt out of + # fail-closed auth explicitly so the unauthenticated probes succeed. + CONTROLPANE_API_AUTH_DISABLED: "1" run: | python - <<'PY' import sys diff --git a/apps/controlplane-api/README.md b/apps/controlplane-api/README.md index 167d8f4..9d3a1d7 100644 --- a/apps/controlplane-api/README.md +++ b/apps/controlplane-api/README.md @@ -49,7 +49,8 @@ docker build -f apps/controlplane-api/Dockerfile -t squirrelops-controlplane-api - `PINGTING_PYTHON_BIN` (optional explicit Python executable) - `PINGTING_STATUS_MAX_AGE_SECONDS` (default: `120`) - `PINGTING_STATUS_TIMEOUT_SECONDS` (default: `20`) -- `CONTROLPANE_API_AUTH_TOKEN` (optional shared API token) +- `CONTROLPANE_API_AUTH_TOKEN` (shared API token; when unset the API fails closed and returns 503) +- `CONTROLPANE_API_AUTH_DISABLED` (set to `1` to intentionally run without auth, trusted local use only) - `CONTROLPANE_CORS_ALLOW_ORIGINS` (comma-separated origins) - `CONTROLPANE_ACTION_TIMEOUT_SECONDS` (default: `900`) - `CONTROLPANE_BOOTSTRAP_SCRIPT_PATH` (default: `scripts/bootstrap_repos.sh`) diff --git a/apps/controlplane-api/controlplane_api/app.py b/apps/controlplane-api/controlplane_api/app.py index b4275c6..1ce5eb3 100644 --- a/apps/controlplane-api/controlplane_api/app.py +++ b/apps/controlplane-api/controlplane_api/app.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, timezone +import hmac from pathlib import Path import sys from typing import Any @@ -27,6 +28,13 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() +def _token_matches(provided: str | None, expected: str) -> bool: + """Constant-time comparison that fails closed on empty/missing tokens.""" + if not provided or not expected: + return False + return hmac.compare_digest(provided, expected) + + def _extract_bearer_token(raw_value: str | None) -> str | None: if raw_value is None: return None @@ -41,6 +49,8 @@ def _extract_bearer_token(raw_value: str | None) -> str | None: def _resolve_request_token(request: Request) -> str | None: + # HTTP callers must use headers. Query-string tokens are rejected here because + # they leak into access logs, browser history, and Referer headers. bearer = _extract_bearer_token(request.headers.get("authorization")) if bearer: return bearer @@ -49,11 +59,6 @@ def _resolve_request_token(request: Request) -> str | None: token = api_key.strip() if token: return token - token_query = request.query_params.get("token") - if token_query: - token = token_query.strip() - if token: - return token return None @@ -119,9 +124,13 @@ def create_app(settings: ControlPlaneSettings | None = None) -> FastAPI: ) async def relay_deception_websocket(*, websocket: WebSocket, upstream_url: str) -> None: - if settings.api_auth_token and _resolve_websocket_token(websocket) != settings.api_auth_token: - await websocket.close(code=4401, reason="authentication required") - return + if not settings.api_auth_disabled: + if not settings.api_auth_token: + await websocket.close(code=4503, reason="server auth not configured") + return + if not _token_matches(_resolve_websocket_token(websocket), settings.api_auth_token): + await websocket.close(code=4401, reason="authentication required") + return await websocket.accept() upstream_token = settings.clownpeanuts_ws_token @@ -153,14 +162,28 @@ async def relay_deception_websocket(*, websocket: WebSocket, upstream_url: str) @app.middleware("http") async def auth_middleware(request: Request, call_next: Any) -> Response: - if not settings.api_auth_token: - return await call_next(request) + # CORS preflight and the unauthenticated health probe always pass. if request.method.upper() == "OPTIONS": return await call_next(request) if request.url.path == "/health": return await call_next(request) - token = _resolve_request_token(request) - if token != settings.api_auth_token: + # Fail closed: authentication may only be skipped when an operator has + # explicitly opted out via CONTROLPANE_API_AUTH_DISABLED. An unset token + # is treated as a misconfiguration, not as "allow everyone". + if settings.api_auth_disabled: + return await call_next(request) + if not settings.api_auth_token: + return JSONResponse( + status_code=503, + content={ + "detail": ( + "server authentication is not configured; set " + "CONTROLPANE_API_AUTH_TOKEN, or set " + "CONTROLPANE_API_AUTH_DISABLED=1 to run without auth" + ) + }, + ) + if not _token_matches(_resolve_request_token(request), settings.api_auth_token): return JSONResponse( status_code=401, content={"detail": "authentication required"}, diff --git a/apps/controlplane-api/controlplane_api/config.py b/apps/controlplane-api/controlplane_api/config.py index 85d43e2..2eec8a9 100644 --- a/apps/controlplane-api/controlplane_api/config.py +++ b/apps/controlplane-api/controlplane_api/config.py @@ -15,6 +15,13 @@ def _parse_int_env(name: str, default: int) -> int: return default +def _parse_bool_env(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + def _parse_origins(raw: str) -> list[str]: items = [item.strip() for item in raw.split(",")] return [item for item in items if item] @@ -43,6 +50,7 @@ class ControlPlaneSettings: update_script_path: Path cors_allow_origins: list[str] api_auth_token: str + api_auth_disabled: bool def load_settings() -> ControlPlaneSettings: @@ -103,4 +111,5 @@ def load_settings() -> ControlPlaneSettings: ) ), api_auth_token=os.getenv("CONTROLPANE_API_AUTH_TOKEN", "").strip(), + api_auth_disabled=_parse_bool_env("CONTROLPANE_API_AUTH_DISABLED", default=False), ) diff --git a/apps/controlplane-dashboard/next.config.mjs b/apps/controlplane-dashboard/next.config.mjs index 8e7e0cf..378c1e5 100644 --- a/apps/controlplane-dashboard/next.config.mjs +++ b/apps/controlplane-dashboard/next.config.mjs @@ -19,7 +19,7 @@ const productionOnlyHeaders = [ "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", - "connect-src 'self' https: wss: http://127.0.0.1:8099 http://localhost:8099 ws://127.0.0.1:8099 ws://localhost:8099 http://127.0.0.1:8199 http://localhost:8199 ws://127.0.0.1:8199 ws://localhost:8199", + "connect-src 'self' http://127.0.0.1:8099 http://localhost:8099 ws://127.0.0.1:8099 ws://localhost:8099 http://127.0.0.1:8199 http://localhost:8199 ws://127.0.0.1:8199 ws://localhost:8199", "object-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", diff --git a/apps/controlplane-dashboard/package-lock.json b/apps/controlplane-dashboard/package-lock.json index 15c8a15..c6a5a74 100644 --- a/apps/controlplane-dashboard/package-lock.json +++ b/apps/controlplane-dashboard/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "d3-scale": "4.0.2", - "next": "14.2.12", + "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" }, @@ -22,15 +22,15 @@ } }, "node_modules/@next/env": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.12.tgz", - "integrity": "sha512-3fP29GIetdwVIfIRyLKM7KrvJaqepv+6pVodEbx0P5CaMLYBtx+7eEg8JYO5L9sveJO87z9eCReceZLi0hxO1Q==", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.12.tgz", - "integrity": "sha512-crHJ9UoinXeFbHYNok6VZqjKnd8rTd7K3Z2zpyzF1ch7vVNKmhjv/V7EHxep3ILoN8JB9AdRn/EtVVyG9AkCXw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", "cpu": [ "arm64" ], @@ -44,9 +44,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.12.tgz", - "integrity": "sha512-JbEaGbWq18BuNBO+lCtKfxl563Uw9oy2TodnN2ioX00u7V1uzrsSUcg3Ep9ce+P0Z9es+JmsvL2/rLphz+Frcw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", "cpu": [ "x64" ], @@ -60,12 +60,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.12.tgz", - "integrity": "sha512-qBy7OiXOqZrdp88QEl2H4fWalMGnSCrr1agT/AVDndlyw2YJQA89f3ttR/AkEIP9EkBXXeGl6cC72/EZT5r6rw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -76,12 +79,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.12.tgz", - "integrity": "sha512-EfD9L7o9biaQxjwP1uWXnk3vYZi64NVcKUN83hpVkKocB7ogJfyH2r7o1pPnMtir6gHZiGCeHKagJ0yrNSLNHw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -92,12 +98,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.12.tgz", - "integrity": "sha512-iQ+n2pxklJew9IpE47hE/VgjmljlHqtcD5UhZVeHICTPbLyrgPehaKf2wLRNjYH75udroBNCgrSSVSVpAbNoYw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -108,12 +117,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.12.tgz", - "integrity": "sha512-rFkUkNwcQ0ODn7cxvcVdpHlcOpYxMeyMfkJuzaT74xjAa5v4fxP4xDk5OoYmPi8QNLDs3UgZPMSBmpBuv9zKWA==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -124,9 +136,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.12.tgz", - "integrity": "sha512-PQFYUvwtHs/u0K85SG4sAdDXYIPXpETf9mcEjWc0R4JmjgMKSDwIU/qfZdavtP6MPNiMjuKGXHCtyhR/M5zo8g==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", "cpu": [ "arm64" ], @@ -140,9 +152,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.12.tgz", - "integrity": "sha512-FAj2hMlcbeCV546eU2tEv41dcJb4NeqFlSXU/xL/0ehXywHnNpaYajOUvn3P8wru5WyQe6cTZ8fvckj/2XN4Vw==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", "cpu": [ "ia32" ], @@ -156,9 +168,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.12.tgz", - "integrity": "sha512-yu8QvV53sBzoIVRHsxCHqeuS8jYq6Lrmdh0briivuh+Brsp6xjg80MAozUsBTAV9KNmY08KlX0KYTWz1lbPzEg==", + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", "cpu": [ "x64" ], @@ -420,13 +432,12 @@ } }, "node_modules/next": { - "version": "14.2.12", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.12.tgz", - "integrity": "sha512-cDOtUSIeoOvt1skKNihdExWMTybx3exnvbFbb9ecZDIxlvIbREQzt9A5Km3Zn3PfU+IFjyYGsHS+lN9VInAGKA==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", "dependencies": { - "@next/env": "14.2.12", + "@next/env": "14.2.35", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -441,15 +452,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.12", - "@next/swc-darwin-x64": "14.2.12", - "@next/swc-linux-arm64-gnu": "14.2.12", - "@next/swc-linux-arm64-musl": "14.2.12", - "@next/swc-linux-x64-gnu": "14.2.12", - "@next/swc-linux-x64-musl": "14.2.12", - "@next/swc-win32-arm64-msvc": "14.2.12", - "@next/swc-win32-ia32-msvc": "14.2.12", - "@next/swc-win32-x64-msvc": "14.2.12" + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/apps/controlplane-dashboard/package.json b/apps/controlplane-dashboard/package.json index f2e37b6..fc19153 100644 --- a/apps/controlplane-dashboard/package.json +++ b/apps/controlplane-dashboard/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "d3-scale": "4.0.2", - "next": "14.2.12", + "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/docker-compose.controlplane.yml b/docker-compose.controlplane.yml index d34120f..36bda12 100644 --- a/docker-compose.controlplane.yml +++ b/docker-compose.controlplane.yml @@ -4,9 +4,15 @@ services: context: . dockerfile: apps/controlplane-api/Dockerfile ports: - - "8199:8199" + # Bind to loopback only. The API can trigger local scripts and proxies an + # authenticated upstream, so it must not be reachable off-host by default. + - "127.0.0.1:8199:8199" environment: CONTROLPLANE_WORKSPACE_ROOT: /Users/matt/code + # Required for auth. The API fails closed (HTTP 503) when this is empty, + # unless CONTROLPANE_API_AUTH_DISABLED is set to 1 for trusted local use. + CONTROLPANE_API_AUTH_TOKEN: ${CONTROLPANE_API_AUTH_TOKEN:-} + CONTROLPANE_API_AUTH_DISABLED: ${CONTROLPANE_API_AUTH_DISABLED:-} CLOWNPEANUTS_API_BASE: http://host.docker.internal:8099 CLOWNPEANUTS_WS_EVENTS_URL: ws://host.docker.internal:8099/ws/events CLOWNPEANUTS_WS_THEATER_URL: ws://host.docker.internal:8099/ws/theater/live @@ -14,7 +20,10 @@ services: PINGTING_STATUS_PATH: /Users/matt/code/pingting/data/status.json PINGTING_CONFIG_PATH: /Users/matt/code/pingting/config/pingting.yaml volumes: - - /Users/matt/code:/Users/matt/code + # Mount only the managed runtime repos rather than the whole workspace so a + # container compromise cannot reach unrelated files under the home tree. + - /Users/matt/code/pingting:/Users/matt/code/pingting + - /Users/matt/code/clownpeanuts:/Users/matt/code/clownpeanuts extra_hosts: - host.docker.internal:host-gateway restart: unless-stopped diff --git a/docs/current-state.md b/docs/current-state.md index e2bd75a..181d5de 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -144,23 +144,26 @@ Current behavior: ## 4. API auth, CORS, and websocket token handling -Current auth model is optional and token-based. +The auth model is token-based and **fails closed**. When `CONTROLPANE_API_AUTH_TOKEN` is unset: -- API routes are unauthenticated. +- API routes (other than `/health` and `OPTIONS` preflight) return HTTP `503`. +- To intentionally run without auth (trusted local use only), set + `CONTROLPANE_API_AUTH_DISABLED=1`. This is the only way auth is skipped. When `CONTROLPANE_API_AUTH_TOKEN` is set: -- HTTP requests require matching token from one of: +- HTTP requests require a matching token (constant-time compared) from one of: - `Authorization: Bearer ` - `X-API-Key: ` - - `?token=` query parameter + - HTTP query-string tokens are **not** accepted (they leak into logs and history). - `OPTIONS` and `/health` are exempt. -- websocket routes require matching token from: +- websocket routes require a matching token from: - bearer header - `X-API-Key` - - query token keys (`token`, `api_key`, `access_token`) + - query token keys (`token`, `api_key`, `access_token`). These are retained + because browsers cannot set headers on WebSocket connections. CORS behavior: From 62040c80407cb76f614039db53f86862a2de8127 Mon Sep 17 00:00:00 2001 From: Matt MacDougall Date: Mon, 22 Jun 2026 01:12:01 -0400 Subject: [PATCH 2/2] harden: move API token off the client (BFF) and canonicalize env var names H3 - dashboard API token no longer ships to the browser: - Add a same-origin server route (app/api/cp/[...slug]/route.ts) that proxies all REST calls to the control-plane API and injects the token from a server-only env var (CONTROLPLANE_API_TOKEN). Verified the token never appears in the client bundle. - lib/api.ts and lib/controlplane.ts now call the same-origin BFF with no token. - WebSocket streams (which browsers cannot send headers on) become opt-in via a dedicated, clearly client-visible NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN; when unset the dashboard falls back to REST polling through the authenticated BFF. - compose: dashboard reaches the API server-side over the compose network; bind the dashboard to loopback. L6 - env var spelling: - The canonical control-plane prefix is now CONTROLPLANE_. The historical misspelling CONTROLPANE_ is still honored as a deprecated alias, so correcting the spelling can never silently disable auth. - Updated config resolution, the smoke harness, compose, CI, and docs. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/controlplane-smoke.yml | 2 +- apps/controlplane-api/README.md | 17 +++- .../controlplane_api/config.py | 49 +++++++--- .../app/api/cp/[...slug]/route.ts | 98 +++++++++++++++++++ apps/controlplane-dashboard/app/lib/api.ts | 51 ++++------ .../app/lib/controlplane.ts | 20 +--- docker-compose.controlplane.yml | 20 ++-- docs/current-state.md | 16 ++- harness/controlplane-smoke.sh | 3 +- 9 files changed, 203 insertions(+), 73 deletions(-) create mode 100644 apps/controlplane-dashboard/app/api/cp/[...slug]/route.ts diff --git a/.github/workflows/controlplane-smoke.yml b/.github/workflows/controlplane-smoke.yml index 2e6da85..4185f1b 100644 --- a/.github/workflows/controlplane-smoke.yml +++ b/.github/workflows/controlplane-smoke.yml @@ -27,7 +27,7 @@ jobs: env: # The self-check exercises endpoint wiring, not auth. Opt out of # fail-closed auth explicitly so the unauthenticated probes succeed. - CONTROLPANE_API_AUTH_DISABLED: "1" + CONTROLPLANE_API_AUTH_DISABLED: "1" run: | python - <<'PY' import sys diff --git a/apps/controlplane-api/README.md b/apps/controlplane-api/README.md index 9d3a1d7..2f57375 100644 --- a/apps/controlplane-api/README.md +++ b/apps/controlplane-api/README.md @@ -49,8 +49,15 @@ docker build -f apps/controlplane-api/Dockerfile -t squirrelops-controlplane-api - `PINGTING_PYTHON_BIN` (optional explicit Python executable) - `PINGTING_STATUS_MAX_AGE_SECONDS` (default: `120`) - `PINGTING_STATUS_TIMEOUT_SECONDS` (default: `20`) -- `CONTROLPANE_API_AUTH_TOKEN` (shared API token; when unset the API fails closed and returns 503) -- `CONTROLPANE_API_AUTH_DISABLED` (set to `1` to intentionally run without auth, trusted local use only) -- `CONTROLPANE_CORS_ALLOW_ORIGINS` (comma-separated origins) -- `CONTROLPANE_ACTION_TIMEOUT_SECONDS` (default: `900`) -- `CONTROLPANE_BOOTSTRAP_SCRIPT_PATH` (default: `scripts/bootstrap_repos.sh`) +- `CONTROLPLANE_API_AUTH_TOKEN` (shared API token; when unset the API fails closed and returns 503) +- `CONTROLPLANE_API_AUTH_DISABLED` (set to `1` to intentionally run without auth, trusted local use only) +- `CONTROLPLANE_CORS_ALLOW_ORIGINS` (comma-separated origins) +- `CONTROLPLANE_ACTION_TIMEOUT_SECONDS` (default: `900`) +- `CONTROLPLANE_ACTION_STATE_PATH` (default: `data/controlplane/actions-state.json`) +- `CONTROLPLANE_BOOTSTRAP_SCRIPT_PATH` (default: `scripts/bootstrap_repos.sh`) +- `CONTROLPLANE_SMOKE_SCRIPT_PATH` (default: `harness/smoke.sh`) +- `CONTROLPLANE_UPDATE_SCRIPT_PATH` (default: `scripts/update_repos.sh`) + +> The control-plane variables were previously misspelled `CONTROLPANE_*` (missing +> the second "L"). Those legacy names still work as deprecated aliases, but the +> canonical `CONTROLPLANE_*` spelling above takes precedence and should be used. diff --git a/apps/controlplane-api/controlplane_api/config.py b/apps/controlplane-api/controlplane_api/config.py index 2eec8a9..8209d42 100644 --- a/apps/controlplane-api/controlplane_api/config.py +++ b/apps/controlplane-api/controlplane_api/config.py @@ -5,8 +5,33 @@ import os +def _env_raw(name: str) -> str | None: + """Read an env var, preferring the canonical name. + + The control-plane variables were historically misspelled "CONTROLPANE_" + (missing the second "L"). The correct "CONTROLPLANE_" spelling is now + canonical; the misspelled name is still honored as a deprecated alias so + "fixing the typo" can never silently change behavior (for example, leaving + authentication unconfigured). + """ + value = os.getenv(name) + if value is not None: + return value + if name.startswith("CONTROLPLANE_"): + legacy = "CONTROLPANE_" + name[len("CONTROLPLANE_"):] + legacy_value = os.getenv(legacy) + if legacy_value is not None: + return legacy_value + return None + + +def _env_str(name: str, default: str) -> str: + value = _env_raw(name) + return value if value is not None else default + + def _parse_int_env(name: str, default: int) -> int: - raw = os.getenv(name) + raw = _env_raw(name) if raw is None: return default try: @@ -16,7 +41,7 @@ def _parse_int_env(name: str, default: int) -> int: def _parse_bool_env(name: str, default: bool = False) -> bool: - raw = os.getenv(name) + raw = _env_raw(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} @@ -89,27 +114,27 @@ def load_settings() -> ControlPlaneSettings: pingting_status_max_age_seconds=_parse_int_env("PINGTING_STATUS_MAX_AGE_SECONDS", 120), pingting_command_timeout_seconds=_parse_int_env("PINGTING_STATUS_TIMEOUT_SECONDS", 20), orchestration_state_path=Path( - os.getenv( - "CONTROLPANE_ACTION_STATE_PATH", + _env_str( + "CONTROLPLANE_ACTION_STATE_PATH", str(repo_root / "data" / "controlplane" / "actions-state.json"), ) ).expanduser(), - orchestration_action_timeout_seconds=_parse_int_env("CONTROLPANE_ACTION_TIMEOUT_SECONDS", 900), + orchestration_action_timeout_seconds=_parse_int_env("CONTROLPLANE_ACTION_TIMEOUT_SECONDS", 900), bootstrap_script_path=Path( - os.getenv("CONTROLPANE_BOOTSTRAP_SCRIPT_PATH", str(repo_root / "scripts" / "bootstrap_repos.sh")) + _env_str("CONTROLPLANE_BOOTSTRAP_SCRIPT_PATH", str(repo_root / "scripts" / "bootstrap_repos.sh")) ).expanduser(), smoke_script_path=Path( - os.getenv("CONTROLPANE_SMOKE_SCRIPT_PATH", str(repo_root / "harness" / "smoke.sh")) + _env_str("CONTROLPLANE_SMOKE_SCRIPT_PATH", str(repo_root / "harness" / "smoke.sh")) ).expanduser(), update_script_path=Path( - os.getenv("CONTROLPANE_UPDATE_SCRIPT_PATH", str(repo_root / "scripts" / "update_repos.sh")) + _env_str("CONTROLPLANE_UPDATE_SCRIPT_PATH", str(repo_root / "scripts" / "update_repos.sh")) ).expanduser(), cors_allow_origins=_parse_origins( - os.getenv( - "CONTROLPANE_CORS_ALLOW_ORIGINS", + _env_str( + "CONTROLPLANE_CORS_ALLOW_ORIGINS", "http://127.0.0.1:4317,http://localhost:4317,http://127.0.0.1:3001,http://localhost:3001,http://127.0.0.1:3000,http://localhost:3000", ) ), - api_auth_token=os.getenv("CONTROLPANE_API_AUTH_TOKEN", "").strip(), - api_auth_disabled=_parse_bool_env("CONTROLPANE_API_AUTH_DISABLED", default=False), + api_auth_token=_env_str("CONTROLPLANE_API_AUTH_TOKEN", "").strip(), + api_auth_disabled=_parse_bool_env("CONTROLPLANE_API_AUTH_DISABLED", default=False), ) diff --git a/apps/controlplane-dashboard/app/api/cp/[...slug]/route.ts b/apps/controlplane-dashboard/app/api/cp/[...slug]/route.ts new file mode 100644 index 0000000..11f48c7 --- /dev/null +++ b/apps/controlplane-dashboard/app/api/cp/[...slug]/route.ts @@ -0,0 +1,98 @@ +import { NextRequest } from "next/server" + +// Server-side proxy (BFF) to the control-plane API. +// +// The browser talks only to this same-origin route; the control-plane auth token +// is read here from a SERVER-ONLY env var (no NEXT_PUBLIC prefix) and never ships +// to the client bundle. The upstream host is fixed by configuration, so callers +// cannot redirect this proxy at an arbitrary destination. + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const PROXY_PREFIX = "/api/cp" + +const upstreamBase = ( + process.env.CONTROLPLANE_API_INTERNAL_BASE ?? + process.env.CONTROLPANE_API_INTERNAL_BASE ?? + "http://127.0.0.1:8199" +).replace(/\/+$/, "") + +const upstreamToken = ( + process.env.CONTROLPLANE_API_TOKEN ?? + process.env.CONTROLPANE_API_TOKEN ?? + "" +).trim() + +// Hop-by-hop and identity headers that must not be forwarded upstream. +const STRIPPED_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "authorization", + "cookie", + "x-api-key", +]) + +const buildUpstreamUrl = (request: NextRequest): string => { + const pathname = request.nextUrl.pathname + const suffix = pathname.startsWith(PROXY_PREFIX) ? pathname.slice(PROXY_PREFIX.length) : pathname + const normalized = suffix.startsWith("/") ? suffix : `/${suffix}` + return `${upstreamBase}${normalized}${request.nextUrl.search}` +} + +const proxy = async (request: NextRequest): Promise => { + const url = buildUpstreamUrl(request) + + const headers = new Headers() + request.headers.forEach((value, key) => { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) { + headers.set(key, value) + } + }) + if (upstreamToken) { + headers.set("authorization", `Bearer ${upstreamToken}`) + } + + const method = request.method.toUpperCase() + const hasBody = method !== "GET" && method !== "HEAD" + const body = hasBody ? await request.arrayBuffer() : undefined + + let upstream: Response + try { + upstream = await fetch(url, { + method, + headers, + body, + redirect: "manual", + cache: "no-store", + }) + } catch { + return new Response(JSON.stringify({ detail: "control-plane API unreachable" }), { + status: 502, + headers: { "content-type": "application/json" }, + }) + } + + const responseHeaders = new Headers() + const contentType = upstream.headers.get("content-type") + if (contentType) { + responseHeaders.set("content-type", contentType) + } + const wwwAuth = upstream.headers.get("www-authenticate") + if (wwwAuth) { + responseHeaders.set("www-authenticate", wwwAuth) + } + + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }) +} + +export const GET = proxy +export const POST = proxy +export const PUT = proxy +export const PATCH = proxy +export const DELETE = proxy +export const OPTIONS = proxy diff --git a/apps/controlplane-dashboard/app/lib/api.ts b/apps/controlplane-dashboard/app/lib/api.ts index 7715208..e33e6b6 100644 --- a/apps/controlplane-dashboard/app/lib/api.ts +++ b/apps/controlplane-dashboard/app/lib/api.ts @@ -1,45 +1,32 @@ -const CONTROLPLANE_BASE = process.env.NEXT_PUBLIC_CONTROLPANE_API ?? "http://127.0.0.1:8199" -const CONTROLPLANE_WS_BASE = process.env.NEXT_PUBLIC_CONTROLPANE_WS ?? "ws://127.0.0.1:8199" -const API_BASE = - process.env.NEXT_PUBLIC_DECEPTION_API ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_API ?? - `${CONTROLPLANE_BASE}/deception` +// REST calls go through the same-origin BFF at /api/cp/*, which injects the +// control-plane auth token server-side (see app/api/cp/[...slug]/route.ts). +// No API token is shipped to the browser for REST traffic. +const API_BASE = "/api/cp/deception" + +// WebSockets cannot be proxied by `next start`, so they connect directly to the +// control-plane API. A browser WebSocket cannot send headers, so when the API +// requires auth the token must travel as a query parameter and is therefore +// visible to the client. This is opt-in: leave NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN +// unset to keep all secrets off the client (the dashboard transparently falls +// back to REST polling through the BFF), or set it to the API token to enable +// the live WebSocket stream and accept that it is client-visible. +const CONTROLPLANE_WS_BASE = + process.env.NEXT_PUBLIC_CONTROLPLANE_WS ?? + process.env.NEXT_PUBLIC_CONTROLPANE_WS ?? + "ws://127.0.0.1:8199" const WS_BASE = process.env.NEXT_PUBLIC_DECEPTION_WS ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS ?? `${CONTROLPLANE_WS_BASE}/deception/ws/events` const WS_THEATER_BASE = process.env.NEXT_PUBLIC_DECEPTION_WS_THEATER ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS_THEATER ?? `${CONTROLPLANE_WS_BASE}/deception/ws/theater/live` -const API_AUTH_TOKEN = ( - process.env.NEXT_PUBLIC_CONTROLPANE_API_TOKEN ?? - process.env.NEXT_PUBLIC_DECEPTION_API_TOKEN ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_API_TOKEN ?? - "" -).trim() const WS_AUTH_TOKEN = ( - process.env.NEXT_PUBLIC_CONTROLPANE_API_TOKEN ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS_TOKEN ?? - process.env.NEXT_PUBLIC_DECEPTION_WS_TOKEN ?? - process.env.NEXT_PUBLIC_DECEPTION_API_TOKEN ?? - process.env.NEXT_PUBLIC_CLOWNPEANUTS_API_TOKEN ?? + process.env.NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN ?? + process.env.NEXT_PUBLIC_CONTROLPANE_WS_TOKEN ?? "" ).trim() -const withApiAuthHeaders = (headers?: HeadersInit): Headers => { - const merged = new Headers(headers) - if (API_AUTH_TOKEN && !merged.has("Authorization") && !merged.has("X-API-Key")) { - merged.set("Authorization", `Bearer ${API_AUTH_TOKEN}`) - } - return merged -} - -const cpFetch = (url: string, init?: RequestInit): Promise => { - const nextInit = { ...(init ?? {}) } - nextInit.headers = withApiAuthHeaders(init?.headers) - return fetch(url, nextInit) -} +const cpFetch = (url: string, init?: RequestInit): Promise => fetch(url, init) const withApiTokenQuery = (url: string): string => { if (!WS_AUTH_TOKEN) { diff --git a/apps/controlplane-dashboard/app/lib/controlplane.ts b/apps/controlplane-dashboard/app/lib/controlplane.ts index e962cce..f87325a 100644 --- a/apps/controlplane-dashboard/app/lib/controlplane.ts +++ b/apps/controlplane-dashboard/app/lib/controlplane.ts @@ -1,19 +1,9 @@ -const CONTROLPLANE_API_BASE = process.env.NEXT_PUBLIC_CONTROLPANE_API ?? "http://127.0.0.1:8199" -const CONTROLPLANE_API_TOKEN = (process.env.NEXT_PUBLIC_CONTROLPANE_API_TOKEN ?? "").trim() - -const withControlPlaneHeaders = (headers?: HeadersInit): Headers => { - const merged = new Headers(headers) - if (CONTROLPLANE_API_TOKEN && !merged.has("Authorization") && !merged.has("X-API-Key")) { - merged.set("Authorization", `Bearer ${CONTROLPLANE_API_TOKEN}`) - } - return merged -} - +// REST calls go through the same-origin BFF at /api/cp/*, which injects the +// control-plane auth token server-side (see app/api/cp/[...slug]/route.ts). +// No control-plane token is exposed to the browser. const controlplaneFetch = (path: string, init?: RequestInit): Promise => { const normalizedPath = path.startsWith("/") ? path : `/${path}` - const nextInit = { ...(init ?? {}) } - nextInit.headers = withControlPlaneHeaders(init?.headers) - return fetch(`${CONTROLPLANE_API_BASE}${normalizedPath}`, nextInit) + return fetch(`/api/cp${normalizedPath}`, init) } -export { CONTROLPLANE_API_BASE, controlplaneFetch } +export { controlplaneFetch } diff --git a/docker-compose.controlplane.yml b/docker-compose.controlplane.yml index 36bda12..0737247 100644 --- a/docker-compose.controlplane.yml +++ b/docker-compose.controlplane.yml @@ -10,9 +10,9 @@ services: environment: CONTROLPLANE_WORKSPACE_ROOT: /Users/matt/code # Required for auth. The API fails closed (HTTP 503) when this is empty, - # unless CONTROLPANE_API_AUTH_DISABLED is set to 1 for trusted local use. - CONTROLPANE_API_AUTH_TOKEN: ${CONTROLPANE_API_AUTH_TOKEN:-} - CONTROLPANE_API_AUTH_DISABLED: ${CONTROLPANE_API_AUTH_DISABLED:-} + # unless CONTROLPLANE_API_AUTH_DISABLED is set to 1 for trusted local use. + CONTROLPLANE_API_AUTH_TOKEN: ${CONTROLPLANE_API_AUTH_TOKEN:-} + CONTROLPLANE_API_AUTH_DISABLED: ${CONTROLPLANE_API_AUTH_DISABLED:-} CLOWNPEANUTS_API_BASE: http://host.docker.internal:8099 CLOWNPEANUTS_WS_EVENTS_URL: ws://host.docker.internal:8099/ws/events CLOWNPEANUTS_WS_THEATER_URL: ws://host.docker.internal:8099/ws/theater/live @@ -32,10 +32,18 @@ services: build: context: ./apps/controlplane-dashboard ports: - - "4317:4317" + - "127.0.0.1:4317:4317" environment: - NEXT_PUBLIC_CONTROLPANE_API: http://127.0.0.1:8199 - NEXT_PUBLIC_CONTROLPANE_WS: ws://127.0.0.1:8199 + # Server-side BFF target. The dashboard server (not the browser) reaches the + # API over the compose network and injects the token, so it never ships to + # the client. Keep this in sync with the API's CONTROLPANE_API_AUTH_TOKEN. + CONTROLPLANE_API_INTERNAL_BASE: http://controlplane-api:8199 + CONTROLPLANE_API_TOKEN: ${CONTROLPLANE_API_AUTH_TOKEN:-} + # The live WebSocket stream is opt-in and, by browser limitation, uses a + # client-visible token. Leave unset to keep all secrets off the client (the + # dashboard falls back to REST polling). Set NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN + # to the API token to enable it. + NEXT_PUBLIC_CONTROLPLANE_WS: ws://127.0.0.1:8199 depends_on: - controlplane-api restart: unless-stopped diff --git a/docs/current-state.md b/docs/current-state.md index 181d5de..dd1c5eb 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -167,11 +167,25 @@ When `CONTROLPANE_API_AUTH_TOKEN` is set: CORS behavior: -- enabled when `CONTROLPANE_CORS_ALLOW_ORIGINS` resolves to non-empty list +- enabled when `CONTROLPLANE_CORS_ALLOW_ORIGINS` resolves to non-empty list - `allow_credentials=False` - `allow_methods=["*"]` - `allow_headers=["*"]` +> Environment variable spelling: the canonical control-plane prefix is +> `CONTROLPLANE_`. The previously shipped misspelling `CONTROLPANE_` still works +> as a deprecated alias but should not be used in new configuration. + +Dashboard token handling: + +- The dashboard makes all REST calls to a same-origin server route (`/api/cp/*`) + that injects the control-plane token from a server-only env var + (`CONTROLPLANE_API_TOKEN`). The token is never included in the client bundle. +- WebSocket streams connect directly to the API. Because browsers cannot set + headers on WebSocket connections, enabling the live stream requires a + client-visible token (`NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN`). It is opt-in: when + unset, the dashboard falls back to REST polling through the authenticated BFF. + ## 5. Current data flow ### 5.1 Overview tab diff --git a/harness/controlplane-smoke.sh b/harness/controlplane-smoke.sh index 9db4b1b..b05aebd 100755 --- a/harness/controlplane-smoke.sh +++ b/harness/controlplane-smoke.sh @@ -2,7 +2,8 @@ set -euo pipefail API_BASE="${1:-http://127.0.0.1:8199}" -TOKEN="${CONTROLPANE_API_AUTH_TOKEN:-}" +# Prefer the canonical spelling; fall back to the deprecated misspelled name. +TOKEN="${CONTROLPLANE_API_AUTH_TOKEN:-${CONTROLPANE_API_AUTH_TOKEN:-}}" request() { local path="$1"