feat: bind operator mode to public origin (Closes #609)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-12 01:54:51 +00:00
1 parent 075a277265
commit 8ea46974ff
6 files changed
+138 -7

No files matched your search

+32 -2
View File
@@ -10,6 +10,7 @@ import os
import secrets
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
from fastapi import Request
@@ -47,6 +48,33 @@ def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def public_origin() -> str | None:
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
if not configured:
return None
try:
parsed = urlsplit(configured)
port = parsed.port
except ValueError:
return None
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
return None
hostname = parsed.hostname.lower()
authority = f"[{hostname}]" if ":" in hostname else hostname
if port is not None and port != 443:
authority += f":{port}"
return f"https://{authority}"
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
@@ -59,6 +87,8 @@ def configuration_error() -> str | None:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
if public_origin() is None:
return "operator mode requires a canonical HTTPS public origin"
return None
@@ -274,5 +304,5 @@ def application_path(request: Request) -> str:
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
expected = public_origin()
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
+16 -3
View File
@@ -13,7 +13,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.exceptions import RequestValidationError
@@ -340,10 +340,12 @@ def _security_event_store() -> SecurityEventStore:
def _passkey_relying_party(request: Request) -> tuple[str, str]:
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
canonical_origin = dashboard_auth.public_origin()
canonical_host = urlsplit(canonical_origin).hostname if canonical_origin else None
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", canonical_host or request.url.hostname or "")
origin = os.getenv(
"STACKCHAIN_PASSKEY_ORIGIN",
f"{request.url.scheme}://{request.url.netloc}",
canonical_origin or f"{request.url.scheme}://{request.url.netloc}",
)
return rp_id, origin
@@ -886,6 +888,15 @@ def _share_target_login_redirect(request: Request) -> str:
@app.middleware("http")
async def require_operator_session(request: Request, call_next):
path = dashboard_auth.application_path(request)
canonical_origin = dashboard_auth.public_origin()
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE and canonical_origin:
expected_authority = urlsplit(canonical_origin).netloc
if request.url.netloc.lower() != expected_authority:
return JSONResponse(
{"detail": "Request host does not match the configured public origin"},
status_code=421,
headers={"Cache-Control": "no-store"},
)
if path == "/healthz":
return await call_next(request)
@@ -1043,6 +1054,8 @@ async def enforce_browser_security_boundary(request: Request, call_next):
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
)
response.headers["X-Frame-Options"] = "DENY"
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE:
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response