diff --git a/src/rights_filter/server/http_app.py b/src/rights_filter/server/http_app.py index 26993b1..9044339 100644 --- a/src/rights_filter/server/http_app.py +++ b/src/rights_filter/server/http_app.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import hashlib import hmac import json import mimetypes @@ -35,6 +36,17 @@ class _PayloadTooLarge(Exception): """Raised when a request body exceeds _MAX_BODY_BYTES.""" +# Content types treated as the trusted operator app shell, which receives the +# strict script-src CSP + framing/nosniff headers. Both javascript spellings are +# listed because mimetypes maps .js to text/javascript on Python 3.12+. +_APP_SHELL_CONTENT_TYPES = { + "text/html", + "application/javascript", + "text/javascript", + "text/css", +} + + # Media routes loaded via /, which cannot send an Authorization # header — these rely on the session cookie instead. _MEDIA_PREFIXES = ( @@ -51,6 +63,19 @@ _PROTECTED_PREFIXES = ("/api/", *_MEDIA_PREFIXES) # load media without ever carrying the token in a URL. _SESSION_COOKIE = "copyrighter_session" +# Methods that change state — cookie-authenticated ones get an Origin/Referer +# CSRF check (a header-authenticated request cannot be forged cross-site because +# browsers never auto-attach an Authorization header). +_STATE_CHANGING_METHODS = {"POST", "PATCH", "PUT", "DELETE"} + + +def _session_tag(token: str) -> str: + # Deterministic HMAC of the token so the session cookie carries a derived + # tag, not the raw COPYRIGHTER_AUTH_TOKEN. A leaked cookie can still ride the + # session but no longer reveals the token itself (which is also the .env + # secret / Authorization bearer). No server-side session store needed. + return hmac.new(token.encode("utf-8"), b"copyrighter-session-v1", hashlib.sha256).hexdigest() + def build_server( host: str, @@ -64,6 +89,9 @@ def build_server( class CopyrighterHandler(BaseHTTPRequestHandler): server_version = "CopyrighterHTTP/0.1" + # Bound how long a slow/idle client can hold a worker thread's socket, so + # a handful of slowloris-style connections cannot exhaust the thread pool. + timeout = 30 def do_GET(self) -> None: path = _path(self.path) @@ -186,6 +214,9 @@ def build_server( elif path.startswith("/api/collections/candidates/") and path.endswith("/promote"): candidate_id = unquote(path.split("/")[4]) self._json(store.promote_collection_candidate(candidate_id, body)) + elif path.startswith("/api/collections/candidates/") and path.endswith("/dismiss"): + candidate_id = unquote(path.split("/")[4]) + self._json(store.dismiss_collection_candidate(candidate_id)) elif path.startswith("/api/knowledge/") and path.endswith("/promote-watchlist"): entry_id = unquote(path.split("/")[3]) self._json(store.promote_watchlist_entry(entry_id)) @@ -268,13 +299,40 @@ def build_server( jar = SimpleCookie() jar.load(cookie_header) morsel = jar.get(_SESSION_COOKIE) - if morsel is not None and hmac.compare_digest(morsel.value, auth_token): + if morsel is not None and hmac.compare_digest(morsel.value, _session_tag(auth_token)): + # Cookie-authenticated state changes are CSRF-forgeable, so + # require a same-origin Origin/Referer for them. + if self.command in _STATE_CHANGING_METHODS and not self._origin_ok(): + self._json({"error": "invalid origin"}, HTTPStatus.FORBIDDEN) + return False return True self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED) return False + def _origin_ok(self) -> bool: + # Accept only requests whose Origin (preferred) or Referer resolves to + # the same host we were reached on. If neither header is present we + # cannot verify, so allow (older/non-browser clients) rather than break + # the app — the classic cross-site POST always carries an Origin. + host = self.headers.get("Host", "") + origin = self.headers.get("Origin", "") + if origin: + return urlparse(origin).netloc == host + referer = self.headers.get("Referer", "") + if referer: + return urlparse(referer).netloc == host + return True + def _body(self) -> dict[str, object]: - length = int(self.headers.get("Content-Length", "0") or "0") + # Parse defensively: a non-integer header must not leak Python's parse + # error, and a negative length must not slip past the size cap into + # rfile.read(-1) (an unbounded read-to-EOF that exhausts memory). + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + raise ValueError("invalid Content-Length") + if length < 0: + raise ValueError("invalid Content-Length") if length > _MAX_BODY_BYTES: raise _PayloadTooLarge() if not length: @@ -286,9 +344,10 @@ def build_server( def _maybe_set_session_cookie(self) -> None: if getattr(self, "_issue_session_cookie", False): + # Store the derived tag, never the raw token. self.send_header( "Set-Cookie", - f"{_SESSION_COOKIE}={auth_token}; HttpOnly; SameSite=Strict; Path=/", + f"{_SESSION_COOKIE}={_session_tag(auth_token)}; HttpOnly; SameSite=Strict; Path=/", ) def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None: @@ -340,6 +399,21 @@ def build_server( # don't leak the URL via Referer. self.send_header("Cache-Control", "no-store") self.send_header("Referrer-Policy", "no-referrer") + elif content_type.split(";", 1)[0] in _APP_SHELL_CONTENT_TYPES: + # Trusted app shell (HTML/JS/CSS). The GUI loads only same-origin + # scripts (no inline