Fail closed when dashboard authentication is not explicitly configured #263
24
README.md
24
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
|
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
9
conftest.py
Normal 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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
20
src/main.py
20
src/main.py
|
|
@ -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/")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user