Enforce deployment security boundaries

This commit is contained in:
2026-07-21 12:10:05 +02:00
parent 825791e9b0
commit 230ecf42b0
10 changed files with 629 additions and 28 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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=<generate", 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.assertNotIn("MASTER_KEY_B64=<generate", 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__":