From 2a88f556894109230d4b392f1e2cd10efcb19203 Mon Sep 17 00:00:00 2001 From: matitos Date: Sat, 19 Sep 2026 14:31:42 +0000 Subject: [PATCH] Make the API token fully optional The deploy job hard-failed without CRYPTO_API_TOKEN, and /api/ws (which skips Basic auth) relied on that token. Drop the requirement: the app now works with no token at all, matching the backend guard that was always a no-op when unset. When a token is configured it still protects /api and /api/ws; when it is not, /api/ws is as open as the rest of the API, which the Traefik Basic auth still guards for everything else. Adds tests for the tokenless API and WebSocket. --- .env.example | 6 ++++-- .gitea/workflows/deploy.yml | 12 ++++++------ AGENTS.md | 8 ++++---- README.md | 7 ++++--- backend/tests/test_api.py | 23 +++++++++++++++++++++++ docker-compose.yml | 7 +++---- frontend/nginx.conf | 4 ++-- 7 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 599bb0c..02d8ad8 100644 --- a/.env.example +++ b/.env.example @@ -44,8 +44,10 @@ MAX_POSITION_PCT=1.0 MAX_TRADES_PER_DAY=0 # --- API token (optional) --- -# When set, /api requires this token. The frontend nginx injects it server-side, -# so the browser never sees it. Leave empty to disable. +# When set, /api and the /api/ws live feed require this token. The frontend +# nginx injects it server-side, so the browser never sees it. Leave empty for +# no token; the app works fully without it. (The Traefik Basic auth still +# guards the UI in the deployed stack.) API_TOKEN= API_TOKEN_HEADER=X-API-Token diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cc0dce2..93b8c34 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -147,13 +147,13 @@ jobs: echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy" exit 1 fi - # /api/ws bypasses Basic auth (browsers don't reliably replay - # credentials on a WebSocket upgrade) and relies on the API token - # instead. Refuse to ship if that token is missing, or the live feed - # would be unauthenticated. + # CRYPTO_API_TOKEN is optional. When unset, the API and the /api/ws + # live feed are unauthenticated beyond the Traefik Basic auth that + # still guards everything except /api/ws. Set it to require a bearer + # token on /api (nginx injects it server-side, so the browser never + # sees it). if [ -z "$CRYPTO_API_TOKEN" ]; then - echo "::error::CRYPTO_API_TOKEN must be set because /api/ws skips Basic auth; refusing to deploy" - exit 1 + echo "Note: CRYPTO_API_TOKEN is not set; /api and /api/ws will not require a bearer token." fi - name: Log in to Docker Hub (optional) diff --git a/AGENTS.md b/AGENTS.md index da104ff..1a84116 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,10 +164,10 @@ real migration step. depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists). - `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router (`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a - WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx, - or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job - fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in - `nginx.conf` separate from `/api/` so upgrade headers are only set there. + WS upgrade. When `API_TOKEN` is set the WS requires it (header, injected by + nginx, or `?token=` for manual clients); when unset the path is open, matching + the rest of `/api`. `CRYPTO_API_TOKEN` is optional. Keep the `/api/ws` block + in `nginx.conf` separate from `/api/` so upgrade headers are only set there. ## Backtesting diff --git a/README.md b/README.md index 469ea26..8cd6d76 100644 --- a/README.md +++ b/README.md @@ -136,9 +136,10 @@ auth, not instead of it. `/api/ws` is special: browsers do not reliably replay Basic-auth credentials on a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so Traefik serves that one path on a separate higher-priority router **without** -Basic auth. It is still gated by `API_TOKEN`, which nginx injects on the -handshake. Because of this the deploy **requires** `CRYPTO_API_TOKEN` to be set — -without it the WebSocket would be unauthenticated. +Basic auth. If `API_TOKEN` is set, the WebSocket still requires it (nginx +injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as +the rest of `/api`, which is fine for a trusted/private deployment but is the +reason to set a token if the host is exposed. ## Risk controls diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 02dcb4e..db93f06 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -145,6 +145,29 @@ def test_api_token_enforced(runtime: Runtime) -> None: assert client.get("/api/health").status_code == 200 +def test_websocket_connects_without_any_token(runtime: Runtime) -> None: + # API_TOKEN is unset by default: the API and the live feed must work with no + # token at all, even when (as nginx does) an empty token header is sent. + runtime.settings.api_token = "" + app = create_app(runtime.settings, runtime) + with ( + TestClient(app) as client, + client.websocket_connect("/api/ws", headers={"X-API-Token": ""}) as websocket, + ): + message = websocket.receive_json() + assert message["type"] == "status" + assert message["data"]["running"] is False + + +def test_api_works_without_token(runtime: Runtime) -> None: + runtime.settings.api_token = "" + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + assert client.get("/api/status").status_code == 200 + assert client.get("/api/strategies").status_code == 200 + assert client.post("/api/backtest", json={"strategy_key": "sma_cross"}).status_code != 401 + + def test_websocket_rejects_missing_token(runtime: Runtime) -> None: runtime.settings.api_token = "secret" app = create_app(runtime.settings, runtime) diff --git a/docker-compose.yml b/docker-compose.yml index 309cb10..ffb1cc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,10 +98,9 @@ services: - "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}" # Browsers do not reliably replay Basic-auth credentials on a WebSocket # upgrade, which left the live feed stuck "reconnecting". Give /api/ws its - # own higher-priority router that skips Basic auth; the backend still - # requires the API token, which nginx injects server-side. When no API - # token is configured this path is unauthenticated, so only use it on a - # trusted network. + # own higher-priority router that skips Basic auth so the feed connects. + # If CRYPTO_API_TOKEN is set, the backend still requires it (nginx injects + # it server-side); if it is unset, /api/ws is as open as the rest of /api. - "traefik.http.routers.crypto-trading-ws.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`) && PathPrefix(`/api/ws`)" - "traefik.http.routers.crypto-trading-ws.entrypoints=websecure" - "traefik.http.routers.crypto-trading-ws.tls.certresolver=myresolvercd" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index de73faa..23cdafc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,8 +9,8 @@ server { } # WebSocket feed. Kept in its own block so the upgrade headers are only set - # here, and so the API token is injected server-side on the handshake (the - # browser cannot set headers on a WebSocket). + # here, and so the optional API token is injected server-side on the + # handshake (the browser cannot set headers on a WebSocket). location /api/ws { proxy_pass http://backend:8000; proxy_http_version 1.1;