"""Origin check for state-changing API requests (CSRF defence on top of SameSite=Strict cookies).""" from __future__ import annotations import json from urllib.parse import urlsplit from starlette.types import ASGIApp, Receive, Scope, Send UNSAFE = frozenset({"POST", "PUT", "PATCH", "DELETE"}) MARKER = "PacketStation" REFUSED = {"detail": "Cross-origin request refused", "code": "bad_origin"} def _origin_of(url: str) -> str | None: try: parts = urlsplit(url) except ValueError: return None if not parts.scheme or not parts.netloc: return None return f"{parts.scheme}://{parts.netloc}".lower() def allowed(scope: Scope) -> bool: """True when the request is safe, not under /api, or carries a same-origin Origin and the marker header.""" if scope.get("method", "GET").upper() not in UNSAFE or not scope.get("path", "").startswith("/api"): return True headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} if headers.get("x-requested-with", "") != MARKER: return False host = headers.get("host", "").strip().lower() if not host: return False expected = f"{scope.get('scheme', 'http')}://{host}" origin = headers.get("origin") if origin and origin != "null": got = _origin_of(origin) elif headers.get("referer"): got = _origin_of(headers["referer"]) else: got = None return got == expected class OriginCheckMiddleware: """Runs after TrustedProxyMiddleware so the scheme is the client's.""" def __init__(self, app: ASGIApp): self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or allowed(scope): await self.app(scope, receive, send) return body = json.dumps(REFUSED).encode("utf-8") 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})