mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
b2e0086f1b
commit
81595cd588
3 changed files with 92 additions and 51 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue