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:
@@ -91,6 +91,23 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_ws_origin(websocket) -> bool:
|
||||
"""Verify that the WebSocket upgrade request comes from an allowed origin.
|
||||
|
||||
Checks the Origin header against the configured CORS origins.
|
||||
Returns True if the origin is allowed or if no CORS restriction is configured.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
allowed_origins = settings.cors_origin_list
|
||||
if not allowed_origins:
|
||||
return True
|
||||
origin = websocket.headers.get("origin", "")
|
||||
if not origin:
|
||||
return True # Non-browser clients don't send Origin
|
||||
return origin in allowed_origins
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
|
||||
+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)
|
||||
|
||||
+35
-5
@@ -39,8 +39,38 @@ async def reset_rate_limit(redis_key: str) -> None:
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
"""Extract client IP from request.
|
||||
|
||||
Only trusts X-Forwarded-For if the direct client is a trusted proxy
|
||||
(configured via TRUSTED_PROXY_CIDRS env var, comma-separated CIDRs).
|
||||
This prevents IP spoofing to bypass rate limits.
|
||||
"""
|
||||
direct_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Check if the direct client is a trusted proxy
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
trusted_proxies = getattr(settings, "trusted_proxy_cidrs", "")
|
||||
|
||||
if trusted_proxies:
|
||||
import ipaddress
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(direct_ip)
|
||||
for cidr in trusted_proxies.split(","):
|
||||
cidr = cidr.strip()
|
||||
if cidr and client_ip in ipaddress.ip_network(cidr, strict=False):
|
||||
# Trusted proxy — use X-Forwarded-For
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
# Use the leftmost (original client) IP
|
||||
return forwarded.split(",")[0].strip()
|
||||
# Fallback to X-Real-IP
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Not a trusted proxy or no trusted proxies configured — use direct IP
|
||||
return direct_ip
|
||||
|
||||
+9
-4
@@ -74,8 +74,12 @@ class LocalStorage(StorageBackend):
|
||||
os.makedirs(self.base_path, exist_ok=True)
|
||||
|
||||
def _full_path(self, path: str) -> str:
|
||||
"""Get the full filesystem path."""
|
||||
return os.path.join(self.base_path, path)
|
||||
"""Get the full filesystem path with path traversal protection."""
|
||||
# Normalize and ensure the path stays within base_path
|
||||
full = os.path.normpath(os.path.join(self.base_path, path))
|
||||
if not full.startswith(os.path.normpath(self.base_path)):
|
||||
raise ValueError(f"Path traversal detected: {path}")
|
||||
return full
|
||||
|
||||
async def save(self, path: str, data: bytes) -> str:
|
||||
full_path = self._full_path(path)
|
||||
@@ -113,8 +117,9 @@ class LocalStorage(StorageBackend):
|
||||
return os.path.exists(self._full_path(path))
|
||||
|
||||
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||||
# Local storage returns the file path for direct access
|
||||
return self._full_path(path)
|
||||
"""Return a relative URL path for the file (not the filesystem path)."""
|
||||
# Return a relative path that can be served by the app
|
||||
return f"/api/v1/dms/files/{path}"
|
||||
|
||||
async def list_files(self, prefix: str) -> list[str]:
|
||||
full_prefix = self._full_path(prefix)
|
||||
|
||||
Reference in New Issue
Block a user