From 230ecf42b080af62fcbc4a6502c9830ccf895e9a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 12:10:05 +0200 Subject: [PATCH] Enforce deployment security boundaries --- docs/DEPLOYMENT_OPERATOR_GUIDE.md | 45 +++++ docs/SELF_HOSTED_INSTALLABILITY.md | 8 +- .../core/configuration_safety.py | 170 +++++++++++++++++- src/govoplan_core/core/install_config.py | 101 +++++++++++ src/govoplan_core/server/fastapi.py | 68 +++++++ src/govoplan_core/server/request_limits.py | 71 ++++++++ src/govoplan_core/settings.py | 21 +++ tests/test_api_smoke.py | 58 +++--- tests/test_core_events.py | 72 +++++++- tests/test_install_config.py | 43 +++++ 10 files changed, 629 insertions(+), 28 deletions(-) create mode 100644 src/govoplan_core/server/request_limits.py diff --git a/docs/DEPLOYMENT_OPERATOR_GUIDE.md b/docs/DEPLOYMENT_OPERATOR_GUIDE.md index 6a6a1fd..f00abad 100644 --- a/docs/DEPLOYMENT_OPERATOR_GUIDE.md +++ b/docs/DEPLOYMENT_OPERATOR_GUIDE.md @@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`. | Setting | Default | Notes | | --- | --- | --- | | `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. | +| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. | +| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. | | `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. | | `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. | | `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. | | `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. | | `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. | +| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. | +| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. | + +Interactive password login is enabled with fixed-window limits of 10 failures +per normalized identity and 100 failures per direct client over 900 seconds. +`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL` +when Redis is reachable so replicas share state; a bounded process-local +fallback keeps development and Redis outages functional, with per-process +enforcement until Redis recovers. + +### Outbound Connector Egress + +| Setting | Default | Notes | +| --- | --- | --- | +| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when connectors, mail, SMB, or object storage must reach internal addresses. | +| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. | +| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. | +| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. | +| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. | + +Production-like configuration validation requires the private-network choice to +be explicit. HTTP connector downloads are streamed up to the configured bound, +and credential-bearing DAV redirects remain confined to their configured +origin. + +The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and +connect to the same approved address record while retaining the original host +for HTTP Host, TLS SNI, and certificate verification. SMB uses an approved +numeric connection target in public-only mode. The S3 SDK cannot provide the +same peer and redirect guarantee, so S3 connector endpoints fail closed while +application-enforced public-only egress is required. Production deployments +should still enforce the same decision at their worker/container egress firewall +or outbound proxy as a second boundary. + +File connector TLS verification may be disabled only in dev/test. A custom CA +bundle must be an existing regular file whose resolved absolute path is listed +in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector +credentials are supported only in deployment-owned connector JSON and require +their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles +must use encrypted stored credentials or a scoped secret-provider reference. Public URLs are currently supplied by deployment/reverse-proxy configuration and module settings. Do not hardcode them in core; configuration packages should ask for portal, WebUI, postbox, and notification URLs when they become relevant. +Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value +aligned with the reverse proxy and do not expose the application server directly +through the same trusted address range. ### Module Catalogs, Licenses, And Trust Roots diff --git a/docs/SELF_HOSTED_INSTALLABILITY.md b/docs/SELF_HOSTED_INSTALLABILITY.md index fda0c44..c6ba724 100644 --- a/docs/SELF_HOSTED_INSTALLABILITY.md +++ b/docs/SELF_HOSTED_INSTALLABILITY.md @@ -40,10 +40,16 @@ set +a The command reports all known blockers at once. Production-like/self-hosted profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`, -`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development +`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for +`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development bootstrap, insecure auth cookies, and unsigned catalog trust roots when a catalog source is configured. +Connector process-secret names and custom CA files are deployment-owned through +the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and +`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen +either boundary. + ## Production-Like Dev Stack Use the local production-like wrapper for repeatable rehearsal: diff --git a/src/govoplan_core/core/configuration_safety.py b/src/govoplan_core/core/configuration_safety.py index b5d38b9..58bcf02 100644 --- a/src/govoplan_core/core/configuration_safety.py +++ b/src/govoplan_core/core/configuration_safety.py @@ -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", diff --git a/src/govoplan_core/core/install_config.py b/src/govoplan_core/core/install_config.py index b12d465..7ef7caa 100644 --- a/src/govoplan_core/core/install_config.py +++ b/src/govoplan_core/core/install_config.py @@ -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 diff --git a/src/govoplan_core/server/fastapi.py b/src/govoplan_core/server/fastapi.py index dfecc38..712d81a 100644 --- a/src/govoplan_core/server/fastapi.py +++ b/src/govoplan_core/server/fastapi.py @@ -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): diff --git a/src/govoplan_core/server/request_limits.py b/src/govoplan_core/server/request_limits.py new file mode 100644 index 0000000..a485f65 --- /dev/null +++ b/src/govoplan_core/server/request_limits.py @@ -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 diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index d806b70..a51d881 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -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") diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 4939803..0412ef0 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -1811,12 +1811,21 @@ class ApiSmokeTests(unittest.TestCase): tenant_id = str(login["tenant"]["id"]) env_keys = [ "GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON", + "GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "GOVOPLAN_TEST_SEAFILE_PASSWORD", "GOVOPLAN_TEST_NEXTCLOUD_TOKEN", "GOVOPLAN_TEST_SMB_PASSWORD", ] previous_env = {key: os.environ.get(key) for key in env_keys} try: + os.environ["GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST"] = ",".join( + ( + "GOVOPLAN_TEST_SEAFILE_PASSWORD", + "GOVOPLAN_TEST_WEBDAV_PASSWORD", + "GOVOPLAN_TEST_NEXTCLOUD_TOKEN", + "GOVOPLAN_TEST_SMB_PASSWORD", + ) + ) os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile" os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud" os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb" @@ -2203,36 +2212,37 @@ class ApiSmokeTests(unittest.TestCase): self.assertFalse(deleted_space.json()["is_active"]) self.assertIsNotNone(deleted_space.json()["deleted_at"]) - import httpx + from govoplan_files.backend.storage.http_client import ConnectorHttpResponse def seafile_request(method: str, url: str, **kwargs): - request = httpx.Request(method, url) params = kwargs.get("params") or {} if url == "http://127.0.0.1:9082/api2/auth-token/": - return httpx.Response(200, json={"token": "token-1"}, request=request) + return ConnectorHttpResponse(200, {}, b'{"token":"token-1"}') if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/": self.assertEqual(params.get("p"), "/reports/summary.txt") self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1") - return httpx.Response( + return ConnectorHttpResponse( 200, - json={ - "id": "file-1", - "name": "summary.txt", - "size": 14, - "type": "file", - "mtime": 1783425600, - "permission": "r", - }, - request=request, + {}, + json.dumps( + { + "id": "file-1", + "name": "summary.txt", + "size": 14, + "type": "file", + "mtime": 1783425600, + "permission": "r", + } + ).encode(), ) if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/": self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"}) - return httpx.Response(200, json="https://download.example.invalid/summary.txt", request=request) + return ConnectorHttpResponse(200, {}, b'"https://download.example.invalid/summary.txt"') if url == "https://download.example.invalid/summary.txt": - return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request) - return httpx.Response(404, request=request) + return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report") + return ConnectorHttpResponse(404, {}, b"") - with patch("govoplan_files.backend.storage.connector_browse.httpx.request", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=seafile_request): + with patch("govoplan_files.backend.storage.connector_browse.request_connector_bytes", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=seafile_request): imported = self.client.post( "/api/v1/files/connectors/profiles/seafile-dev/import", headers=headers, @@ -2259,18 +2269,16 @@ class ApiSmokeTests(unittest.TestCase): webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""} def webdav_request(method: str, url: str, **kwargs): - request = httpx.Request(method, url) self.assertEqual(method, "GET") self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt") self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud") - return httpx.Response( + return ConnectorHttpResponse( 200, - content=webdav_payload["content"], - headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))}, - request=request, + {"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))}, + webdav_payload["content"], ) - with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): + with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request): nextcloud_import = self.client.post( "/api/v1/files/connectors/profiles/user-nextcloud/import", headers=headers, @@ -2292,7 +2300,7 @@ class ApiSmokeTests(unittest.TestCase): webdav_payload["content"] = b"nextcloud notice updated" webdav_payload["etag"] = "\"nextcloud-etag-3\"" - with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): + with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request): nextcloud_sync = self.client.post( "/api/v1/files/connectors/profiles/user-nextcloud/sync", headers=headers, @@ -2314,7 +2322,7 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"") self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"])) - with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): + with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request): nextcloud_sync_unchanged = self.client.post( "/api/v1/files/connectors/profiles/user-nextcloud/sync", headers=headers, diff --git a/tests/test_core_events.py b/tests/test_core_events.py index ba2866a..b661d03 100644 --- a/tests/test_core_events.py +++ b/tests/test_core_events.py @@ -4,8 +4,9 @@ import shutil import tempfile import unittest from pathlib import Path +from unittest.mock import patch -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.testclient import TestClient from govoplan_core.audit.logging import audit_event, audit_operation_context @@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase): cors_origins=("*",), ) + def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None: + with patch.dict( + "os.environ", + {"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"}, + ): + app = create_govoplan_app( + title="security header test", + version="test", + registry=PlatformRegistry(), + ) + + with TestClient(app, base_url="https://govoplan.example.test") as client: + response = client.get("/health") + + self.assertEqual("nosniff", response.headers["X-Content-Type-Options"]) + self.assertEqual("DENY", response.headers["X-Frame-Options"]) + self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"]) + self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"]) + self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"]) + + def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None: + with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex( + RuntimeError, + "GovOPlaN configuration validation: FAILED", + ): + create_govoplan_app( + title="unsafe production test", + version="test", + registry=PlatformRegistry(), + ) + + def test_app_rejects_request_bodies_above_deployment_limit(self) -> None: + router = APIRouter() + + @router.post("/body") + async def body(request: Request): + return {"size": len(await request.body())} + + with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}): + app = create_govoplan_app( + title="request body limit test", + version="test", + registry=PlatformRegistry(), + api_router=router, + ) + + with TestClient(app) as client: + response = client.post("/body", content=b"12345678901") + streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901"))) + + self.assertEqual(413, response.status_code, response.text) + self.assertIn("10 bytes", response.json()["detail"]) + self.assertEqual(413, streamed.status_code, streamed.text) + + def test_app_enforces_configured_trusted_hosts(self) -> None: + with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}): + app = create_govoplan_app( + title="trusted host test", + version="test", + registry=PlatformRegistry(), + ) + + with TestClient(app, base_url="https://govoplan.example.test") as client: + allowed = client.get("/health") + rejected = client.get("https://attacker.example.test/health") + + self.assertEqual(200, allowed.status_code, allowed.text) + self.assertEqual(400, rejected.status_code, rejected.text) + def test_audit_event_persists_trace_details_from_event_context(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-")) try: diff --git a/tests/test_install_config.py b/tests/test_install_config.py index 0890cf4..a351aec 100644 --- a/tests/test_install_config.py +++ b/tests/test_install_config.py @@ -45,7 +45,9 @@ class InstallConfigTests(unittest.TestCase): "CELERY_ENABLED": "true", "REDIS_URL": "redis://redis.example.internal:6379/0", "CORS_ORIGINS": "https://govoplan.example.org", + "GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org", "AUTH_COOKIE_SECURE": "true", + "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false", "FILE_STORAGE_BACKEND": "local", "FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files", }, @@ -75,6 +77,35 @@ class InstallConfigTests(unittest.TestCase): self.assertIn("DATABASE_URL", error_keys) self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys) + def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None: + result = validate_runtime_configuration( + { + "APP_ENV": "production", + "GOVOPLAN_TRUSTED_HOSTS": "*", + "FORWARDED_ALLOW_IPS": "*", + "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false", + }, + profile="self-hosted", + ) + + error_keys = {issue.key for issue in result.errors} + self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys) + self.assertIn("FORWARDED_ALLOW_IPS", error_keys) + + def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None: + result = validate_runtime_configuration( + { + "APP_ENV": "development", + "GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name", + "GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem", + }, + profile="development", + ) + + error_keys = {issue.key for issue in result.errors} + self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys) + self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys) + def test_env_templates_surface_install_profiles(self) -> None: self_hosted = env_template(profile="self-hosted") production_like = env_template(profile="production-like", generate_secrets=True) @@ -82,9 +113,21 @@ class InstallConfigTests(unittest.TestCase): self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) self.assertIn("MASTER_KEY_B64=