"""Refuse state-changing API requests that another web site makes a browser send. The double-submit CSRF token (app/identity/deps.py ``require_csrf``) guards everything a signed-in user can change. The requests made before anyone is signed in — sign-in itself, sign-up, a password reset — have no token yet, so a hostile page could make a visitor's browser sign in to an account of the attacker's choosing (login CSRF): whatever the victim then writes, files or uploads lands in the attacker's account. Browsers say where a request comes from: ``Sec-Fetch-Site`` in every current browser, ``Origin`` on any cross-origin POST in older ones. A request another site started is refused here, before any route runs. Mail apps, calendar apps and servers send neither header and are not affected (DAV lives outside /api anyway). """ from __future__ import annotations import json from urllib.parse import urlsplit from starlette.datastructures import Headers from starlette.types import ASGIApp, Receive, Scope, Send from app.config import get_settings _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _BODY = json.dumps( {"error": {"code": "cross_site", "message": "request refused: sent from another site"}} ).encode() def _hostname(value: str) -> str: """``host[:port]`` -> ``host``, lower-cased (IPv6 brackets kept).""" value = value.strip().lower() if value.startswith("["): return value.split("]", 1)[0] + "]" return value.rsplit(":", 1)[0] if value.count(":") == 1 else value def is_cross_site(headers: Headers) -> bool: """Whether a browser reports this request as started by another site.""" fetch_site = headers.get("sec-fetch-site") if fetch_site is not None: # "same-origin", "same-site" (our own subdomains), "none" (typed or # bookmarked): all fine. Only another site is refused. return fetch_site.strip().lower() == "cross-site" origin = headers.get("origin") if origin is None: return False # not a browser, or a same-origin request from an old one if origin.strip().lower() == "null": return True # sandboxed frame, data: URL, file: page origin_host = (urlsplit(origin.strip()).hostname or "").lower() # Our own domain, whatever a reverse proxy did to the Host header. domain = get_settings().app_domain.lower() if origin_host == domain or origin_host.endswith("." + domain): return False # Otherwise the name the browser used: Host, or the one a reverse proxy saw. ours = {_hostname(headers.get("host", ""))} forwarded = headers.get("x-forwarded-host") if forwarded: ours.add(_hostname(forwarded.split(",")[0])) return origin_host not in ours class CrossSiteGuard: """ASGI middleware: 403 on cross-site POST/PUT/PATCH/DELETE under /api/.""" def __init__(self, app: ASGIApp) -> None: self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if ( scope["type"] == "http" and scope["method"] not in _SAFE_METHODS and scope["path"].startswith("/api/") and is_cross_site(Headers(scope=scope)) ): await send( { "type": "http.response.start", "status": 403, "headers": [ (b"content-type", b"application/json"), (b"content-length", str(len(_BODY)).encode()), ], } ) await send({"type": "http.response.body", "body": _BODY}) return await self.app(scope, receive, send)