diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d737b24..063db4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `POST /api/csp-report` now collects browser CSP report-only violations and the report-only policy advertises both `report-uri` and `report-to` sinks, making CSP dry-runs visible outside individual browser devtools consoles (closes #2095). - **PR #2150** by @Jordan-SkyLF — "Refresh usage" button on the Provider quota card in Settings → Providers. Calls `/api/provider/quota?refresh=1&ts=` with `cache: 'no-store'` to bypass browser, service worker, and reverse-proxy caches that may have stamped a previous quota response, then re-renders just the quota card from the fresh response and shows a `Last checked ...` timestamp. Disabled `Refreshing…` state during the in-flight request; success toast on completion or failure toast if the refresh fails. Note: the `refresh=1` query param is a no-op at the server today (`get_provider_quota()` has no in-process cache layer), so the win is strictly browser-side cache-bust + the `no-store` fetch option. A future maintainer follow-up may add server-side TTL caching of OAuth account-limit fetches, at which point the `refresh=1` param becomes load-bearing on both sides. ## [v0.51.51] — 2026-05-12 — Release AA (stage-344 — 16-PR contributor batch — i18n + insights bucketing/mobile + manual-compress async + workspace recovery + iOS PWA scroll + Cloudflare login health + fr locale) diff --git a/api/routes.py b/api/routes.py index 65f12285..801e4907 100644 --- a/api/routes.py +++ b/api/routes.py @@ -63,6 +63,12 @@ _MESSAGING_SESSION_METADATA_CACHE: dict[str, object] = { } _MESSAGING_SESSION_METADATA_LOCK = threading.Lock() _STALE_MESSAGING_END_REASONS = {"session_reset", "session_switch"} +_CSP_REPORT_LOGGER = logging.getLogger("csp_report") +_CSP_REPORT_RATE_LIMIT: dict[str, list[float]] = {} +_CSP_REPORT_RATE_LIMIT_LOCK = threading.Lock() +_CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS = 60 +_CSP_REPORT_RATE_LIMIT_MAX = 100 +_CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── @@ -1057,6 +1063,69 @@ def _check_csrf(handler) -> bool: return False +def _client_ip_for_rate_limit(handler) -> str: + try: + address = getattr(handler, "client_address", None) + if address: + return str(address[0]) + except Exception: + pass + return "unknown" + + +def _csp_report_rate_limited(handler, *, now: float | None = None) -> bool: + now = time.time() if now is None else now + key = _client_ip_for_rate_limit(handler) + cutoff = now - _CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS + with _CSP_REPORT_RATE_LIMIT_LOCK: + timestamps = [ts for ts in _CSP_REPORT_RATE_LIMIT.get(key, []) if ts >= cutoff] + if len(timestamps) >= _CSP_REPORT_RATE_LIMIT_MAX: + _CSP_REPORT_RATE_LIMIT[key] = timestamps + return True + timestamps.append(now) + _CSP_REPORT_RATE_LIMIT[key] = timestamps + return False + + +def _send_no_content(handler, status: int = 204) -> bool: + handler.send_response(status) + handler.send_header("Content-Length", "0") + handler.end_headers() + return True + + +def _read_csp_report_payload(handler): + try: + length = int(handler.headers.get("Content-Length", 0)) + except Exception: + length = 0 + if length > _CSP_REPORT_MAX_BODY_BYTES: + try: + handler.rfile.read(_CSP_REPORT_MAX_BODY_BYTES) + except Exception: + pass + return {"discarded": "body_too_large", "bytes": length} + raw = handler.rfile.read(length) if length else b"{}" + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {"invalid": True, "bytes": len(raw)} + + +def _handle_csp_report(handler) -> bool: + """Collect browser CSP report-only violations without requiring auth.""" + if _csp_report_rate_limited(handler): + _CSP_REPORT_LOGGER.warning( + "Dropped CSP report from %s: rate limit exceeded", + _client_ip_for_rate_limit(handler), + ) + return _send_no_content(handler) + + payload = _read_csp_report_payload(handler) + _CSP_REPORT_LOGGER.info("CSP report from %s: %s", _client_ip_for_rate_limit(handler), payload) + return _send_no_content(handler) + + def _normalize_provider_id(value: str | None) -> str: raw = str(value or "").strip().lower() if not raw: @@ -3872,6 +3941,14 @@ def handle_get(handler, parsed) -> bool: def handle_post(handler, parsed) -> bool: """Handle all POST routes. Returns True if handled, False for 404.""" diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger) + if parsed.path == "/api/csp-report": + if diag: + diag.stage("csp_report") + try: + return _handle_csp_report(handler) + finally: + if diag: + diag.finish() # CSRF: reject cross-origin browser requests if diag: diag.stage("csrf") diff --git a/server.py b/server.py index 9768422f..25aa110e 100644 --- a/server.py +++ b/server.py @@ -210,8 +210,10 @@ class Handler(BaseHTTPRequestHandler): "img-src 'self' data: blob:; " "font-src 'self' data:; " "media-src 'self' data: blob:; " - "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" + "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; " + "report-uri /api/csp-report; report-to csp-endpoint" ) + _CSP_REPORT_TO = '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/api/csp-report"}]}' @classmethod def csp_report_only_policy(cls) -> str: @@ -219,6 +221,7 @@ class Handler(BaseHTTPRequestHandler): def end_headers(self) -> None: self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy()) + self.send_header("Report-To", self._CSP_REPORT_TO) super().end_headers() def log_message(self, fmt, *args): pass # suppress default Apache-style log @@ -262,7 +265,7 @@ class Handler(BaseHTTPRequestHandler): set_request_profile(cookie_profile) try: parsed = urlparse(self.path) - if not check_auth(self, parsed): return + if parsed.path != "/api/csp-report" and not check_auth(self, parsed): return result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/tests/test_issue1909_csp_report_only.py b/tests/test_issue1909_csp_report_only.py index 56d8f282..ca251450 100644 --- a/tests/test_issue1909_csp_report_only.py +++ b/tests/test_issue1909_csp_report_only.py @@ -1,7 +1,11 @@ """Regression tests for #1909 CSP report-only security header.""" +import io +import json from http.server import BaseHTTPRequestHandler +from types import SimpleNamespace +import api.routes as routes from server import Handler @@ -15,12 +19,20 @@ def test_handler_adds_content_security_policy_report_only(monkeypatch): headers = dict(sent_headers) assert "Content-Security-Policy-Report-Only" in headers + assert "Report-To" in headers assert "Content-Security-Policy" not in headers policy = headers["Content-Security-Policy-Report-Only"] assert "default-src 'self'" in policy assert "object-src 'none'" in policy assert "frame-ancestors 'self'" in policy assert "base-uri 'self'" in policy + assert "report-uri /api/csp-report" in policy + assert "report-to csp-endpoint" in policy + assert json.loads(headers["Report-To"]) == { + "group": "csp-endpoint", + "max_age": 10886400, + "endpoints": [{"url": "/api/csp-report"}], + } def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui(): @@ -33,3 +45,108 @@ def test_csp_report_only_keeps_legacy_inline_allowances_for_current_ui(): assert "'unsafe-eval'" not in policy assert "img-src 'self' data: blob:" in policy assert "connect-src 'self'" in policy + + +class _FakeHandler: + def __init__(self, body=b"{}", headers=None, client_ip="203.0.113.10"): + self.headers = { + "Content-Length": str(len(body)), + "Content-Type": "application/csp-report", + **(headers or {}), + } + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.client_address = (client_ip, 54321) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + +def test_csp_report_endpoint_accepts_report_uri_payload_without_csrf(monkeypatch, caplog): + routes._CSP_REPORT_RATE_LIMIT.clear() + payload = { + "csp-report": { + "document-uri": "http://127.0.0.1:8787/", + "violated-directive": "script-src-elem", + "blocked-uri": "inline", + } + } + handler = _FakeHandler(json.dumps(payload).encode("utf-8")) + + def fail_if_called(_handler): + raise AssertionError("CSP reports must bypass the normal CSRF gate") + + monkeypatch.setattr(routes, "_check_csrf", fail_if_called) + + with caplog.at_level("INFO", logger="csp_report"): + assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True + + assert handler.status == 204 + assert handler.sent_headers["Content-Length"] == "0" + assert "violated-directive" in caplog.text + + +def test_csp_report_endpoint_accepts_report_to_array_payload(): + routes._CSP_REPORT_RATE_LIMIT.clear() + payload = [ + { + "type": "csp-violation", + "url": "http://127.0.0.1:8787/", + "body": {"blockedURL": "https://example.invalid/script.js"}, + } + ] + handler = _FakeHandler( + json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/reports+json"}, + ) + + assert routes.handle_post(handler, SimpleNamespace(path="/api/csp-report")) is True + + assert handler.status == 204 + assert handler.sent_headers["Content-Length"] == "0" + + +def test_csp_report_endpoint_rate_limits_by_client_ip(monkeypatch): + routes._CSP_REPORT_RATE_LIMIT.clear() + monkeypatch.setattr(routes, "_CSP_REPORT_RATE_LIMIT_MAX", 1) + first = _FakeHandler(b"{}", client_ip="203.0.113.11") + second = _FakeHandler(b"{}", client_ip="203.0.113.11") + + assert routes.handle_post(first, SimpleNamespace(path="/api/csp-report")) is True + assert routes.handle_post(second, SimpleNamespace(path="/api/csp-report")) is True + + assert first.status == 204 + assert second.status == 204 + assert second.rfile.tell() == 0 + + +def test_server_bypasses_auth_for_csp_report(monkeypatch): + handler = Handler.__new__(Handler) + handler.path = "/api/csp-report" + handler.command = "POST" + handler._req_t0 = 0 + + def fail_auth(_handler, _parsed): + raise AssertionError("CSP report collector must not require auth") + + called = {} + + def fake_route(_handler, parsed): + called["path"] = parsed.path + return True + + monkeypatch.setattr("server.check_auth", fail_auth) + monkeypatch.setattr("server.clear_request_profile", lambda: None) + monkeypatch.setattr("server.get_profile_cookie", lambda _handler: None) + + Handler._handle_write(handler, fake_route) + + assert called == {"path": "/api/csp-report"}