diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f63aca6fc62..54ee28e26bf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -477,6 +477,73 @@ async def host_header_middleware(request: Request, call_next): return await call_next(request) +@app.middleware("http") +async def _plugin_api_runtime_gate(request: Request, call_next): + """Block requests to disabled plugin API routes at request time. + + :func:`_mount_plugin_api_routes` gates at import time, but if a plugin + is disabled *after* the dashboard is already running, its FastAPI router + remains mounted until restart. This middleware enforces the enabled/ + disabled policy on every request to ``/api/plugins/{name}/...`` so that + runtime config changes take effect immediately. + + Registered BEFORE the auth middlewares (so it executes AFTER them): a + request that hasn't cleared auth must get auth's 401 first, never this + gate's 404 — otherwise an unauthenticated caller could fingerprint which + plugins are installed/enabled by reading the status code. We only reach + the enabled/disabled check for a request that auth already let through. + """ + path = request.url.path + if path.startswith("/api/plugins/"): + # Only gate authenticated requests. Unauthenticated ones fall + # through so auth_middleware / the OAuth gate return 401 first and + # this route can't be used as a plugin-name oracle. + _authed = ( + getattr(request.state, "token_authenticated", False) + or getattr(request.app.state, "auth_required", False) + or _has_valid_session_token(request) + or _has_valid_query_token(request, path) + ) + if _authed: + # Extract plugin name from /api/plugins//... + parts = path.split("/") + # parts: ['', 'api', 'plugins', '', ...] + if len(parts) >= 4: + plugin_name = parts[3] + if plugin_name: + try: + from hermes_cli.plugins_cmd import ( + _get_enabled_set, + _get_disabled_set, + ) + enabled_set = _get_enabled_set() + disabled_set = _get_disabled_set() + except Exception: + enabled_set = set() + disabled_set = set() + # Determine plugin source. Check the cached plugin list; + # if not found, assume user plugin (safe default — blocks). + plugins = _get_dashboard_plugins() + plugin = next( + (p for p in plugins if p.get("name") == plugin_name), + None, + ) + source = plugin.get("source") if plugin else "user" + if source == "user": + if plugin_name in disabled_set or plugin_name not in enabled_set: + return JSONResponse( + status_code=404, + content={"detail": "Plugin not found"}, + ) + elif source == "bundled": + if plugin_name in disabled_set: + return JSONResponse( + status_code=404, + content={"detail": "Plugin not found"}, + ) + return await call_next(request) + + # --------------------------------------------------------------------------- # Dashboard OAuth auth gate — engaged only when start_server flags the # bind as non-loopback-without-insecure. No-op pass-through in loopback @@ -515,57 +582,6 @@ async def auth_middleware(request: Request, call_next): return await call_next(request) -@app.middleware("http") -async def _plugin_api_runtime_gate(request: Request, call_next): - """Block requests to disabled plugin API routes at request time. - - :func:`_mount_plugin_api_routes` gates at import time, but if a plugin - is disabled *after* the dashboard is already running, its FastAPI router - remains mounted until restart. This middleware enforces the enabled/ - disabled policy on every request to ``/api/plugins/{name}/...`` so that - runtime config changes take effect immediately. - """ - path = request.url.path - if path.startswith("/api/plugins/"): - # Extract plugin name from /api/plugins//... - parts = path.split("/") - # parts: ['', 'api', 'plugins', '', ...] - if len(parts) >= 4: - plugin_name = parts[3] - if plugin_name: - try: - from hermes_cli.plugins_cmd import ( - _get_enabled_set, - _get_disabled_set, - ) - enabled_set = _get_enabled_set() - disabled_set = _get_disabled_set() - except Exception: - enabled_set = set() - disabled_set = set() - # Determine plugin source. Check the cached plugin list; - # if not found, assume user plugin (safe default — blocks). - plugins = _get_dashboard_plugins() - plugin = next( - (p for p in plugins if p.get("name") == plugin_name), - None, - ) - source = plugin.get("source") if plugin else "user" - if source == "user": - if plugin_name in disabled_set or plugin_name not in enabled_set: - return JSONResponse( - status_code=404, - content={"detail": "Plugin not found"}, - ) - elif source == "bundled": - if plugin_name in disabled_set: - return JSONResponse( - status_code=404, - content={"detail": "Plugin not found"}, - ) - return await call_next(request) - - @app.middleware("http") async def _token_auth_seam(request: Request, call_next): """Outermost auth seam: non-interactive bearer-token auth for opted-in routes. diff --git a/tests/hermes_cli/test_plugin_runtime_disable_gate.py b/tests/hermes_cli/test_plugin_runtime_disable_gate.py index 6889f5ddfd1..c466d186ed7 100644 --- a/tests/hermes_cli/test_plugin_runtime_disable_gate.py +++ b/tests/hermes_cli/test_plugin_runtime_disable_gate.py @@ -112,6 +112,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/hot/probe", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -142,6 +143,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/hot/probe", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -172,6 +174,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/hot/probe", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -203,6 +206,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/bundledx/probe", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -233,6 +237,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/bundledx/probe", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -259,6 +264,7 @@ class TestPluginApiRuntimeGate: "path": "/api/status", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) @@ -283,6 +289,7 @@ class TestPluginApiRuntimeGate: "path": "/api/plugins/unknown/action", "query_string": b"", "headers": [], + "state": {"token_authenticated": True}, } request = Request(scope) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b2caf95291..5d68361bcfe 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -66,6 +66,24 @@ def _install_example_plugin(_isolate_hermes_home): shutil.rmtree(dst) shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst) + # The dashboard now gates user-plugin asset serving + backend import + # behind the ``plugins.enabled`` allow-list (GHSA-mcfc-hp25-cjv7). + # An installed-but-not-enabled user plugin has its API mount skipped + # and its assets 404'd — which is the whole point of the gate. These + # fixtures exist to exercise the *serving* paths, so opt the example + # plugin in exactly as a real operator would with `hermes plugins + # enable example`. + from hermes_cli.config import load_config, save_config + _cfg = load_config() + _plugins_cfg = _cfg.setdefault("plugins", {}) + _enabled = _plugins_cfg.get("enabled") + if not isinstance(_enabled, list): + _enabled = [] + if "example" not in _enabled: + _enabled.append("example") + _plugins_cfg["enabled"] = _enabled + save_config(_cfg) + # Snapshot the existing routes BEFORE mounting so we can: # 1. Identify the routes the mount call appends. # 2. Restore the original list on teardown — otherwise leftover