mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-06 07:51:53 +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
|
|
@ -74,6 +74,82 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
|
|||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Env var names that influence how the next subprocess executes —
|
||||
# never writable through ``save_env_value``. Anything that controls
|
||||
# the loader, interpreter, shell, or replacement editor counts:
|
||||
#
|
||||
# * ``LD_PRELOAD`` / ``LD_LIBRARY_PATH`` / ``LD_AUDIT`` — Linux dynamic
|
||||
# loader. ``DYLD_*`` — macOS equivalent. Planting a path here means
|
||||
# the next ``subprocess.run([...])`` Hermes makes loads attacker code
|
||||
# before main().
|
||||
# * ``PYTHONPATH`` / ``PYTHONHOME`` / ``PYTHONSTARTUP`` /
|
||||
# ``PYTHONUSERBASE`` — Python interpreter init. Hermes itself starts
|
||||
# from one of these on every restart.
|
||||
# * ``NODE_OPTIONS`` / ``NODE_PATH`` — Node interpreter; affects npm,
|
||||
# ``hermes update``, the TUI build.
|
||||
# * ``PATH`` — too broad to allow. The dashboard never needs to rewrite
|
||||
# the operator's PATH; if a tool can't be found, the fix is to add an
|
||||
# absolute path in the integration config, not to mutate PATH globally.
|
||||
# * ``GIT_SSH_COMMAND`` / ``GIT_EXEC_PATH`` — git rewrites that fire
|
||||
# on every plugin install / ``hermes update``.
|
||||
# * ``BROWSER`` / ``EDITOR`` / ``VISUAL`` / ``PAGER`` — commands the
|
||||
# shell or CLI invokes implicitly. Wrong values here = RCE on next
|
||||
# ``$EDITOR``.
|
||||
# * ``SHELL`` — what subprocess uses with ``shell=True`` (we try to
|
||||
# avoid that, but defense in depth).
|
||||
# * ``HERMES_HOME`` / ``HERMES_PROFILE`` / ``HERMES_CONFIG`` /
|
||||
# ``HERMES_ENV`` — Hermes runtime location flags. Writing these into
|
||||
# ``.env`` would relocate state in ways the user did not request from
|
||||
# the dashboard. ``config.yaml`` is the supported surface for these.
|
||||
#
|
||||
# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate
|
||||
# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,
|
||||
# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The
|
||||
# denylist is name-by-name on purpose so the gate stays narrow and
|
||||
# doesn't accidentally break provider setup wizards.
|
||||
#
|
||||
# This is enforced on *write* only — values already in ``.env`` (set
|
||||
# by the operator out-of-band, or pre-existing) keep working. The
|
||||
# point is that the dashboard's writable surface cannot escalate by
|
||||
# planting them.
|
||||
_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
|
||||
# Loader / linker
|
||||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG",
|
||||
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
|
||||
"DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH",
|
||||
# Python
|
||||
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
|
||||
"PYTHONEXECUTABLE", "PYTHONNOUSERSITE",
|
||||
# Node
|
||||
"NODE_OPTIONS", "NODE_PATH",
|
||||
# General
|
||||
"PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER",
|
||||
# Git
|
||||
"GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL",
|
||||
# Hermes runtime location — never via dashboard env writer.
|
||||
# NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*,
|
||||
# HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed.
|
||||
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
|
||||
})
|
||||
|
||||
|
||||
def _reject_denylisted_env_var(key: str) -> None:
|
||||
"""Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`.
|
||||
|
||||
Centralised so both the regular and "secure" env writers share the
|
||||
same gate, and so the message is consistent for callers.
|
||||
"""
|
||||
if key in _ENV_VAR_NAME_DENYLIST:
|
||||
raise ValueError(
|
||||
f"Environment variable {key!r} is on the writer denylist. "
|
||||
"Names that influence subprocess execution (LD_PRELOAD, "
|
||||
"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location "
|
||||
"(HERMES_HOME, HERMES_PROFILE, ...) cannot be persisted via "
|
||||
"the env writer. If you really need this, edit "
|
||||
"~/.hermes/.env directly."
|
||||
)
|
||||
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
|
||||
# (path, mtime_ns, size) -> cached expanded config dict.
|
||||
# load_config() returns a deepcopy of the cached value when the file
|
||||
|
|
@ -4874,6 +4950,7 @@ def save_env_value(key: str, value: str):
|
|||
return
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
_reject_denylisted_env_var(key)
|
||||
value = value.replace("\n", "").replace("\r", "")
|
||||
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
|
||||
value = _check_non_ascii_credential(key, value)
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,12 @@ async def set_env_var(body: EnvVarUpdate):
|
|||
try:
|
||||
save_env_value(body.key, body.value)
|
||||
return {"ok": True, "key": body.key}
|
||||
except ValueError as exc:
|
||||
# save_env_value raises ValueError for invalid names and for keys
|
||||
# on the denylist (LD_PRELOAD, PATH, PYTHONPATH, …). Surface the
|
||||
# message to the SPA so the user understands why the write was
|
||||
# refused instead of seeing an opaque 500.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
_log.exception("PUT /api/env failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -4543,6 +4549,17 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
|
|||
|
||||
Only serves files from the plugin's ``dashboard/`` subdirectory.
|
||||
Path traversal is blocked by checking ``resolve().is_relative_to()``.
|
||||
|
||||
Restricted to a browser-fetchable suffix allowlist (JS/CSS/JSON/HTML/
|
||||
SVG/PNG/JPG/WOFF). The dashboard loads plugin JS via ``<script src>``
|
||||
and CSS via ``<link href>``, neither of which can attach a custom
|
||||
auth header — so this route stays unauthenticated to keep the SPA
|
||||
working. But user-installed plugins ship a ``plugin_api.py``
|
||||
backend module that the browser never fetches; it's only imported
|
||||
by :func:`_mount_plugin_api_routes` at startup. Without a suffix
|
||||
allowlist, anyone on the loopback port can curl the ``.py`` source
|
||||
of a private third-party plugin. Reject everything outside the
|
||||
browser-asset set.
|
||||
"""
|
||||
plugins = _get_dashboard_plugins()
|
||||
plugin = next((p for p in plugins if p["name"] == plugin_name), None)
|
||||
|
|
@ -4557,7 +4574,11 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
|
|||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Guess content type
|
||||
# Browser-asset suffix allowlist. Everything outside this set is
|
||||
# rejected with 404 so we don't leak ``.py`` backend sources, README
|
||||
# files, ``.env.example`` templates, etc. — none of which the SPA
|
||||
# actually fetches. Add to this set deliberately when a new asset
|
||||
# type comes up; do NOT change the default fallback.
|
||||
suffix = target.suffix.lower()
|
||||
content_types = {
|
||||
".js": "application/javascript",
|
||||
|
|
@ -4568,10 +4589,22 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
|
|||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".map": "application/json",
|
||||
}
|
||||
media_type = content_types.get(suffix, "application/octet-stream")
|
||||
if suffix not in content_types:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="File not found",
|
||||
)
|
||||
media_type = content_types[suffix]
|
||||
return FileResponse(
|
||||
target,
|
||||
media_type=media_type,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue