Files
gh-christianlouis-inboxconv…/backend/app/core/middleware.py
T
copilot-swe-agent[bot] bcbef88803 Fix CI failures: add backend/conftest.py for module resolution and run black formatting
- Add backend/conftest.py that inserts the backend directory into sys.path,
  fixing ModuleNotFoundError when pytest runs from the backend/ directory
  (as CI does with `cd backend && pytest tests/`)
- Run black formatter on all 28 backend files that needed reformatting
- All 53 tests pass with both `pytest tests/` and `python -m pytest tests/`

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/beb47db2-da25-416a-8fb0-c1452a3b22a7
2026-03-23 10:24:28 +00:00

107 lines
3.7 KiB
Python

"""
Security middleware for adding security headers and CSRF protection.
"""
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
import secrets
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
# Prevent clickjacking
response.headers["X-Frame-Options"] = "DENY"
# Prevent MIME type sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Enable XSS protection (for older browsers)
response.headers["X-XSS-Protection"] = "1; mode=block"
# Strict Transport Security (HTTPS only)
# Note: Only enable in production with HTTPS
if request.url.hostname not in ["localhost", "127.0.0.1"]:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
# Content Security Policy (adjust based on frontend needs)
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://js.stripe.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self' https://api.stripe.com; "
"frame-src https://js.stripe.com;"
)
response.headers["Content-Security-Policy"] = csp
# Referrer Policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Permissions Policy (formerly Feature Policy)
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()"
)
return response
class CSRFProtectionMiddleware(BaseHTTPMiddleware):
"""
Basic CSRF protection for state-changing operations.
For API-only applications, this is less critical but still good practice.
"""
def __init__(self, app: ASGIApp, exempt_paths: list = None):
super().__init__(app)
self.exempt_paths = exempt_paths or [
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/google",
"/docs",
"/openapi.json",
"/health",
]
async def dispatch(self, request: Request, call_next) -> Response:
# Skip CSRF check for safe methods
if request.method in ["GET", "HEAD", "OPTIONS"]:
return await call_next(request)
# Skip CSRF check for exempt paths
if any(request.url.path.startswith(path) for path in self.exempt_paths):
return await call_next(request)
# For API endpoints using JWT, the token itself provides CSRF protection
# This is because attackers can't access the token stored in httpOnly cookies
# or local storage from a different origin
# If implementing cookie-based sessions, would check CSRF token here:
# csrf_token = request.headers.get("X-CSRF-Token")
# if not csrf_token or not self._validate_csrf_token(csrf_token):
# return JSONResponse(
# status_code=403,
# content={"detail": "CSRF token missing or invalid"}
# )
response = await call_next(request)
return response
@staticmethod
def _generate_csrf_token() -> str:
"""Generate a secure CSRF token"""
return secrets.token_urlsafe(32)
@staticmethod
def _validate_csrf_token(token: str) -> bool:
"""Validate CSRF token (implement actual validation logic)"""
# In a real implementation, compare against stored token
return len(token) == 43 # token_urlsafe(32) produces 43 chars