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:
Agent Zero
2026-07-26 20:49:15 +02:00
parent 5ec1fc9b05
commit 604a2b7648
10 changed files with 360 additions and 38 deletions
+3
View File
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
# Frontend URL for email links (password reset, invitations, etc.)
frontend_url: str = "http://localhost:5173"
# Trusted proxy CIDRs (comma-separated) — only these proxies can set X-Forwarded-For
trusted_proxy_cidrs: str = ""
# Rate Limiting
rate_limit_login_max: int = 5
rate_limit_login_window: int = 900 # 15 min
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -1
View File
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
from app.config import get_settings
from app.core.db import close_engine, get_engine
from app.core.error_codes import ApiError
from app.core.middleware import CSRFMiddleware
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware
from app.core.monitoring import record_error, record_request
from app.core.plugin_error_handler import wrap_plugin_route
from app.core.service_container import get_container
@@ -314,6 +314,7 @@ def create_app() -> FastAPI:
max_age=3600,
)
app.add_middleware(CSRFMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestLoggingMiddleware)
# ── Global exception handler — catch ALL unhandled exceptions ──
+5 -1
View File
@@ -205,10 +205,14 @@ async def ai_ui_control_ws(websocket: WebSocket):
Authentication: via session cookie (same pattern as kommunikation plugin).
"""
from app.config import get_settings
from app.core.auth import get_session_data, get_redis
from app.core.auth import get_session_data, get_redis, verify_ws_origin
from app.core.service_container import get_container
settings = get_settings()
if not verify_ws_origin(websocket):
await websocket.close(code=4003, reason="Origin not allowed")
return
session_id = websocket.cookies.get(settings.session_cookie_name)
if not session_id:
await websocket.close(code=4001, reason="Not authenticated")
+6 -2
View File
@@ -471,11 +471,15 @@ async def websocket_endpoint(
Authenticates via session cookie. On connect, subscribes user to all their conversations.
"""
# Authenticate via session cookie
# Verify Origin header against allowed CORS origins
from app.config import get_settings
from app.core.auth import get_session_data, get_redis
from app.core.auth import get_session_data, get_redis, verify_ws_origin
settings = get_settings()
if not verify_ws_origin(websocket):
await websocket.close(code=4003, reason="Origin not allowed")
return
session_id = websocket.cookies.get(settings.session_cookie_name)
if not session_id:
await websocket.close(code=4001, reason="Not authenticated")
+37 -4
View File
@@ -2,12 +2,14 @@
No auth required so errors can be logged even during logout.
Rate-limited to 10 requests per minute per IP (simple in-memory implementation).
Context data is sanitized to prevent leaking sensitive information.
"""
from __future__ import annotations
import time
import logging
import re
from collections import defaultdict, deque
from typing import Any
@@ -23,6 +25,13 @@ RATE_LIMIT = 10 # max requests
RATE_WINDOW = 60 # seconds
_ip_requests: dict[str, deque[float]] = defaultdict(deque)
# -- Sensitive key patterns to strip from context --
_SENSITIVE_PATTERNS = re.compile(
r"(?i)(token|password|secret|authorization|cookie|session|api[_-]?key|"
r"access[_-]?token|refresh[_-]?token|csrf|bearer|private[_-]?key|"
r"client[_-]?secret|x[_-]?auth|x[_-]?api[_-]?key)",
)
def _is_rate_limited(client_ip: str) -> bool:
"""Return True if the IP has exceeded the rate limit."""
@@ -40,6 +49,25 @@ def _is_rate_limited(client_ip: str) -> bool:
return False
def _sanitize_context(context: Any, max_depth: int = 3, _depth: int = 0) -> Any:
"""Recursively remove sensitive keys and limit depth/size of context data."""
if _depth > max_depth:
return "[truncated]"
if isinstance(context, dict):
sanitized = {}
for key, value in context.items():
if _SENSITIVE_PATTERNS.search(str(key)):
sanitized[key] = "[redacted]"
else:
sanitized[key] = _sanitize_context(value, max_depth, _depth + 1)
return sanitized
if isinstance(context, list):
return [_sanitize_context(item, max_depth, _depth + 1) for item in context[:20]]
if isinstance(context, str) and len(context) > 500:
return context[:500] + "[truncated]"
return context
# -- Request schema --
class ErrorReport(BaseModel):
@@ -54,11 +82,16 @@ class ErrorReport(BaseModel):
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
async def report_error(error: ErrorReport, request: Request) -> Response:
"""Log a frontend error. No auth required. Rate-limited per IP."""
client_ip = request.client.host if request.client else "unknown"
from app.core.rate_limit import get_client_ip
client_ip = get_client_ip(request)
if _is_rate_limited(client_ip):
return Response(status_code=status.HTTP_429_TOO_MANY_REQUESTS)
# Sanitize context to prevent leaking sensitive data
sanitized_context = _sanitize_context(error.context) if error.context else None
# Log with structured info
logger.error(
"Frontend error reported: %s",
@@ -67,14 +100,14 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
"error_timestamp": error.timestamp,
"error_message": error.message,
"error_stack": error.stack,
"error_context": error.context,
"error_context": sanitized_context,
"error_url": error.url,
"error_user_agent": error.userAgent,
"client_ip": client_ip,
},
)
# If forgejo_error_reporter plugin is active, forward error
# If forgejo_error_reporter plugin is active, forward sanitized error
try:
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
entry = {
@@ -83,7 +116,7 @@ async def report_error(error: ErrorReport, request: Request) -> Response:
"url": error.url,
"userAgent": error.userAgent,
"timestamp": error.timestamp,
"context": error.context,
"context": sanitized_context,
}
await report_error_to_forgejo(entry)
except Exception: