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:
Teknium 2026-05-25 15:07:19 -07:00 committed by GitHub
parent 27df4b3882
commit 30928f945f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 305 additions and 2 deletions

View file

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

View file

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