T-005: Security hardening - CORS, Rate Limit, CSRF

- CORS: Restrict allowed origins to specific frontend URLs, limit methods and headers
- Rate Limit: Add fine-grained endpoint-specific rate limits for sensitive operations
  - Login: 5 requests/minute
  - Register: 3 requests/hour
  - Password change: 3 requests/5 minutes
  - Payment: 20 requests/minute
  - Admin: 30 requests/minute
- CSRF: Add CSRF protection middleware with double-submit cookie pattern
  - New app/core/csrf.py module with CSRFMiddleware
  - Require CSRF tokens on sensitive endpoints (auth, payment, profile)
  - Skip webhook endpoints for CSRF validation
- Fix pydantic-settings import in config.py
This commit is contained in:
TradeMate Dev
2026-05-29 10:26:23 +08:00
parent 7c9885f704
commit c04fa2c19f
7 changed files with 464 additions and 58 deletions
+166
View File
@@ -0,0 +1,166 @@
"""
CSRF Protection Module for TradeMate
Provides CSRF token generation and validation for form submissions
"""
import secrets
import time
from typing import Optional, Tuple
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
import logging
logger = logging.getLogger(__name__)
# CSRF token configuration
CSRF_TOKEN_EXPIRY = 3600 # 1 hour
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_COOKIE_NAME = "csrf_token"
# Methods that require CSRF protection
CSRF_PROTECTED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# Endpoints that should skip CSRF protection (e.g., webhook endpoints)
CSRF_SKIP_ENDPOINTS = [
"/api/v1/webhook/",
"/api/v1/payment/notify",
"/api/v1/whatsapp/webhook",
]
def generate_csrf_token() -> str:
"""Generate a secure CSRF token"""
return secrets.token_urlsafe(32)
def validate_csrf_token(token: Optional[str], request: Request) -> bool:
"""
Validate CSRF token from request.
Checks:
1. Token is present
2. Token matches the session/token cookie
3. Token is not expired
"""
if not token:
return False
# Get the expected token from cookie or session
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
if not cookie_token:
return False
# Constant-time comparison to prevent timing attacks
return secrets.compare_digest(token, cookie_token)
class CSRFMiddleware(BaseHTTPMiddleware):
"""
CSRF protection middleware for FastAPI.
For JWT-based APIs, CSRF protection is primarily needed for:
1. Form-based submissions (if any)
2. Any endpoint that uses cookies for authentication
3. Prevention of cross-site request forgery attacks
The middleware:
- Generates CSRF tokens for authenticated sessions
- Validates CSRF tokens on state-changing requests
- Skips validation for webhook endpoints and public APIs
"""
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Skip CSRF protection for:
# 1. Health check endpoint
# 2. Webhook endpoints
# 3. Public API endpoints (no auth required)
if path == "/health" or any(path.startswith(skip) for skip in CSRF_SKIP_ENDPOINTS):
return await call_next(request)
# Get authorization header
auth_header = request.headers.get("Authorization", "")
has_jwt = auth_header.startswith("Bearer ")
# For API requests with JWT, we use double-submit cookie pattern
# The client should send X-CSRF-Token header matching the csrf_token cookie
if request.method in CSRF_PROTECTED_METHODS:
# Check for CSRF token in header
csrf_token = request.headers.get(CSRF_HEADER_NAME)
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
# If there's a JWT but no CSRF token, this might be a direct API call
# In that case, we require the CSRF token to be present
if has_jwt and not csrf_token:
# This is a potential CSRF attempt
# For API clients using JWT, we still require CSRF protection
# to prevent attacks from malicious websites
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "CSRF_TOKEN_MISSING",
"message": "CSRF token required for this request",
"required_header": CSRF_HEADER_NAME,
},
)
# Validate the token if present
if csrf_token and cookie_token:
if not validate_csrf_token(csrf_token, request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "CSRF_TOKEN_INVALID",
"message": "Invalid or expired CSRF token",
},
)
elif csrf_token and not cookie_token:
# Token in header but no cookie - invalid state
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "CSRF_TOKEN_MISMATCH",
"message": "CSRF token cookie not found",
},
)
response = await call_next(request)
# If this is an authenticated request and no CSRF cookie exists,
# generate and set one
if has_jwt and not request.cookies.get(CSRF_COOKIE_NAME):
csrf_token = generate_csrf_token()
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
max_age=CSRF_TOKEN_EXPIRY,
httponly=False, # Must be accessible to JavaScript for double-submit
secure=settings.DEBUG is False, # Secure in production
samesite="lax", # Prevent cross-site requests
path="/",
)
# Also add the token to response headers for convenience
response.headers[CSRF_HEADER_NAME] = csrf_token
return response
def get_csrf_token_from_request(request: Request) -> Optional[str]:
"""Helper to extract CSRF token from request"""
return request.headers.get(CSRF_HEADER_NAME) or request.cookies.get(CSRF_COOKIE_NAME)
def require_csrf_token(request: Request) -> str:
"""
Dependency function to require CSRF token in route handlers.
Use with: Depends(require_csrf_token)
"""
csrf_token = get_csrf_token_from_request(request)
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token required",
)
return csrf_token