feat: Add CSRF protection middleware to the application.

oh_security
Cizz22 4 months ago
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

@ -29,6 +29,7 @@ from src.enums import ResponseStatus
from src.exceptions import handle_exception from src.exceptions import handle_exception
from src.logging import configure_logging from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware from src.middleware import RequestValidationMiddleware
from src.csrf_protect import CSRFProtectMiddleware
from src.rate_limiter import limiter from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@ -124,6 +125,10 @@ security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware) app.add_middleware(RequestValidationMiddleware)
app.add_middleware(
CSRFProtectMiddleware,
excluded_paths=["/healthcheck"]
)

Loading…
Cancel
Save