security: harden data auth and deployment
This commit is contained in:
+41
-20
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user