security: harden data auth and deployment

This commit is contained in:
2026-09-17 18:31:35 +12:00
parent a6d1c73837
commit 5639dbcb83
32 changed files with 1401 additions and 378 deletions
+41 -20
View File
@@ -7,6 +7,7 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings
from .db import has_admin_user, init_db
@@ -47,11 +48,11 @@ from .logging_config import (
configure_logging,
reset_request_id,
sanitize_headers,
sanitize_value,
summarize_http_body,
sanitize_path,
)
from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .secret_storage import validate_secret_storage_configuration
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
@@ -82,22 +83,33 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
operation_token = begin_operation(
operation_id,
label=request.headers.get("X-Magent-Operation-Label"),
path=request.url.path,
path=sanitize_path(request.url.path),
)
request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "").rstrip("/")
allowed_origin = str(settings.cors_allow_origin or "").rstrip("/")
if origin and origin != allowed_origin:
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
reset_operation(operation_token)
reset_request_id(token)
return JSONResponse(
status_code=403,
content={"detail": "Cross-origin state change rejected"},
headers={"X-Request-ID": request_id},
)
started_at = time.perf_counter()
body = await request.body()
body_summary = summarize_http_body(body, request.headers.get("content-type"))
async def receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
request._receive = receive
body_summary = {
"content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
"declared_bytes": request.headers.get("content-length"),
}
logger.info(
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
"request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
request.method,
request.url.path,
sanitize_value(dict(request.query_params)),
sanitize_path(request.url.path),
sorted(set(request.query_params.keys())),
request.client.host if request.client else "-",
sanitize_headers(
{
@@ -124,7 +136,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.exception(
"request failed method=%s path=%s duration_ms=%s",
request.method,
request.url.path,
sanitize_path(request.url.path),
duration_ms,
)
if operation_id and operation_token is not None:
@@ -140,6 +152,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
@@ -149,7 +162,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
logger.info(
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
request.method,
request.url.path,
sanitize_path(request.url.path),
response.status_code,
duration_ms,
sanitize_headers(
@@ -203,9 +216,9 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
if len(jwt_secret) < 32 or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is unset or still set to the default value"
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
@@ -218,10 +231,17 @@ def _log_security_configuration_warnings() -> None:
)
def _enforce_secure_startup_configuration() -> None:
def _enforce_secret_configuration() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
if len(jwt_secret) < 32 or jwt_secret == "change-me":
raise RuntimeError(
"JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
)
validate_secret_storage_configuration()
def _enforce_secure_startup_configuration() -> None:
_enforce_secret_configuration()
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
@@ -242,6 +262,7 @@ async def startup() -> None:
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
_enforce_secret_configuration()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()