Phase 2: Fix high-priority security and stability issues (H1-H7)
H1: Sanitize error endpoint context (strip tokens/passwords, limit depth/size) H2: Rate limiter IP spoofing fix (trusted proxy CIDR check for X-Forwarded-For) H3: CSRF middleware uses Redis singleton instead of per-request connection H4: WebSocket origin verification added to both kommunikation and ai_ui_control H5: Storage path traversal protection, get_url() returns relative URL not filesystem path H6: Security headers middleware (HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy) H7: Forward-repair migration 0045 for databases that ran original 0021/0027 Also: add trusted_proxy_cidrs to config, add verify_ws_origin to auth
This commit is contained in:
+62
-21
@@ -14,6 +14,50 @@ from app.config import get_settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all responses."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
settings = get_settings()
|
||||
is_production = settings.environment == "production"
|
||||
|
||||
# HSTS — only in production (HTTPS assumed behind proxy)
|
||||
if is_production:
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=63072000; includeSubDomains; preload"
|
||||
)
|
||||
|
||||
# Prevent MIME type sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Prevent clickjacking
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
# Content Security Policy — restrictive but allows inline styles for SPA
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self' wss: ws:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
# Referrer policy
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
|
||||
# Permissions policy
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"geolocation=(), microphone=(), camera=()"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Validate Origin header and CSRF token on all state-changing requests.
|
||||
|
||||
@@ -63,30 +107,27 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
content={"detail": "No session for CSRF validation", "code": "csrf_no_session"},
|
||||
)
|
||||
|
||||
# Look up CSRF token from Redis session
|
||||
import redis.asyncio as aioredis
|
||||
# Look up CSRF token from Redis session (use singleton)
|
||||
from app.core.auth import get_redis
|
||||
|
||||
redis = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
raw = await redis.get(f"session:{session_id}")
|
||||
if raw is None:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "Session expired for CSRF validation", "code": "csrf_session_expired"},
|
||||
)
|
||||
redis = get_redis()
|
||||
raw = await redis.get(f"session:{session_id}")
|
||||
if raw is None:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "Session expired for CSRF validation", "code": "csrf_session_expired"},
|
||||
)
|
||||
|
||||
session_data = json.loads(raw)
|
||||
stored_token = session_data.get("csrf_token")
|
||||
session_data = json.loads(raw)
|
||||
stored_token = session_data.get("csrf_token")
|
||||
|
||||
if not stored_token or stored_token != csrf_header:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF token mismatch", "code": "csrf_token_mismatch"},
|
||||
)
|
||||
if not stored_token or stored_token != csrf_header:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF token mismatch", "code": "csrf_token_mismatch"},
|
||||
)
|
||||
|
||||
# Sliding session: also extend TTL on CSRF-validated unsafe requests
|
||||
await redis.expire(f"session:{session_id}", settings.session_ttl_seconds)
|
||||
finally:
|
||||
await redis.close()
|
||||
# Sliding session: also extend TTL on CSRF-validated unsafe requests
|
||||
await redis.expire(f"session:{session_id}", settings.session_ttl_seconds)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
Reference in New Issue
Block a user