Fail closed when dashboard authentication is not explicitly configured #263

Merged
timmy merged 1 commits from timmy/262-fail-closed-dashboard-auth into main 2026-08-08 04:33:26 +00:00
5 changed files with 139 additions and 17 deletions

View File

@ -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 request. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge. state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API 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 access is intentionally disabled. Authentication defaults to fail-closed
authenticated gateway, configure the built-in single-operator boundary with two `operator` mode. It requires two independent secrets of at least 24 characters;
independent high-entropy secrets. The access token is entered at `/login`; the missing, short, reused, or invalid configuration leaves `/healthz` available but
browser receives only a short-lived signed session and CSRF proof. Generate the returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
values in your secret manager (for example, `openssl rand -hex 32`) and inject 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: them at runtime—never commit them:
```bash ```bash
export GITEA_URL='https://forge.example.com' export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-notification-and-issue-write-token>' export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>' export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>' export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to eight hours. # 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 offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing removing unrelated forge preferences. Rotate either dashboard secret by replacing
the injected value and restarting the service; changing the signing secret expires 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 For direct, unproxied local development only, set
verify the backend and its Gitea connection directly, sign in and request `STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing `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 `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 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, the configured Gitea token. Remote search starts after two characters, is debounced,

9
conftest.py Normal file
View File

@ -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)

View File

@ -3,6 +3,7 @@
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import ipaddress
import json import json
import os import os
import secrets import secrets
@ -14,6 +15,9 @@ from fastapi import Request
SESSION_COOKIE = "stackchain_session" SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf" CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60 DEFAULT_TTL_SECONDS = 8 * 60 * 60
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"
@dataclass(frozen=True) @dataclass(frozen=True)
@ -26,17 +30,42 @@ def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") 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: 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: def _secret() -> bytes:
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "") configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if configured: if configuration_error() is not None or mode() != OPERATOR_MODE:
return configured.encode() raise RuntimeError("Dashboard authentication is not configured")
return hmac.new( return configured.encode()
access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256
).digest()
def _encode(raw: bytes) -> str: def _encode(raw: bytes) -> str:

View File

@ -430,10 +430,26 @@ app.include_router(frontend_router)
@app.middleware("http") @app.middleware("http")
async def require_operator_session(request: Request, call_next): 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) return await call_next(request)
path = dashboard_auth.application_path(request)
public = ( public = (
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"} path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
or path.startswith("/static/") or path.startswith("/static/")

View File

@ -6,10 +6,72 @@ from src import main
@pytest.fixture @pytest.fixture
def access_control(monkeypatch): 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_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") 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 @pytest.mark.anyio
async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch): async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch):
called = False called = False