feat: bind operator mode to public origin (Closes #609)
This commit is contained in:
1 parent
075a277265
commit
8ea46974ff
6 files changed
+138
-7
No files matched your search
+32
-2
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user