feat: fail closed on invalid dashboard auth (#262)
This commit is contained in:
1 parent
7bdfe62495
commit
00a956b1fb
5 files changed
+139
-17
No files matched your search
+35
-6
@@ -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:
|
||||
|
||||
+18
-2
@@ -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/")
|
||||
|
||||
Reference in new issue
Block a user