""" R6 headers / CSP / CORS behavioral tests. Security headers are emitted unconditionally (independent of security.enabled). The strict CSP applies to the API / error surface; the still-inline dashboard / playground keep the baseline headers but not the strict CSP until they are externalized (CSP-compat refactor - tracked separately). CORS is deny-by-default. """ import pytest pytestmark = pytest.mark.posture class TestBaselineHeaders: def test_present_on_api_response(self, stock_client): r = stock_client.get("/health") h = {k.lower(): v for k, v in r.headers.items()} assert h.get("x-content-type-options") == "nosniff" assert h.get("x-frame-options", "").upper() == "DENY" assert h.get("referrer-policy") == "no-referrer" assert h.get("cross-origin-opener-policy") == "same-origin" def test_present_even_with_security_disabled(self, stock_client, server_module, monkeypatch): # Headers must be emitted independent of the security.enabled flag (the # old middleware only added them when enabled). Force it off and verify. monkeypatch.setitem(server_module.config["security"], "enabled", False) r = stock_client.get("/health") h = {k.lower() for k in r.headers} assert "content-security-policy" in h assert "x-content-type-options" in h def test_baseline_on_dashboard_mount(self, stock_client, server_module): from auth import create_access_token h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"} r = stock_client.get("/dashboard/", headers=h) hh = {k.lower(): v for k, v in r.headers.items()} assert hh.get("x-content-type-options") == "nosniff" assert hh.get("x-frame-options", "").upper() == "DENY" class TestStrictCsp: def test_api_csp_is_strict(self, stock_client): csp = stock_client.get("/health").headers.get("content-security-policy", "") assert "script-src 'self'" in csp assert "frame-ancestors 'none'" in csp assert "default-src 'none'" in csp assert "unsafe-inline" not in csp def test_ui_mount_not_given_strict_csp(self, stock_client, server_module): # The inline-script UI must not get the strict CSP yet (it would break). from auth import create_access_token h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"} csp = stock_client.get("/playground/", headers=h).headers.get("content-security-policy") assert csp is None class TestCorsDenyByDefault: def test_no_cors_allow_origin_by_default(self, stock_client): r = stock_client.get("/health", headers={"Origin": "https://evil.example"}) assert "access-control-allow-origin" not in {k.lower() for k in r.headers} class TestErrorSanitization: def test_5xx_is_generic_with_correlation_id(self, stock_client, server_module): from auth import create_access_token h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"} # /monitor/stats/reset has no monitor singleton without a lifespan -> the # handler raises -> our central handler returns a sanitized 500. r = stock_client.post("/monitor/stats/reset", headers=h) assert r.status_code >= 500 body = r.json() assert body.get("error") == "Internal server error" assert "correlation_id" in body # No internal detail (exception text / paths) leaked. assert "Traceback" not in r.text and "/home/" not in r.text def test_4xx_detail_preserved(self, stock_client): # /token is public; with no api_token configured it raises a 4xx, which # the central handler passes through with its developer-facing detail. r = stock_client.post("/token", json={"email": "x@y.com"}) assert 400 <= r.status_code < 500 assert "detail" in r.json() and r.json()["detail"] class TestWebhookHeaderSanitization: def test_crlf_in_value_rejected(self): from webhook import sanitize_webhook_headers with pytest.raises(ValueError): sanitize_webhook_headers({"X-Foo": "bar\r\nInjected: 1"}) def test_hop_by_hop_denied(self): from webhook import sanitize_webhook_headers for bad in ("Host", "Content-Length", "Transfer-Encoding", "Authorization"): with pytest.raises(ValueError): sanitize_webhook_headers({bad: "x"}) def test_bad_name_rejected(self): from webhook import sanitize_webhook_headers with pytest.raises(ValueError): sanitize_webhook_headers({"X Foo": "bar"}) def test_good_headers_pass(self): from webhook import sanitize_webhook_headers assert sanitize_webhook_headers({"X-Trace-Id": "abc123"}) == {"X-Trace-Id": "abc123"} def test_schema_validator_rejects_early(self): from schemas import WebhookConfig import pydantic with pytest.raises(pydantic.ValidationError): WebhookConfig(webhook_url="https://example.com/cb", webhook_headers={"Host": "evil"}) class TestCRLFSafeLogging: def test_crlf_stripped_from_log_message(self): import logging from utils import CRLFSafeFilter rec = logging.LogRecord("t", logging.INFO, __file__, 1, "url=http://x/\r\nINJECTED admin login", None, None) CRLFSafeFilter().filter(rec) msg = rec.getMessage() assert "\r" not in msg and "\n" not in msg assert "INJECTED" in msg # content kept, just de-fanged