Enforce deployment security boundaries
This commit is contained in:
@@ -262,7 +262,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
audit_event="files.connector_profile.updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
||||
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="DATABASE_URL",
|
||||
@@ -289,6 +289,174 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
two_person_approval_required=True,
|
||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
label="Private-network connector access",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
label="Structured connector response limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
label="Connector file-transfer limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
label="Connector secret environment allowlist",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||
label="Connector CA bundle allowlist",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
label="HTTP request-body limit",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||
label="HTTP Strict Transport Security duration",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_ENABLED",
|
||||
label="Interactive login throttling",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Disabling the deployment login throttle weakens protection against password guessing.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||
label="Login failures per identity",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Shared through Redis when available, with a bounded process-local fallback.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||
label="Login failures per client",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||
label="Login throttle window",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Fixed counter window shared by identity and direct-client limits.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||
label="Login throttle Redis retry interval",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="medium",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_TRUSTED_HOSTS",
|
||||
label="Trusted HTTP hosts",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Host-header validation is deployment-managed and must cover every public API host.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="FORWARDED_ALLOW_IPS",
|
||||
label="Trusted reverse proxy addresses",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||
label="Module package catalog trusted keys",
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
|
||||
_validate_async_and_auth_settings(env, runtime, collector)
|
||||
_validate_cors_settings(env, runtime, collector)
|
||||
_validate_file_storage_settings(env, runtime, collector)
|
||||
_validate_outbound_connector_policy(env, runtime, collector)
|
||||
_validate_module_catalog_trust(env, runtime, collector)
|
||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||
|
||||
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||
if runtime.production_like and not trusted_hosts:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||
elif "*" in trusted_hosts and runtime.production_like:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||
|
||||
|
||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
|
||||
|
||||
def _validate_outbound_connector_policy(
|
||||
env: Mapping[str, str],
|
||||
runtime: _RuntimeProfile,
|
||||
collector: _ConfigIssueCollector,
|
||||
) -> None:
|
||||
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
"Private-network connector access must be an explicit deployment decision.",
|
||||
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||
)
|
||||
for key in (
|
||||
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
):
|
||||
value = _clean(env.get(key))
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||
secret_env_names = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
"Connector secret environment allowlist contains an invalid variable name.",
|
||||
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||
)
|
||||
ca_bundle_paths = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
if not runtime.production or not catalog_source:
|
||||
@@ -277,7 +341,28 @@ REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
|
||||
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||
|
||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||
|
||||
CORS_ORIGINS=https://govoplan.example.org
|
||||
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
|
||||
# restricted to the actual reverse proxy address or network.
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
AUTH_COOKIE_SECURE=true
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
@@ -321,8 +406,24 @@ CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||
|
||||
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||
|
||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
AUTH_COOKIE_SECURE=false
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||
|
||||
@@ -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):
|
||||
|
||||
71
src/govoplan_core/server/request_limits.py
Normal file
71
src/govoplan_core/server/request_limits.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class _RequestBodyTooLarge(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
def __init__(self, app: Any, *, max_bytes: int) -> None:
|
||||
if max_bytes <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
self.app = app
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
declared = _content_length(scope.get("headers", ()))
|
||||
if declared is not None and declared > self.max_bytes:
|
||||
await self._reject(scope, receive, send)
|
||||
return
|
||||
|
||||
received = 0
|
||||
response_started = False
|
||||
|
||||
async def limited_receive() -> dict[str, Any]:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message.get("type") == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > self.max_bytes:
|
||||
raise _RequestBodyTooLarge
|
||||
return message
|
||||
|
||||
async def tracked_send(message: dict[str, Any]) -> None:
|
||||
nonlocal response_started
|
||||
if message.get("type") == "http.response.start":
|
||||
response_started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, limited_receive, tracked_send)
|
||||
except _RequestBodyTooLarge:
|
||||
if response_started:
|
||||
raise
|
||||
await self._reject(scope, receive, send)
|
||||
|
||||
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
response = JSONResponse(
|
||||
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
|
||||
status_code=413,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
|
||||
for key, value in headers:
|
||||
if bytes(key).lower() != b"content-length":
|
||||
continue
|
||||
try:
|
||||
parsed = int(bytes(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed >= 0 else None
|
||||
return None
|
||||
@@ -47,6 +47,27 @@ class Settings(BaseSettings):
|
||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||
)
|
||||
auth_login_throttle_client_limit: int = Field(
|
||||
default=100,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||
)
|
||||
auth_login_throttle_window_seconds: int = Field(
|
||||
default=15 * 60,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||
)
|
||||
auth_login_throttle_redis_retry_seconds: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||
)
|
||||
|
||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||
|
||||
Reference in New Issue
Block a user