mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Add CSP report collector endpoint
This commit is contained in:
@@ -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=<now>` 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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user