feat: Add CSRF protection middleware to the application.
parent
6bab027427
commit
a728d39fce
@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import Request, HTTPException
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CSRFProtectMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
CSRF Protection Middleware using Double Submit Cookie pattern.
|
||||||
|
|
||||||
|
This middleware:
|
||||||
|
1. Ensures a 'csrf_token' cookie exists for every request.
|
||||||
|
2. For mutating requests (POST, PUT, PATCH, DELETE):
|
||||||
|
- Requires an 'X-CSRF-Token' header.
|
||||||
|
- Validates that the header value matches the 'csrf_token' cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app,
|
||||||
|
cookie_name: str = "csrf_token",
|
||||||
|
header_name: str = "X-CSRF-Token",
|
||||||
|
excluded_paths: Optional[List[str]] = None,
|
||||||
|
safe_methods: List[str] = ("GET", "HEAD", "OPTIONS", "TRACE")
|
||||||
|
):
|
||||||
|
super().__init__(app)
|
||||||
|
self.cookie_name = cookie_name
|
||||||
|
self.header_name = header_name
|
||||||
|
self.excluded_paths = excluded_paths or []
|
||||||
|
self.safe_methods = safe_methods
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# 1. Skip validation for "safe" methods (GET, HEAD, etc.) or excluded paths
|
||||||
|
is_safe_method = request.method in self.safe_methods
|
||||||
|
is_excluded = any(request.url.path.startswith(path) for path in self.excluded_paths)
|
||||||
|
|
||||||
|
if not is_safe_method and not is_excluded:
|
||||||
|
# 2. Extract token from cookie and header
|
||||||
|
csrf_cookie = request.cookies.get(self.cookie_name)
|
||||||
|
csrf_header = request.headers.get(self.header_name.lower()) # Starlette lowercase headers
|
||||||
|
|
||||||
|
# 3. Validate
|
||||||
|
if not csrf_cookie:
|
||||||
|
log.warning(f"CSRF validation failed: Missing cookie '{self.cookie_name}'")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="CSRF validation failed: Missing session cookie."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not csrf_header:
|
||||||
|
log.warning(f"CSRF validation failed: Missing header '{self.header_name}'")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"CSRF validation failed: Missing header '{self.header_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not secrets.compare_digest(csrf_cookie, csrf_header):
|
||||||
|
log.warning(f"CSRF validation failed: Token mismatch for path {request.url.path}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="CSRF validation failed: Token mismatch."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Proceed with the request
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
|
||||||
|
# 5. Ensure the cookie is set if missing (e.g., on first GET request)
|
||||||
|
if not request.cookies.get(self.cookie_name):
|
||||||
|
new_token = secrets.token_urlsafe(32)
|
||||||
|
response.set_cookie(
|
||||||
|
key=self.cookie_name,
|
||||||
|
value=new_token,
|
||||||
|
httponly=False, # Set to False so frontend JS can read it for Double Submit
|
||||||
|
samesite="lax",
|
||||||
|
secure=True, # Recommended for production with HTTPS
|
||||||
|
path="/"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
Loading…
Reference in New Issue