fix(dashboard): run plugin gate after auth + enable example fixture

Follow-up on the salvaged #47491 commits:

- Register _plugin_api_runtime_gate BEFORE the auth middlewares so it
  executes AFTER them, and add an explicit auth check: unauthenticated
  requests to /api/plugins/<name>/ fall through to auth's 401 instead of
  this gate's 404. Prevents the gate from becoming a plugin-name oracle
  (an unauthenticated caller could otherwise fingerprint installed/enabled
  plugins by status code). Keeps test_non_kanban_plugin_route_requires_auth
  green.
- Enable the 'example' user plugin in the _install_example_plugin test
  fixture so the auth / static-asset-allowlist tests still reach the real
  serving paths now that user plugins are gated on plugins.enabled.
- Mark the runtime-gate unit-test scopes as authenticated so they exercise
  the enabled/disabled policy under the new auth-first ordering.
This commit is contained in:
Teknium 2026-07-01 03:36:31 -07:00
parent b2e0086f1b
commit 81595cd588
3 changed files with 92 additions and 51 deletions

View file

@ -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/<name>/...
parts = path.split("/")
# parts: ['', 'api', 'plugins', '<name>', ...]
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/<name>/...
parts = path.split("/")
# parts: ['', 'api', 'plugins', '<name>', ...]
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.

View file

@ -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)

View file

@ -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