mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +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
|
|
@ -4,6 +4,7 @@ import os
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import (
|
||||
|
|
@ -775,3 +776,120 @@ class TestUserMessagePreviewConfig:
|
|||
preview = DEFAULT_CONFIG["display"]["user_message_preview"]
|
||||
assert preview["first_lines"] == 2
|
||||
assert preview["last_lines"] == 2
|
||||
|
||||
|
||||
class TestEnvWriteDenylist:
|
||||
"""``save_env_value`` refuses to persist env-var names that
|
||||
influence how subprocesses execute — ``LD_PRELOAD``, ``PYTHONPATH``,
|
||||
``PATH``, ``EDITOR``, etc. — or any ``HERMES_*`` runtime flag.
|
||||
|
||||
The dashboard exposes ``PUT /api/env`` to any authed caller (and
|
||||
the session token lives in the SPA's HTML where any future plugin
|
||||
XSS or local process could exfiltrate it). Without this gate, an
|
||||
attacker who steals the token could plant
|
||||
``LD_PRELOAD=/tmp/evil.so`` in ``.env`` and own the next Hermes
|
||||
process on next startup via the dotenv → ``os.environ`` chain in
|
||||
``hermes_cli/env_loader.py``.
|
||||
|
||||
Regression test for the dashboard pentest finding filed alongside
|
||||
the ``web-pentest`` skill (PR #32265 / issue #32267).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermes_home(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
ensure_hermes_home()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"denied_key",
|
||||
[
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONSTARTUP",
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"EDITOR",
|
||||
"VISUAL",
|
||||
"PAGER",
|
||||
"BROWSER",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_EXEC_PATH",
|
||||
"HERMES_HOME",
|
||||
"HERMES_PROFILE",
|
||||
"HERMES_CONFIG",
|
||||
"HERMES_ENV",
|
||||
],
|
||||
)
|
||||
def test_denylisted_keys_rejected(self, denied_key):
|
||||
"""Each denylisted name raises ``ValueError`` and never reaches
|
||||
the on-disk ``.env`` file."""
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value(denied_key, "anything")
|
||||
|
||||
# And nothing landed on disk either.
|
||||
env = load_env()
|
||||
assert denied_key not in env
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_key",
|
||||
[
|
||||
"HERMES_GEMINI_CLIENT_ID",
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY",
|
||||
"HERMES_SPOTIFY_CLIENT_ID",
|
||||
"HERMES_QWEN_BASE_URL",
|
||||
"HERMES_MAX_ITERATIONS",
|
||||
],
|
||||
)
|
||||
def test_hermes_integration_keys_still_writable(self, allowed_key):
|
||||
"""``HERMES_*`` overall is NOT blocked — only the four runtime
|
||||
location names (HOME/PROFILE/CONFIG/ENV) are. Integration
|
||||
credentials following the ``HERMES_*`` convention must keep
|
||||
working or we'd regress every provider setup wizard that
|
||||
currently writes one of these (auth.py, Spotify, Langfuse, …)."""
|
||||
save_env_value(allowed_key, "test-value-123")
|
||||
env = load_env()
|
||||
assert env[allowed_key] == "test-value-123"
|
||||
|
||||
def test_legitimate_provider_key_still_works(self):
|
||||
"""The denylist must not regress on real provider key writes."""
|
||||
save_env_value("OPENROUTER_API_KEY", "sk-or-test-1234")
|
||||
env = load_env()
|
||||
assert env["OPENROUTER_API_KEY"] == "sk-or-test-1234"
|
||||
|
||||
def test_arbitrary_user_key_still_works(self):
|
||||
"""Plugin / user-defined env vars (anything outside the
|
||||
denylist and outside ``HERMES_*``) keep working. The denylist
|
||||
is narrow on purpose."""
|
||||
save_env_value("MY_PLUGIN_TOKEN", "plugin-secret-123")
|
||||
env = load_env()
|
||||
assert env["MY_PLUGIN_TOKEN"] == "plugin-secret-123"
|
||||
|
||||
def test_save_env_value_secure_inherits_denylist(self):
|
||||
"""The ``_secure`` variant goes through ``save_env_value`` so
|
||||
it inherits the gate — verify, don't assume."""
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value_secure("LD_PRELOAD", "/tmp/evil.so")
|
||||
|
||||
def test_pre_existing_value_in_env_file_is_left_alone(self, tmp_path):
|
||||
"""The gate is on *write*. If ``.env`` already contains
|
||||
``LD_PRELOAD`` (set out-of-band by the operator before this
|
||||
change shipped, or hand-edited), we don't blow up — we just
|
||||
refuse to add or update it via the API."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("LD_PRELOAD=/something/legit.so\n")
|
||||
|
||||
# load_env returns it (the read path is intentionally permissive)
|
||||
env = load_env()
|
||||
assert env["LD_PRELOAD"] == "/something/legit.so"
|
||||
|
||||
# But the write path still refuses to update it
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value("LD_PRELOAD", "/tmp/evil.so")
|
||||
|
||||
|
|
|
|||
|
|
@ -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