65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
"""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})
|