# src/middleware.py # Shared middleware, decorators, and request helpers import os import secrets from fastapi import HTTPException, Request from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response # Per-process token that lets the in-app tool layer hit admin-gated # routes via HTTP loopback (the agent's tool calls don't carry the # admin user's session cookie). Set once at import; tools read the # same value from this module. Never persisted or exposed externally. INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32) INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token" def require_admin(request: Request): """Raise 403 if the current user isn't an admin. Allows access when auth is explicitly disabled, or when the request carries the in-process internal-tool token used by loopback agent tools. """ # In-process bypass for tool-layer loopback calls. Two paths: # (a) header-direct (caller set X-Odysseus-Internal-Token), or # (b) the auth middleware already validated the token and stamped # request.state.current_user = "internal-tool". try: if request.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN: return if getattr(request.state, "current_user", None) == "internal-tool": return except Exception: pass auth_mgr = getattr(request.app.state, "auth_manager", None) if os.getenv("AUTH_ENABLED", "true").lower() == "false": return if not auth_mgr or not auth_mgr.is_configured: raise HTTPException(403, "Admin only") user = getattr(request.state, "current_user", None) if not user or not auth_mgr.is_admin(user): raise HTTPException(403, "Admin only") class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add standard security headers to all responses.""" async def dispatch(self, request: Request, call_next) -> Response: # Generate a per-request nonce for inline scripts nonce = secrets.token_hex(16) request.state.csp_nonce = nonce response = await call_next(request) path = request.url.path # Tool render endpoints are served inside iframes — allow framing by self is_tool_render = path.startswith("/api/tools/") and path.endswith("/render") # Visual report pages are self-contained HTML — need inline scripts + external images is_report = path.startswith("/api/research/report/") response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" if is_report: response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; " "font-src 'self'; " "img-src 'self' data: blob: https:; " "connect-src 'self'; " "frame-ancestors 'none'" ) elif is_tool_render: # Tool iframe content: skip all framing headers — the iframe's # sandbox="allow-scripts" attribute provides isolation. # Don't overwrite the route's own restrictive CSP either. pass else: response.headers["X-Frame-Options"] = "DENY" # NOTE: `style-src 'unsafe-inline'` is intentionally retained. # `static/index.html` and `static/login.html` ship inline