Enforce deployment security boundaries
This commit is contained in:
@@ -9,14 +9,21 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||
from govoplan_core.core.install_config import validate_runtime_configuration
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
|
||||
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
|
||||
)
|
||||
|
||||
|
||||
def _slow_request_threshold_ms() -> float:
|
||||
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
|
||||
return 500.0
|
||||
|
||||
|
||||
def _hsts_seconds() -> int:
|
||||
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
|
||||
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except ValueError:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _max_request_body_bytes() -> int:
|
||||
default = 512 * 1024 * 1024
|
||||
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _trusted_hosts() -> tuple[str, ...]:
|
||||
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
|
||||
|
||||
|
||||
def _validate_production_startup() -> None:
|
||||
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
|
||||
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
|
||||
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
|
||||
return
|
||||
validation = validate_runtime_configuration()
|
||||
if validation.errors:
|
||||
raise RuntimeError(validation.to_text())
|
||||
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
||||
logger.warning(
|
||||
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
|
||||
)
|
||||
|
||||
|
||||
def create_govoplan_app(
|
||||
*,
|
||||
title: str,
|
||||
@@ -37,9 +87,27 @@ def create_govoplan_app(
|
||||
cors_origins: Iterable[str] = (),
|
||||
health_payload: dict[str, Any] | None = None,
|
||||
) -> FastAPI:
|
||||
_validate_production_startup()
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
|
||||
trusted_hosts = _trusted_hosts()
|
||||
if trusted_hosts:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
|
||||
app.state.govoplan_registry = registry
|
||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||
hsts_seconds = _hsts_seconds()
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_response_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
|
||||
if hsts_seconds and request.url.scheme == "https":
|
||||
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
|
||||
return response
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_correlation_context(request: Request, call_next):
|
||||
|
||||
Reference in New Issue
Block a user