Files

65 lines
2.2 KiB
Python
Raw Permalink Normal View History

"""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})