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