diff --git a/README.md b/README.md index 251ac92..26848dc 100644 --- a/README.md +++ b/README.md @@ -45,16 +45,18 @@ comment path and submits the summary, decision, and inline comments in one revie request. The dashboard rechecks the current pull-request head, CI success, draft state, and mergeability immediately before every merge. Serve the dashboard only to trusted users on its own origin; cross-origin API -access is intentionally disabled. For any deployment not already behind an -authenticated gateway, configure the built-in single-operator boundary with two -independent high-entropy secrets. The access token is entered at `/login`; the -browser receives only a short-lived signed session and CSRF proof. Generate the -values in your secret manager (for example, `openssl rand -hex 32`) and inject +access is intentionally disabled. Authentication defaults to fail-closed +`operator` mode. It requires two independent secrets of at least 24 characters; +missing, short, reused, or invalid configuration leaves `/healthz` available but +returns HTTP 503 before any Gitea access. The access token is entered at `/login`; +the browser receives only a short-lived signed session and CSRF proof. Generate +both values in your secret manager (for example, `openssl rand -hex 32`) and inject them at runtime—never commit them: ```bash export GITEA_URL='https://forge.example.com' export GITEA_TOKEN='' +export STACKCHAIN_DASHBOARD_AUTH_MODE='operator' export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='' export STACKCHAIN_DASHBOARD_SESSION_SECRET='' # Optional; defaults to eight hours. @@ -68,11 +70,15 @@ Use **Sign out & clear this device** on shared devices; it clears Stackchain's offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without removing unrelated forge preferences. Rotate either dashboard secret by replacing the injected value and restarting the service; changing the signing secret expires -all sessions immediately. +all sessions immediately. The signing key is never derived from the access token. -Open `http://127.0.0.1:8000/` only for an unprotected local development run. To -verify the backend and its Gitea connection directly, sign in and request -`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing +For direct, unproxied local development only, set +`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open +`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback +clients; do not use it behind a reverse proxy, whose loopback connection would hide +the browser's peer address. To verify the backend and its Gitea connection in +operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a +successful response is JSON containing `user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard to search commands plus issues and pull requests across every repository visible to the configured Gitea token. Remote search starts after two characters, is debounced, diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..4c13c15 --- /dev/null +++ b/conftest.py @@ -0,0 +1,9 @@ +import pytest + + +@pytest.fixture(autouse=True) +def explicit_local_dashboard_auth_mode(monkeypatch): + """Keep tests intentional now that deployed authentication fails closed.""" + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local") + monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False) \ No newline at end of file diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 6645b29..fe3f406 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -3,6 +3,7 @@ import base64 import hashlib import hmac +import ipaddress import json import os import secrets @@ -14,6 +15,9 @@ from fastapi import Request SESSION_COOKIE = "stackchain_session" CSRF_COOKIE = "stackchain_csrf" DEFAULT_TTL_SECONDS = 8 * 60 * 60 +MIN_SECRET_LENGTH = 24 +OPERATOR_MODE = "operator" +INSECURE_LOCAL_MODE = "insecure-local" @dataclass(frozen=True) @@ -26,17 +30,42 @@ def access_token() -> str: return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") +def mode() -> str: + return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower() + + +def configuration_error() -> str | None: + configured_mode = mode() + if configured_mode == INSECURE_LOCAL_MODE: + return None + if configured_mode != OPERATOR_MODE: + return "unsupported authentication mode" + operator_secret = access_token() + signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "") + if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH: + return "operator mode requires two sufficiently long secrets" + if hmac.compare_digest(operator_secret, signing_secret): + return "operator and session secrets must be independent" + return None + + def enabled() -> bool: - return bool(access_token()) + return mode() == OPERATOR_MODE and configuration_error() is None + + +def is_loopback_request(request: Request) -> bool: + host = request.client.host if request.client else "" + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return host == "localhost" def _secret() -> bytes: configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "") - if configured: - return configured.encode() - return hmac.new( - access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256 - ).digest() + if configuration_error() is not None or mode() != OPERATOR_MODE: + raise RuntimeError("Dashboard authentication is not configured") + return configured.encode() def _encode(raw: bytes) -> str: diff --git a/src/main.py b/src/main.py index fcd60fa..89c15d6 100644 --- a/src/main.py +++ b/src/main.py @@ -430,10 +430,26 @@ app.include_router(frontend_router) @app.middleware("http") async def require_operator_session(request: Request, call_next): - if not dashboard_auth.enabled(): + path = dashboard_auth.application_path(request) + if path == "/healthz": + return await call_next(request) + + if dashboard_auth.configuration_error() is not None: + return JSONResponse( + {"detail": "Dashboard authentication is not configured"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + + if dashboard_auth.mode() == dashboard_auth.INSECURE_LOCAL_MODE: + if not dashboard_auth.is_loopback_request(request): + return JSONResponse( + {"detail": "Insecure local mode requires a loopback client"}, + status_code=403, + headers={"Cache-Control": "no-store"}, + ) return await call_next(request) - path = dashboard_auth.application_path(request) public = ( path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"} or path.startswith("/static/") diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 3c7124a..22dc59f 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -6,10 +6,72 @@ from src import main @pytest.fixture def access_control(monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") +@pytest.mark.anyio +async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch): + monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False) + called = False + + async def user(): + nonlocal called + called = True + return {"id": 1, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + health = await client.get("/healthz") + responses = [ + await client.get("/readyz"), + await client.get("/login"), + await client.get("/"), + await client.get("/api/v1/context"), + ] + + assert health.status_code == 200 + assert [response.status_code for response in responses] == [503, 503, 503, 503] + assert all(response.headers["cache-control"] == "no-store" for response in responses) + assert all(response.json() == { + "detail": "Dashboard authentication is not configured" + } for response in responses) + assert called is False + + +@pytest.mark.anyio +async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters") + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + reused = await client.get("/login") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "") + incomplete = await client.get("/login") + + assert reused.status_code == 503 + assert incomplete.status_code == 503 + assert "same-secret" not in reused.text + + +@pytest.mark.anyio +async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local") + local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234)) + remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234)) + async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client: + local = await client.get("/login") + async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client: + remote = await client.get("/login") + + assert local.status_code == 200 + assert remote.status_code == 403 + assert remote.json() == {"detail": "Insecure local mode requires a loopback client"} + + @pytest.mark.anyio async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch): called = False