First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
2
app/security/__init__.py
Normal file
2
app/security/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Security middleware and helpers for scholarr."""
|
||||
|
||||
135
app/security/csrf.py
Normal file
135
app/security/csrf.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from secrets import compare_digest, token_urlsafe
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
||||
from starlette.types import Message
|
||||
|
||||
|
||||
CSRF_SESSION_KEY = "csrf_token"
|
||||
CSRF_FORM_FIELD = "csrf_token"
|
||||
CSRF_HEADER_NAME = "x-csrf-token"
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_csrf_token(request: Request) -> str:
|
||||
token = request.session.get(CSRF_SESSION_KEY)
|
||||
if token is None:
|
||||
token = token_urlsafe(32)
|
||||
request.session[CSRF_SESSION_KEY] = token
|
||||
return str(token)
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, exempt_paths: set[str] | None = None) -> None:
|
||||
super().__init__(app)
|
||||
self._exempt_paths = exempt_paths or set()
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
if self._should_skip(request):
|
||||
return await call_next(request)
|
||||
|
||||
session_token = request.session.get(CSRF_SESSION_KEY)
|
||||
if not session_token:
|
||||
logger.warning(
|
||||
"csrf.missing_session_token",
|
||||
extra={
|
||||
"event": "csrf.missing_session_token",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
return self._csrf_error_response(
|
||||
request,
|
||||
code="csrf_missing",
|
||||
message="CSRF token missing.",
|
||||
)
|
||||
|
||||
request_token = request.headers.get(CSRF_HEADER_NAME)
|
||||
if request_token is None and self._is_form_payload(request):
|
||||
body = await request.body()
|
||||
request_token = self._token_from_form_body(request, body)
|
||||
request._receive = self._build_receive(body) # type: ignore[attr-defined]
|
||||
|
||||
if not request_token or not compare_digest(str(session_token), str(request_token)):
|
||||
logger.warning(
|
||||
"csrf.invalid_token",
|
||||
extra={
|
||||
"event": "csrf.invalid_token",
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
},
|
||||
)
|
||||
return self._csrf_error_response(
|
||||
request,
|
||||
code="csrf_invalid",
|
||||
message="CSRF token invalid.",
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _should_skip(self, request: Request) -> bool:
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
path = request.url.path
|
||||
if path.startswith("/static/"):
|
||||
return True
|
||||
return path in self._exempt_paths
|
||||
|
||||
def _is_form_payload(self, request: Request) -> bool:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
return content_type.startswith("application/x-www-form-urlencoded") or (
|
||||
content_type.startswith("multipart/form-data")
|
||||
)
|
||||
|
||||
def _token_from_form_body(self, request: Request, body: bytes) -> str | None:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if not content_type.startswith("application/x-www-form-urlencoded"):
|
||||
return None
|
||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
values = parsed.get(CSRF_FORM_FIELD)
|
||||
if not values:
|
||||
return None
|
||||
return values[0]
|
||||
|
||||
def _build_receive(self, body: bytes):
|
||||
consumed = False
|
||||
|
||||
async def receive() -> Message:
|
||||
nonlocal consumed
|
||||
if consumed:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
consumed = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
return receive
|
||||
|
||||
def _csrf_error_response(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> Response:
|
||||
if request.url.path.startswith("/api/"):
|
||||
request_state = getattr(request, "state", None)
|
||||
request_id = getattr(request_state, "request_id", None) if request_state else None
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": None,
|
||||
},
|
||||
"meta": {
|
||||
"request_id": request_id,
|
||||
},
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
return PlainTextResponse(message, status_code=403)
|
||||
Loading…
Add table
Add a link
Reference in a new issue