Enforce deployment security boundaries
This commit is contained in:
@@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`.
|
|||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
| `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_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_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||||
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
| `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_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. |
|
| `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
|
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||||
module settings. Do not hardcode them in core; configuration packages should ask
|
module settings. Do not hardcode them in core; configuration packages should ask
|
||||||
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
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
|
### Module Catalogs, Licenses, And Trust Roots
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ set +a
|
|||||||
|
|
||||||
The command reports all known blockers at once. Production-like/self-hosted
|
The command reports all known blockers at once. Production-like/self-hosted
|
||||||
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
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
|
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||||
catalog source is configured.
|
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
|
## Production-Like Dev Stack
|
||||||
|
|
||||||
Use the local production-like wrapper for repeatable rehearsal:
|
Use the local production-like wrapper for repeatable rehearsal:
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
audit_event="files.connector_profile.updated",
|
audit_event="files.connector_profile.updated",
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
rollback_history_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(
|
ConfigurationFieldSafety(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -289,6 +289,174 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
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(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||||
label="Module package catalog trusted keys",
|
label="Module package catalog trusted keys",
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
|
|||||||
_validate_async_and_auth_settings(env, runtime, collector)
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
_validate_cors_settings(env, runtime, collector)
|
_validate_cors_settings(env, runtime, collector)
|
||||||
_validate_file_storage_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)
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
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.")
|
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:
|
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.")
|
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:
|
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.")
|
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:
|
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"))
|
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:
|
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
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
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
|
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_SECURE=true
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
AUTH_COOKIE_DOMAIN=
|
AUTH_COOKIE_DOMAIN=
|
||||||
@@ -321,8 +406,24 @@ CELERY_ENABLED=true
|
|||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
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
|
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
|
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
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, Request
|
from fastapi import APIRouter, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.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.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
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.conditional_requests import conditional_json_get_middleware
|
||||||
|
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||||
|
|
||||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||||
logger = logging.getLogger("govoplan.request")
|
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:
|
def _slow_request_threshold_ms() -> float:
|
||||||
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
|
|||||||
return 500.0
|
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(
|
def create_govoplan_app(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -37,9 +87,27 @@ def create_govoplan_app(
|
|||||||
cors_origins: Iterable[str] = (),
|
cors_origins: Iterable[str] = (),
|
||||||
health_payload: dict[str, Any] | None = None,
|
health_payload: dict[str, Any] | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
|
_validate_production_startup()
|
||||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
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
|
app.state.govoplan_registry = registry
|
||||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
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")
|
@app.middleware("http")
|
||||||
async def request_correlation_context(request: Request, call_next):
|
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_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
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")
|
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")
|
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||||
|
|||||||
@@ -1811,12 +1811,21 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
tenant_id = str(login["tenant"]["id"])
|
tenant_id = str(login["tenant"]["id"])
|
||||||
env_keys = [
|
env_keys = [
|
||||||
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
|
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
|
||||||
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
|
||||||
"GOVOPLAN_TEST_SMB_PASSWORD",
|
"GOVOPLAN_TEST_SMB_PASSWORD",
|
||||||
]
|
]
|
||||||
previous_env = {key: os.environ.get(key) for key in env_keys}
|
previous_env = {key: os.environ.get(key) for key in env_keys}
|
||||||
try:
|
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_SEAFILE_PASSWORD"] = "super-secret-seafile"
|
||||||
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
|
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
|
||||||
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
|
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.assertFalse(deleted_space.json()["is_active"])
|
||||||
self.assertIsNotNone(deleted_space.json()["deleted_at"])
|
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):
|
def seafile_request(method: str, url: str, **kwargs):
|
||||||
request = httpx.Request(method, url)
|
|
||||||
params = kwargs.get("params") or {}
|
params = kwargs.get("params") or {}
|
||||||
if url == "http://127.0.0.1:9082/api2/auth-token/":
|
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/":
|
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
|
||||||
self.assertEqual(params.get("p"), "/reports/summary.txt")
|
self.assertEqual(params.get("p"), "/reports/summary.txt")
|
||||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
|
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
|
||||||
return httpx.Response(
|
return ConnectorHttpResponse(
|
||||||
200,
|
200,
|
||||||
json={
|
{},
|
||||||
"id": "file-1",
|
json.dumps(
|
||||||
"name": "summary.txt",
|
{
|
||||||
"size": 14,
|
"id": "file-1",
|
||||||
"type": "file",
|
"name": "summary.txt",
|
||||||
"mtime": 1783425600,
|
"size": 14,
|
||||||
"permission": "r",
|
"type": "file",
|
||||||
},
|
"mtime": 1783425600,
|
||||||
request=request,
|
"permission": "r",
|
||||||
|
}
|
||||||
|
).encode(),
|
||||||
)
|
)
|
||||||
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
|
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
|
||||||
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
|
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":
|
if url == "https://download.example.invalid/summary.txt":
|
||||||
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request)
|
return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
|
||||||
return httpx.Response(404, request=request)
|
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(
|
imported = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/seafile-dev/import",
|
"/api/v1/files/connectors/profiles/seafile-dev/import",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2259,18 +2269,16 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
|
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
|
||||||
|
|
||||||
def webdav_request(method: str, url: str, **kwargs):
|
def webdav_request(method: str, url: str, **kwargs):
|
||||||
request = httpx.Request(method, url)
|
|
||||||
self.assertEqual(method, "GET")
|
self.assertEqual(method, "GET")
|
||||||
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
|
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
|
||||||
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
|
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
|
||||||
return httpx.Response(
|
return ConnectorHttpResponse(
|
||||||
200,
|
200,
|
||||||
content=webdav_payload["content"],
|
{"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
||||||
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
|
webdav_payload["content"],
|
||||||
request=request,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
nextcloud_import = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/import",
|
"/api/v1/files/connectors/profiles/user-nextcloud/import",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2292,7 +2300,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
webdav_payload["content"] = b"nextcloud notice updated"
|
webdav_payload["content"] = b"nextcloud notice updated"
|
||||||
webdav_payload["etag"] = "\"nextcloud-etag-3\""
|
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(
|
nextcloud_sync = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -2314,7 +2322,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
|
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
|
||||||
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
|
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(
|
nextcloud_sync_unchanged = self.client.post(
|
||||||
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
"/api/v1/files/connectors/profiles/user-nextcloud/sync",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||||
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
|
|||||||
cors_origins=("*",),
|
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:
|
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
"CELERY_ENABLED": "true",
|
"CELERY_ENABLED": "true",
|
||||||
"REDIS_URL": "redis://redis.example.internal:6379/0",
|
"REDIS_URL": "redis://redis.example.internal:6379/0",
|
||||||
"CORS_ORIGINS": "https://govoplan.example.org",
|
"CORS_ORIGINS": "https://govoplan.example.org",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
|
||||||
"AUTH_COOKIE_SECURE": "true",
|
"AUTH_COOKIE_SECURE": "true",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
|
||||||
"FILE_STORAGE_BACKEND": "local",
|
"FILE_STORAGE_BACKEND": "local",
|
||||||
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
|
||||||
},
|
},
|
||||||
@@ -75,6 +77,35 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("DATABASE_URL", error_keys)
|
self.assertIn("DATABASE_URL", error_keys)
|
||||||
self.assertIn("DEV_BOOTSTRAP_ENABLED", 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:
|
def test_env_templates_surface_install_profiles(self) -> None:
|
||||||
self_hosted = env_template(profile="self-hosted")
|
self_hosted = env_template(profile="self-hosted")
|
||||||
production_like = env_template(profile="production-like", generate_secrets=True)
|
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("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
|
||||||
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
||||||
|
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
|
||||||
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
|
||||||
|
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user