109 lines
4.3 KiB
Python
109 lines
4.3 KiB
Python
"""Regression guards for the print/PDF Content-Security-Policy.
|
|||
|
|
|
||
|
|
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
|
||
|
|
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
|
||
|
|
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
|
||
|
|
which silently blocks the frame and breaks printing for every user (regardless of
|
||
|
|
role). These tests pin the policy on both layers that emit it:
|
||
|
|
|
||
|
|
* the FastAPI security middleware (covers every API response), and
|
||
|
|
* the production nginx config (the source of the *document* CSP that actually
|
||
|
|
governs ``frame-src`` in the browser).
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
|
||
|
|
from app.core.config import settings
|
||
|
|
from app.core.security import issue_token
|
||
|
|
from app.main import app
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
|
||
|
|
|
||
|
|
# Directives the print flow depends on. blob: must be framable, and that must not
|
||
|
|
# come at the cost of dropping the same-origin baseline.
|
||
|
|
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_csp(header: str) -> dict[str, set[str]]:
|
||
|
|
"""Parse a CSP header string into ``{directive: {sources}}``."""
|
||
|
|
directives: dict[str, set[str]] = {}
|
||
|
|
for part in header.split(";"):
|
||
|
|
tokens = part.split()
|
||
|
|
if not tokens:
|
||
|
|
continue
|
||
|
|
directives[tokens[0].lower()] = set(tokens[1:])
|
||
|
|
return directives
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_blob_framing(header: str) -> None:
|
||
|
|
csp = _parse_csp(header)
|
||
|
|
# frame-src must exist and allow self + blob (no falling back to default-src).
|
||
|
|
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
|
||
|
|
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
|
||
|
|
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
|
||
|
|
)
|
||
|
|
# child-src is the Safari fallback for frame-src; keep it aligned.
|
||
|
|
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
|
||
|
|
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
|
||
|
|
# We only widened framing: the same-origin default must stay intact.
|
||
|
|
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
|
||
|
|
|
||
|
|
|
||
|
|
# --- Backend middleware policy ------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def client() -> TestClient:
|
||
|
|
with TestClient(app) as test_client:
|
||
|
|
yield test_client
|
||
|
|
|
||
|
|
|
||
|
|
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
|
||
|
|
response = client.get("/health")
|
||
|
|
assert "content-security-policy" in response.headers
|
||
|
|
_assert_blob_framing(response.headers["content-security-policy"])
|
||
|
|
|
||
|
|
|
||
|
|
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
|
||
|
|
"""The policy is identical for anonymous, authenticated, and rejected (401)
|
||
|
|
requests, so printing can never depend on who is signed in."""
|
||
|
|
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||
|
|
|
||
|
|
responses = [
|
||
|
|
client.get("/health"), # anonymous
|
||
|
|
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
|
||
|
|
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
|
||
|
|
]
|
||
|
|
|
||
|
|
policies = set()
|
||
|
|
for response in responses:
|
||
|
|
header = response.headers.get("content-security-policy")
|
||
|
|
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
|
||
|
|
_assert_blob_framing(header)
|
||
|
|
policies.add(header)
|
||
|
|
|
||
|
|
assert len(policies) == 1, "CSP must not vary by authentication state"
|
||
|
|
|
||
|
|
|
||
|
|
# --- Production document policy (nginx) ---------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_nginx_csp_allows_blob_frames() -> None:
|
||
|
|
"""Every CSP the production nginx emits must allow blob framing. This guards
|
||
|
|
the *document* policy, which is what the browser enforces for the print iframe."""
|
||
|
|
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
|
||
|
|
|
||
|
|
for config_path in NGINX_CONFIGS:
|
||
|
|
assert config_path.exists(), f"missing nginx config: {config_path}"
|
||
|
|
text = config_path.read_text(encoding="utf-8")
|
||
|
|
policies = csp_line.findall(text)
|
||
|
|
assert policies, f"no Content-Security-Policy header found in {config_path}"
|
||
|
|
for policy in policies:
|
||
|
|
_assert_blob_framing(policy)
|