"""Forged-request (CSRF) protection for the browser side of Steward. A page on another site can make a logged-in browser POST to Steward, and the browser sends Steward's session cookie with it. Every state-changing request from a browser therefore has to carry a token that only Steward's own pages know: a random value kept in the session, written into each form by `csrf_field()` and sent by HTMX and fetch() as the `X-CSRF-Token` header (set once on in base.html). One check in a before_request covers every route, so a new route cannot forget it. Callers that authenticate with a bearer token instead of the session cookie (the host agent's ingest; /mcp never reaches Quart) are exempt: a forged browser request cannot carry their token. """ from __future__ import annotations import hmac import secrets from markupsafe import Markup, escape from quart import Response, request, session SESSION_KEY = "_csrf" FORM_FIELD = "csrf_token" HEADER = "X-CSRF-Token" UNSAFE_METHODS = ("POST", "PUT", "PATCH", "DELETE") # Bearer-authenticated endpoints: no session cookie, nothing to forge. EXEMPT_PATHS = frozenset({"/plugins/host_agent/ingest"}) def csrf_token() -> str: """This session's token, created on first use.""" token = session.get(SESSION_KEY) if not token: token = session[SESSION_KEY] = secrets.token_urlsafe(32) return token def csrf_field() -> Markup: """The hidden input every POST form carries.""" return Markup(f'') def rotate() -> None: """Drop the token, so a new one is issued — at login, where the session changes hands.""" session.pop(SESSION_KEY, None) # Forms that send a file past the app's default body limit (16 MiB). This # check reads the form first, so the limit is raised here, before it does: # a backup file (settings only is small; an older one carries the audit log). UPLOAD_LIMITS: dict[str, int] = { "/settings/backups/check/file/": 512 * 1024 * 1024, "/settings/backups/restore/file/": 512 * 1024 * 1024, "/setup/restore": 512 * 1024 * 1024, } async def check_request() -> Response | None: """before_request hook: refuse an unsafe request without the session's token.""" if request.method not in UNSAFE_METHODS or request.path in EXEMPT_PATHS: return None if request.path in UPLOAD_LIMITS: request.max_content_length = UPLOAD_LIMITS[request.path] expected = session.get(SESSION_KEY) sent = request.headers.get(HEADER, "") if not sent: form = await request.form sent = form.get(FORM_FIELD, "") if expected and sent and hmac.compare_digest(expected, sent): return None return Response( "This form has expired or did not come from Steward. Reload the page and try again.", status=400, content_type="text/plain; charset=utf-8")