From a1a6ef137af9662b712d2befec9b1e1f96bd9fe9 Mon Sep 17 00:00:00 2001 From: zgaetano Date: Tue, 31 Mar 2026 23:32:11 -0400 Subject: [PATCH] security: require auth on all admin/dashboard/user routes /admin, /dashboard, /dashboard/status, and all /users/* and /keys/* endpoints were publicly accessible with no authentication, exposing user management, API key generation, and backend topology to anyone. - /dashboard and /dashboard/status now require Bearer token - /admin (user management UI) now requires Bearer token - All /users/* and /keys/revoke routes now require Bearer token - /health scrubbed of sensitive fields (token counts, client counts) - /linkedin/* left public (required for OAuth callback flow) Auth checks use GATEWAY_STATIC_API_KEY or valid OAuth access tokens, consistent with the existing /mcp and /status endpoints. --- gateway-proxy/gateway_proxy.py | 17 +++++++++----- gateway-proxy/user_dashboard_ui.py | 31 ++++++++++++++++++++++--- gateway-proxy/user_routes.py | 36 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/gateway-proxy/gateway_proxy.py b/gateway-proxy/gateway_proxy.py index 1adc32e..e712307 100644 --- a/gateway-proxy/gateway_proxy.py +++ b/gateway-proxy/gateway_proxy.py @@ -753,11 +753,7 @@ async def handle_mcp(request: Request) -> Response: async def health(request: Request) -> JSONResponse: return JSONResponse({ "status": "healthy", - "backends": len(BACKENDS), - "tools": len(TOOL_DEFINITIONS), "oauth": "enabled", - "active_tokens": len(ACCESS_TOKENS), - "registered_clients": len(REGISTERED_CLIENTS), }) @@ -830,7 +826,10 @@ async def probe_backend(name: str, url: str) -> dict: async def dashboard_status(request: Request) -> JSONResponse: - """Public endpoint — no auth required — live-probes all backends.""" + """Auth-protected endpoint — live-probes all backends.""" + token_info = validate_bearer_token(request) + if not token_info: + return JSONResponse({"error": "unauthorized"}, status_code=401) probes = await asyncio.gather( *[probe_backend(name, url) for name, url in BACKENDS.items()], return_exceptions=True @@ -983,6 +982,14 @@ DASHBOARD_HTML = """ async def dashboard(request: Request) -> Response: + token_info = validate_bearer_token(request) + if not token_info: + return Response( + content='{"error": "unauthorized"}', + status_code=401, + media_type="application/json", + headers={"WWW-Authenticate": f'Bearer resource_metadata="{ISSUER_URL}/.well-known/oauth-protected-resource"'}, + ) return Response(DASHBOARD_HTML, media_type="text/html") diff --git a/gateway-proxy/user_dashboard_ui.py b/gateway-proxy/user_dashboard_ui.py index d7df457..c7f937e 100644 --- a/gateway-proxy/user_dashboard_ui.py +++ b/gateway-proxy/user_dashboard_ui.py @@ -5,7 +5,31 @@ Provides a comprehensive web interface for managing users and API keys. Vue 3 compatible. """ -from starlette.responses import HTMLResponse +import os +import time +import hashlib +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse + +_STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "") + +def _admin_require_auth(request: Request): + """Returns None if authorized, or a 401 JSONResponse if not.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse({"error": "unauthorized"}, status_code=401) + token = auth_header[7:] + if _STATIC_API_KEY and token == _STATIC_API_KEY: + return None + try: + from gateway_proxy import ACCESS_TOKENS + token_hash = hashlib.sha256(token.encode()).hexdigest() + info = ACCESS_TOKENS.get(token_hash) + if info and info["expires_at"] >= time.time(): + return None + except ImportError: + pass + return JSONResponse({"error": "unauthorized"}, status_code=401) USER_DASHBOARD_HTML = """ @@ -397,6 +421,7 @@ createApp({ """ -async def user_management_dashboard(request): - """Serve the user management dashboard.""" +async def user_management_dashboard(request: Request): + """Serve the user management dashboard. Requires Bearer token auth.""" + if (err := _admin_require_auth(request)): return err return HTMLResponse(USER_DASHBOARD_HTML) diff --git a/gateway-proxy/user_routes.py b/gateway-proxy/user_routes.py index e7764f9..d6950a0 100644 --- a/gateway-proxy/user_routes.py +++ b/gateway-proxy/user_routes.py @@ -2,16 +2,45 @@ User Management Routes for MCP Gateway ====================================== REST endpoints for user, API key, and MCP access control management. +All routes require a valid Bearer token (static API key or OAuth token). """ import json +import os +import time +import hashlib from starlette.requests import Request from starlette.responses import JSONResponse from user_management import user_manager +STATIC_API_KEY = os.environ.get("GATEWAY_STATIC_API_KEY", "") + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + +def _require_auth(request: Request): + """Returns None if authorized, or a 401 JSONResponse if not.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse({"error": "unauthorized"}, status_code=401) + token = auth_header[7:] + if STATIC_API_KEY and token == STATIC_API_KEY: + return None + # Import live token store from gateway_proxy to validate OAuth tokens + try: + from gateway_proxy import ACCESS_TOKENS + token_hash = _hash(token) + info = ACCESS_TOKENS.get(token_hash) + if info and info["expires_at"] >= time.time(): + return None + except ImportError: + pass + return JSONResponse({"error": "unauthorized"}, status_code=401) + async def create_user(request: Request) -> JSONResponse: """POST /users — Create a new user.""" + if (err := _require_auth(request)): return err try: body = await request.json() username = body.get('username', '').strip() @@ -40,6 +69,7 @@ async def create_user(request: Request) -> JSONResponse: async def list_users(request: Request) -> JSONResponse: """GET /users — List all users.""" + if (err := _require_auth(request)): return err try: users = user_manager.list_users() return JSONResponse({ @@ -53,6 +83,7 @@ async def list_users(request: Request) -> JSONResponse: async def get_user(request: Request) -> JSONResponse: """GET /users/{username} — Get user details.""" + if (err := _require_auth(request)): return err try: username = request.path_params.get('username') users = {u['username']: u for u in user_manager.list_users()} @@ -75,6 +106,7 @@ async def get_user(request: Request) -> JSONResponse: async def delete_user(request: Request) -> JSONResponse: """DELETE /users/{username} — Delete a user.""" + if (err := _require_auth(request)): return err try: username = request.path_params.get('username') user_manager.delete_user(username) @@ -88,6 +120,7 @@ async def delete_user(request: Request) -> JSONResponse: async def toggle_user(request: Request) -> JSONResponse: """PATCH /users/{username}/enable — Enable/disable a user.""" + if (err := _require_auth(request)): return err try: username = request.path_params.get('username') body = await request.json() @@ -109,6 +142,7 @@ async def toggle_user(request: Request) -> JSONResponse: async def generate_api_key(request: Request) -> JSONResponse: """POST /users/{username}/keys — Generate a new API key.""" + if (err := _require_auth(request)): return err try: username = request.path_params.get('username') body = await request.json() @@ -132,6 +166,7 @@ async def generate_api_key(request: Request) -> JSONResponse: async def revoke_api_key(request: Request) -> JSONResponse: """DELETE /keys/{key_hash} — Revoke an API key.""" + if (err := _require_auth(request)): return err try: key_hash = request.path_params.get('key_hash') # In practice, we'd need to reconstruct the hash or pass the full key @@ -155,6 +190,7 @@ async def revoke_api_key(request: Request) -> JSONResponse: async def set_mcp_access(request: Request) -> JSONResponse: """PUT /users/{username}/mcp-access — Configure MCP access for a user.""" + if (err := _require_auth(request)): return err try: username = request.path_params.get('username') body = await request.json()