mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix(dashboard): suffix-allowlist plugin assets + denylist subprocess-influencing env vars (#32277)
Two posture fixes surfaced by the web-pentest skill self-test against the dashboard (issue #32267). 1. /dashboard-plugins/<name>/<path> previously returned 200 for any file inside the plugin's dashboard directory — including plugin_api.py and __pycache__/*.pyc. The path is unauthenticated by architecture (SPA loads JS via <script src> and CSS via <link href>, neither of which can attach a custom auth header), so the fix is not "require token" — it's "restrict to browser-fetchable suffixes." Allowlist now: .js .mjs .css .json .html .svg .png .jpg .jpeg .gif .webp .ico .woff .woff2 .ttf .otf .map. Everything else → 404. This stops a private user-installed plugin's Python source from being readable by anyone reachable on the dashboard's loopback port (other local users on a shared box, sidecar containers sharing the host netns). 2. save_env_value() now refuses to persist env-var names that influence how the next subprocess executes: LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_*, PYTHONPATH, PYTHONHOME, PYTHONSTARTUP, NODE_OPTIONS, NODE_PATH, PATH, SHELL, EDITOR, VISUAL, PAGER, BROWSER, GIT_SSH_COMMAND, GIT_EXEC_PATH; plus HERMES_HOME / HERMES_PROFILE / HERMES_CONFIG / HERMES_ENV. PUT /api/env is authed but the session token lives in the SPA HTML where any future plugin XSS or local process can read it. Without this gate, a token-holder could plant LD_PRELOAD in .env and the next hermes process start would load attacker code via the dotenv to os.environ chain. This is enforced on write only — pre-existing .env values are left alone (the gate is in save_env_value, not in load_env). PUT /api/env now returns 400 with the explanatory message instead of an opaque 500. IMPORTANT: HERMES_* overall is NOT blocked — only the four runtime location names. Integration credentials following the HERMES_* convention (HERMES_GEMINI_*, HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, HERMES_QWEN_BASE_URL, ...) keep working. Regression tests cover both fixes (30 new test cases). No existing tests changed; 257 passing in tests/hermes_cli/. Closes #32267.
This commit is contained in:
parent
27df4b3882
commit
30928f945f
4 changed files with 305 additions and 2 deletions
|
|
@ -2375,3 +2375,78 @@ class TestPtyWebSocket:
|
|||
):
|
||||
pass
|
||||
assert exc.value.code == 4400
|
||||
|
||||
|
||||
class TestDashboardPluginStaticAssetAllowlist:
|
||||
"""``/dashboard-plugins/<name>/<path>`` is unauthenticated by design —
|
||||
the SPA loads plugin JS via ``<script src>`` and CSS via
|
||||
``<link href>``, neither of which can attach a custom auth header.
|
||||
Instead the route restricts file types to the browser-asset
|
||||
allowlist (JS/CSS/JSON/images/fonts) so that user-installed
|
||||
plugins shipping a ``plugin_api.py`` backend module don't leak
|
||||
their Python source to anyone reachable on the loopback port.
|
||||
|
||||
Regression test for the dashboard pentest finding filed alongside
|
||||
the ``web-pentest`` skill (PR #32265 / issue #32267).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_python_source_is_404(self):
|
||||
"""The example plugin's ``plugin_api.py`` must NOT be served as
|
||||
a static asset, even though the file exists under the plugin's
|
||||
dashboard directory. Suffix not in the allowlist → 404."""
|
||||
resp = self.client.get("/dashboard-plugins/example/plugin_api.py")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_pycache_is_404(self):
|
||||
"""Same protection for compiled Python (``.pyc``) inside the
|
||||
plugin's ``__pycache__/``. Real plugins ship these as a
|
||||
side-effect of running tests / dashboard once."""
|
||||
# __pycache__ files are only generated after the api file has
|
||||
# been imported once. Use the path the example plugin actually
|
||||
# generates during the dashboard test boot.
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/example/__pycache__/plugin_api.cpython-311.pyc"
|
||||
)
|
||||
# 404 either way (file may not exist on this CI Python version);
|
||||
# what matters is we never get a 200 with the bytes.
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_manifest_json_still_served(self):
|
||||
"""JSON files remain browser-fetchable — manifests, localized
|
||||
data, source maps, etc. all sit in this bucket."""
|
||||
resp = self.client.get("/dashboard-plugins/example/manifest.json")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/json")
|
||||
# And the body is actually the manifest, not the SPA fallback.
|
||||
body = resp.json()
|
||||
assert body.get("name") == "example"
|
||||
|
||||
def test_unknown_plugin_is_404(self):
|
||||
"""Existing behaviour preserved: nonexistent plugin name → 404."""
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/_definitely_not_a_plugin_/manifest.json"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_path_traversal_still_blocked(self):
|
||||
"""The allowlist is on top of the existing ``.resolve()`` /
|
||||
``is_relative_to()`` check — a ``.js`` named file at an
|
||||
out-of-base path is still rejected as traversal, not served."""
|
||||
resp = self.client.get(
|
||||
"/dashboard-plugins/example/..%2Fplugin_api.py"
|
||||
)
|
||||
# 403 traversal-blocked OR 404 (depending on URL decode order)
|
||||
# — never 200.
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue