Enforce deployment security boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user