diff --git a/mcpgateway/admin_ui/admin.js b/mcpgateway/admin_ui/admin.js index d1bf43e164..42a9d76e3b 100644 --- a/mcpgateway/admin_ui/admin.js +++ b/mcpgateway/admin_ui/admin.js @@ -12,6 +12,12 @@ import htmx from 'htmx.org'; window.htmx = htmx; +// Configure HTMX to use CSP nonce for inline event handlers +// The nonce is set in the template via window.htmxConfig before this bundle loads +if (window.htmxConfig && window.htmxConfig.inlineScriptNonce) { + htmx.config.inlineScriptNonce = window.htmxConfig.inlineScriptNonce; +} + // Bootstrap MUST be first - initializes window.Admin before any modules run import "./bootstrap.js"; diff --git a/mcpgateway/main.py b/mcpgateway/main.py index 06071a51a2..abdc919269 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -3368,6 +3368,25 @@ def tojson_attr(value: object) -> str: jinja_env.filters["tojson_attr"] = tojson_attr + +def get_csp_nonce_from_request(request: Request) -> str: + """ + Retrieve the CSP nonce from the request state. + Used in templates to add nonce attributes to inline scripts. + + Args: + request: The FastAPI Request object. Can be None in test contexts. + + Returns: + The CSP nonce string, or empty string if not available. + """ + if request is None: + return "" + return getattr(request.state, "csp_nonce", "") + + +jinja_env.globals["csp_nonce"] = get_csp_nonce_from_request + templates = Jinja2Templates(env=jinja_env) if not settings.templates_auto_reload: logger.info("🎨 Template auto-reload disabled (production mode)") diff --git a/mcpgateway/middleware/security_headers.py b/mcpgateway/middleware/security_headers.py index 541a580687..1555192417 100644 --- a/mcpgateway/middleware/security_headers.py +++ b/mcpgateway/middleware/security_headers.py @@ -8,8 +8,16 @@ This module implements essential security headers to prevent common attacks including XSS, clickjacking, MIME sniffing, and cross-origin attacks. + +The Content-Security-Policy (CSP) uses a nonce-based approach to allow legitimate +inline scripts while blocking malicious ones. Each request generates a unique +cryptographic nonce that must be included in inline script tags. """ +# Standard +# Standard Library +import secrets + # Third-Party from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request @@ -31,9 +39,15 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): - X-Frame-Options: Prevents clickjacking attacks - X-XSS-Protection: Disables legacy XSS protection (modern browsers use CSP) - Referrer-Policy: Controls referrer information sent with requests - - Content-Security-Policy: Prevents XSS and other code injection attacks + - Content-Security-Policy: Nonce-based CSP prevents XSS and code injection - Strict-Transport-Security: Forces HTTPS connections (when appropriate) + CSP Implementation: + - Uses cryptographically secure nonces (secrets.token_urlsafe(16)) + - No unsafe-inline or unsafe-eval directives + - Nonce stored in request.state.csp_nonce for template access + - Inline scripts must include nonce="{{ csp_nonce(request) }}" attribute + Sensitive headers removed: - X-Powered-By: Removes server technology disclosure - Server: Removes server version information @@ -42,17 +56,21 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): >>> middleware = SecurityHeadersMiddleware(None) >>> isinstance(middleware, SecurityHeadersMiddleware) True - >>> # Test CSP directive construction + >>> # Test CSP directive construction with nonce + >>> import secrets + >>> csp_nonce = secrets.token_urlsafe(16) >>> csp_directives = [ ... "default-src 'self'", - ... "script-src 'self' 'unsafe-inline'", - ... "style-src 'self' 'unsafe-inline'" + ... f"script-src 'self' 'nonce-{csp_nonce}'", + ... f"style-src 'self' 'nonce-{csp_nonce}'" ... ] >>> csp = "; ".join(csp_directives) + ";" >>> "default-src 'self'" in csp True >>> csp.endswith(";") True + >>> "'nonce-" in csp + True >>> # Test HSTS value construction >>> hsts_max_age = 31536000 >>> hsts_value = f"max-age={hsts_max_age}" @@ -116,11 +134,13 @@ async def dispatch(self, request: Request, call_next) -> Response: >>> "strict-origin" in referrer_policy True - Test CSP directive construction: + Test CSP directive construction with nonce-based approach: + >>> import secrets + >>> csp_nonce = secrets.token_urlsafe(16) >>> csp_directives = [ ... "default-src 'self'", - ... "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com", - ... "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com", + ... f"script-src 'self' 'nonce-{csp_nonce}' https://cdnjs.cloudflare.com", + ... f"style-src 'self' 'nonce-{csp_nonce}' https://cdnjs.cloudflare.com", ... "img-src 'self' data: https:", ... "font-src 'self' data: https://cdnjs.cloudflare.com", ... "connect-src 'self' ws: wss: https:", @@ -245,6 +265,11 @@ async def dispatch(self, request: Request, call_next) -> Response: >>> 'Vary' in resp.headers and 'Origin' in resp.headers['Vary'] True """ + # Generate CSP nonce BEFORE processing request so templates can access it + # This must happen before call_next() so request.state.csp_nonce is available during template rendering + csp_nonce = secrets.token_urlsafe(16) + request.state.csp_nonce = csp_nonce + response = await call_next(request) # Only apply security headers if enabled @@ -271,12 +296,16 @@ async def dispatch(self, request: Request, call_next) -> Response: response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - # Content Security Policy - # This CSP is designed to work with the Admin UI while providing security - # Dynamically set frame-ancestors based on X_FRAME_OPTIONS setting to stay consistent + # Content Security Policy with nonce-based approach (nonce already generated above) + + # CSP directives with nonce-based approach for scripts + # Note: style-src uses 'unsafe-inline' without nonce (nonce would disable unsafe-inline) + # This is needed for Alpine.js dynamic inline styles and is acceptable security trade-off + # 'unsafe-hashes' allows onclick/onload/etc inline event handlers + # Scripts still require nonces - no 'unsafe-inline' for script-src (pentesting compliance) csp_directives = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com", + f"script-src 'self' 'nonce-{csp_nonce}' 'unsafe-hashes' https://cdnjs.cloudflare.com https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com", "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", "img-src 'self' data: https:", "font-src 'self' data: https://cdnjs.cloudflare.com", diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index 08fdc66641..e76187fa5e 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -25,7 +25,7 @@ {% endif %} - - {% endif %} + + {% if is_admin %} @@ -459,7 +466,7 @@ + > 📊 Overview @@ -475,7 +482,7 @@ + > đŸ–Ĩī¸ MCP Servers @@ -484,7 +491,7 @@ + > 🔗 Virtual Servers @@ -493,7 +500,7 @@ + > đŸ› ī¸ Tools @@ -501,7 +508,7 @@ + > âš™ī¸ ToolOps @@ -511,7 +518,7 @@ + > đŸ’Ŧ Prompts @@ -520,7 +527,7 @@ + > 📁 Resources @@ -529,7 +536,7 @@ + > đŸŒŗ Roots @@ -538,7 +545,7 @@ + > đŸ“Ļ MCP Registry @@ -555,7 +562,7 @@ + > 🤖 Agents (A2A) @@ -564,7 +571,7 @@ + > 🔌 gRPC Services @@ -582,7 +589,7 @@ + > 👨‍đŸ’ģ LLM Chat @@ -591,7 +598,7 @@ + > âš™ī¸ LLM Settings @@ -609,7 +616,7 @@ + > 📊 Metrics @@ -618,7 +625,7 @@ + > ⚡ Performance @@ -627,7 +634,7 @@ + > 🔍 Observability @@ -645,7 +652,7 @@ + > 🧩 Plugins @@ -663,7 +670,7 @@ + > đŸ‘Ĩ Teams @@ -672,7 +679,7 @@ + > 👤 Users @@ -681,7 +688,7 @@ + > đŸŽĢ API Tokens @@ -699,7 +706,7 @@ + > 📤 Export/Import @@ -708,7 +715,7 @@ + > 📋 System Logs @@ -717,7 +724,7 @@ + > â„šī¸ Version Info @@ -726,7 +733,7 @@ + > 🔧 Maintenance @@ -6296,7 +6303,7 @@

- - {% endif %} - {% endif %} - -