All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- Added Content-Security-Policy header to nginx configuration for SPA, enhancing security against XSS attacks. - Introduced middleware in FastAPI to set X-Content-Type-Options header, preventing MIME-sniffing vulnerabilities. - Updated production readiness audit and access layer endpoint audit to reflect security enhancements and ongoing governance practices. - Added tests to verify the presence of security headers in API responses, ensuring compliance with security standards.
127 lines
3.6 KiB
Python
127 lines
3.6 KiB
Python
"""
|
|
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
|
|
|
|
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
|
|
CI: scripts/security_release_checks.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
|
|
|
from auth import require_auth
|
|
from main import app
|
|
|
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_auth_override():
|
|
yield
|
|
app.dependency_overrides.pop(require_auth, None)
|
|
|
|
|
|
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
|
|
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
|
|
|
|
def auth_user() -> dict:
|
|
return {"profile_id": 901, "role": "trainer"}
|
|
|
|
app.dependency_overrides[require_auth] = auth_user
|
|
with patch("routers.profiles.profile_document") as pd_mock:
|
|
pd_mock.return_value = {"id": 901, "name": "T"}
|
|
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
|
|
assert r.status_code == 200
|
|
pd_mock.assert_called_once_with("901")
|
|
|
|
|
|
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
|
|
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
|
|
return subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
cwd=str(BACKEND_ROOT),
|
|
env=merged,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_env, expect_openapi",
|
|
[
|
|
({"ENVIRONMENT": "development"}, True),
|
|
({"ENVIRONMENT": "production"}, False),
|
|
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
|
|
],
|
|
)
|
|
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
|
|
snippet = f"""
|
|
from main import app
|
|
assert (app.openapi_url is not None) == {expect_openapi}
|
|
assert (app.docs_url is not None) == {expect_openapi}
|
|
"""
|
|
proc = _run_fresh_import_int(snippet, extra_env)
|
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
|
|
|
|
|
def test_health_ready_minimal_body_in_production() -> None:
|
|
snippet = """
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
c = TestClient(app)
|
|
r = c.get("/api/health/ready")
|
|
r.raise_for_status()
|
|
j = r.json()
|
|
assert "tables" not in j
|
|
assert "detail" not in j
|
|
assert "schema_migrations_count" not in j
|
|
assert "status" in j and "database" in j and "schema_complete" in j
|
|
"""
|
|
proc = _run_fresh_import_int(
|
|
snippet,
|
|
{"ENVIRONMENT": "production"},
|
|
)
|
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
|
|
|
|
|
def test_health_ready_full_detail_when_flag_in_production() -> None:
|
|
snippet = """
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
c = TestClient(app)
|
|
r = c.get("/api/health/ready")
|
|
r.raise_for_status()
|
|
j = r.json()
|
|
assert "tables" in j
|
|
assert "schema_migrations_count" in j
|
|
"""
|
|
proc = _run_fresh_import_int(
|
|
snippet,
|
|
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
|
|
)
|
|
assert proc.returncode == 0, proc.stderr + proc.stdout
|
|
|
|
|
|
def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> None:
|
|
"""Globales Middleware: keine MIME-Sniffing-Heuristik für API/Health."""
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
assert r.headers.get("x-content-type-options") == "nosniff"
|
|
r2 = client.get("/api/version")
|
|
assert r2.status_code == 200
|
|
assert r2.headers.get("x-content-type-options") == "nosniff"
|