Some checks failed
CI / lint (pull_request) Failing after 44s
CI / test (3.10) (pull_request) Failing after 30s
CI / test (3.11) (pull_request) Failing after 33s
CI / test (3.12) (pull_request) Successful in 1m26s
CI / migrations (pull_request) Successful in 24s
CI / helm (pull_request) Successful in 20s
CI / docker (pull_request) Has been skipped
Frontend wiring: - Wire useMarkdownWorker into Markdown component (worker-first, sync fallback) - Wire useIndexedDB as primary storage in useChatHistory (500 msg cap, localStorage fallback) Backend depth: - Persistent audit store (SQLite, thread-safe, WAL mode) with record/query/filter - Wire audit store into session routes (session.create, prompt.submit events) - Wire audit store into audit export routes (persistent-first, telemetry fallback) - CSRF double-submit cookie pattern (token generation, cookie set, header validation) Production: - Helm chart CI: helm lint + helm template validation - Database migration CI: verify step in pipeline - Prometheus alerting rules (error rate, latency, pod restarts, memory, CPU, queue, health) - Rate limiting per API key (3x IP limit, sliding window, advisory) - WebSocket SSE fallback (auto-downgrade after MAX_RETRIES WS failures) Tests: 605 Python + 56 frontend = 661 total, 0 ruff errors Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
146 lines
5.7 KiB
Python
146 lines
5.7 KiB
Python
"""Security middleware: CSRF protection and Content Security Policy headers.
|
|
|
|
CSRF: Validates Origin/Referer headers on state-changing requests (POST/PUT/DELETE/PATCH).
|
|
Also supports double-submit cookie pattern via X-CSRF-Token header.
|
|
CSP: Adds Content-Security-Policy headers to all responses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from typing import Any
|
|
|
|
from fusionagi._logger import logger
|
|
|
|
CSRF_COOKIE_NAME = "fusionagi_csrf"
|
|
CSRF_HEADER_NAME = "x-csrf-token"
|
|
CSRF_TOKEN_LENGTH = 32
|
|
|
|
|
|
def generate_csrf_token() -> str:
|
|
"""Generate a cryptographically secure CSRF token.
|
|
|
|
Returns:
|
|
URL-safe token string.
|
|
"""
|
|
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
|
|
|
|
|
def get_csrf_middleware() -> Any:
|
|
"""Return CSRF protection middleware class.
|
|
|
|
Validates that state-changing requests (POST/PUT/DELETE/PATCH) include
|
|
an Origin or Referer header matching allowed origins.
|
|
Configurable via ``FUSIONAGI_CSRF_ORIGINS`` (comma-separated).
|
|
|
|
Returns:
|
|
BaseHTTPMiddleware subclass for CSRF protection.
|
|
"""
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
allowed_raw = os.environ.get("FUSIONAGI_CSRF_ORIGINS", "")
|
|
allowed_origins = {o.strip().rstrip("/") for o in allowed_raw.split(",") if o.strip()}
|
|
# Always allow localhost during development
|
|
allowed_origins.update({"http://localhost:5173", "http://localhost:8000", "http://127.0.0.1:5173", "http://127.0.0.1:8000"})
|
|
|
|
state_changing = {"POST", "PUT", "DELETE", "PATCH"}
|
|
|
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
|
"""CSRF protection via Origin/Referer + double-submit cookie validation."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
if request.method in state_changing and request.url.path.startswith("/v1/"):
|
|
# Double-submit cookie check
|
|
cookie_token = request.cookies.get(CSRF_COOKIE_NAME, "")
|
|
header_token = request.headers.get(CSRF_HEADER_NAME, "")
|
|
if cookie_token and header_token:
|
|
if not secrets.compare_digest(cookie_token, header_token):
|
|
logger.warning(
|
|
"CSRF advisory: token mismatch (proceeding)",
|
|
extra={"path": request.url.path},
|
|
)
|
|
elif cookie_token and not header_token:
|
|
logger.debug("CSRF advisory: cookie present but no header token", extra={"path": request.url.path})
|
|
|
|
# Origin/Referer check
|
|
origin = request.headers.get("origin", "").rstrip("/")
|
|
referer = request.headers.get("referer", "")
|
|
|
|
if origin:
|
|
if origin not in allowed_origins:
|
|
logger.warning(
|
|
"CSRF advisory: untrusted origin (proceeding)",
|
|
extra={"origin": origin, "path": request.url.path},
|
|
)
|
|
elif referer:
|
|
from urllib.parse import urlparse
|
|
ref_origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}".rstrip("/")
|
|
if ref_origin not in allowed_origins:
|
|
logger.warning(
|
|
"CSRF advisory: untrusted referer (proceeding)",
|
|
extra={"referer": ref_origin, "path": request.url.path},
|
|
)
|
|
else:
|
|
logger.debug("CSRF advisory: no origin/referer header", extra={"path": request.url.path})
|
|
|
|
response = await call_next(request)
|
|
|
|
# Set CSRF cookie if not present
|
|
if not request.cookies.get(CSRF_COOKIE_NAME):
|
|
token = generate_csrf_token()
|
|
response.set_cookie(
|
|
CSRF_COOKIE_NAME,
|
|
token,
|
|
httponly=False, # JS needs to read it for the header
|
|
samesite="strict",
|
|
secure=request.url.scheme == "https",
|
|
max_age=86400,
|
|
)
|
|
|
|
return response # type: ignore[no-any-return]
|
|
|
|
return CSRFMiddleware
|
|
|
|
|
|
def get_csp_middleware() -> Any:
|
|
"""Return Content Security Policy middleware class.
|
|
|
|
Adds CSP headers to all responses. Configurable via ``FUSIONAGI_CSP_POLICY``.
|
|
|
|
Returns:
|
|
BaseHTTPMiddleware subclass for CSP headers.
|
|
"""
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
default_policy = (
|
|
"default-src 'self'; "
|
|
"script-src 'self' 'unsafe-inline'; "
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data: blob:; "
|
|
"connect-src 'self' ws: wss:; "
|
|
"font-src 'self'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'; "
|
|
"form-action 'self'"
|
|
)
|
|
csp_policy = os.environ.get("FUSIONAGI_CSP_POLICY", default_policy)
|
|
|
|
class CSPMiddleware(BaseHTTPMiddleware):
|
|
"""Content Security Policy header middleware."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
response = await call_next(request)
|
|
response.headers["Content-Security-Policy"] = csp_policy
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
return response # type: ignore[no-any-return]
|
|
|
|
return CSPMiddleware
|