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