Summary
Self-pentest of the dashboard (driven by the new web-pentest skill in #32265) found two posture issues worth fixing. Both are authed-or-localhost-only — no unauthenticated RCE on the loopback model. Filing as a single hardening issue rather than separate PRs because both are small, related (input validation / allowlist tightening), and benefit from being reviewed together.
Engagement
- Target:
hermes_cli.web_server bound to 127.0.0.1:9119
- Method: web-pentest skill — recon (read-only) → vuln analysis → proof-based exploitation
- Scope: localhost only, dashboard process only
Existing defenses verified working (no regressions): session-token auth on /api/*, Host-header validation against DNS rebinding (GHSA-ppp5-vxwm-4cf7), CORS regex restricted to loopback origins, YAML safe_load rejecting !!python/object/* tags, profile name regex blocking shell metacharacters in /api/profiles/.../open-terminal, plugin-name ../\\ rejection, WebSocket auth (/api/pty, /api/ws, /api/pub, /api/events), reveal-endpoint rate limiting, _normalise_prefix rejecting injection in X-Forwarded-Prefix, static asset traversal blocked via Path.resolve().is_relative_to().
Finding 1 — /dashboard-plugins/{plugin_name}/{file_path:path} is unauthenticated
Severity: Low (information disclosure, localhost-only)
Location: hermes_cli/web_server.py:4540 — serve_plugin_asset()
Evidence:
$ curl -sS http://127.0.0.1:9119/dashboard-plugins/example/manifest.json
{
"name": "example",
"label": "Example",
...
"api": "plugin_api.py"
}
$ curl -sS http://127.0.0.1:9119/dashboard-plugins/example/plugin_api.py
"""Example dashboard plugin — backend API routes.
...
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
...
Why this is a finding: The route is under /dashboard-plugins/, not /api/, so the auth_middleware (which gates on path.startswith("/api/")) doesn't apply. The path-traversal check inside the handler is correct, but the endpoint itself doesn't require the session token. For bundled plugins this just leaks public source. For user-installed third-party plugins (~/.hermes/plugins/<x>/dashboard/), this exposes the Python source of the plugin's frontend manifest + backend API file to anything on localhost — including other users on a shared dev box, processes in unrelated containers sharing the host loopback, etc.
Fix direction: Add _require_token(request) to serve_plugin_asset(). The SPA always carries the token; this doesn't break dashboard functionality.
Finding 2 — PUT /api/env accepts arbitrary env-var names (not restricted to OPTIONAL_ENV_VARS)
Severity: Low-Medium (authed local escalation, weakens defense-in-depth)
Location: hermes_cli/web_server.py:1221 — set_env_var() calls save_env_value() which only validates against _ENV_VAR_NAME_RE (POSIX env-var shape).
Evidence:
$ TOKEN=$(grep -oE '__HERMES_SESSION_TOKEN__="[^"]+"' index.html | cut -d'"' -f2)
$ curl -X PUT -H "X-Hermes-Session-Token: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"key": "LD_PRELOAD", "value": "/tmp/evil.so"}' \
http://127.0.0.1:9119/api/env
{"ok":true,"key":"LD_PRELOAD"}
$ cat ~/.hermes/.env
LD_PRELOAD=/tmp/evil.so
Why this is a finding: ~/.hermes/.env is loaded by env_loader.load_hermes_dotenv() on every Hermes process start with override=True, populating os.environ for the parent process and all subprocesses. Writing LD_PRELOAD, PYTHONPATH, or PATH via the dashboard plants an authed local-RCE foothold that activates on the next hermes ... invocation. Token-required, so it's a defense-in-depth gap — but the dashboard token lives in window.__HERMES_SESSION_TOKEN__ in the SPA's HTML, which makes it exfilable via any future plugin XSS or via any local process that can GET the dashboard.
Fix direction: Enforce a writable-key allowlist. Either:
(a) Restrict writes to keys in OPTIONAL_ENV_VARS only (loses provider-add UX for unknown providers), or
(b) Regex-bound [A-Z_][A-Z0-9_]* AND maintain a deny set: {PATH, LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, PYTHONHOME, DYLD_*, NODE_OPTIONS, GIT_SSH_COMMAND, GIT_EXEC_PATH, BROWSER, EDITOR, PAGER} plus anything else that gets exec'd. Option (b) is more permissive but covers the realistic abuse cases.
I'd ship (b). Implementation belongs in save_env_value() (centralized — also protects the CLI hermes env set path), not in the web handler.
Items considered, not landing
- Session token in SPA HTML body. This is architectural — the SPA needs the token. The defense relies on loopback bind + Host-header validation + CORS regex. Not a finding; noted for awareness.
open-terminal shell escape. Profile name regex ([a-z0-9][a-z0-9_-]{0,63}) blocks all shell metacharacters. Verified.
- YAML object injection via
/api/config/raw. safe_load rejects !!python/object/*. Verified.
- Path traversal in static asset routes.
Path.resolve().is_relative_to(WEB_DIST) catches ../, encoded variants, and absolute paths. Verified.
- Host-header bypass via DNS rebinding.
host_header_middleware catches it. Verified.
/api/config auth bypass via path tricks (//api/config, /API/config). Initial 200 looked like a bypass but content-type analysis showed it's the SPA catchall route — server returns index.html (text/html), not the config JSON. Auth boundary intact.
X-Forwarded-Prefix HTML injection. _normalise_prefix rejects anything with quotes, .., control chars, or length > 64. Verified.
- OAuth provider write endpoints. Each goes through path validation + provider-id allowlist. Out-of-scope hosts in the OAuth callback URLs would be a separate review.
Suggested PR shape
One PR titled "fix(dashboard): require auth on plugin assets + restrict env-write key namespace" with both fixes. Mention the engagement (no impact on existing tests). Add a regression test for each: (1) /dashboard-plugins/example/manifest.json returns 401 without token; (2) PUT /api/env with key=LD_PRELOAD returns 400.
I can do this PR — wanted to surface findings first so you (and anyone watching this issue) can sanity-check the threat model.
Summary
Self-pentest of the dashboard (driven by the new
web-pentestskill in #32265) found two posture issues worth fixing. Both are authed-or-localhost-only — no unauthenticated RCE on the loopback model. Filing as a single hardening issue rather than separate PRs because both are small, related (input validation / allowlist tightening), and benefit from being reviewed together.Engagement
hermes_cli.web_serverbound to127.0.0.1:9119Existing defenses verified working (no regressions): session-token auth on
/api/*, Host-header validation against DNS rebinding (GHSA-ppp5-vxwm-4cf7), CORS regex restricted to loopback origins, YAMLsafe_loadrejecting!!python/object/*tags, profile name regex blocking shell metacharacters in/api/profiles/.../open-terminal, plugin-name../\\rejection, WebSocket auth (/api/pty,/api/ws,/api/pub,/api/events), reveal-endpoint rate limiting,_normalise_prefixrejecting injection inX-Forwarded-Prefix, static asset traversal blocked viaPath.resolve().is_relative_to().Finding 1 —
/dashboard-plugins/{plugin_name}/{file_path:path}is unauthenticatedSeverity: Low (information disclosure, localhost-only)
Location:
hermes_cli/web_server.py:4540—serve_plugin_asset()Evidence:
$ curl -sS http://127.0.0.1:9119/dashboard-plugins/example/manifest.json { "name": "example", "label": "Example", ... "api": "plugin_api.py" } $ curl -sS http://127.0.0.1:9119/dashboard-plugins/example/plugin_api.py """Example dashboard plugin — backend API routes. ... from fastapi import APIRouter router = APIRouter() @router.get("/hello") async def hello(): ...Why this is a finding: The route is under
/dashboard-plugins/, not/api/, so theauth_middleware(which gates onpath.startswith("/api/")) doesn't apply. The path-traversal check inside the handler is correct, but the endpoint itself doesn't require the session token. For bundled plugins this just leaks public source. For user-installed third-party plugins (~/.hermes/plugins/<x>/dashboard/), this exposes the Python source of the plugin's frontend manifest + backend API file to anything on localhost — including other users on a shared dev box, processes in unrelated containers sharing the host loopback, etc.Fix direction: Add
_require_token(request)toserve_plugin_asset(). The SPA always carries the token; this doesn't break dashboard functionality.Finding 2 —
PUT /api/envaccepts arbitrary env-var names (not restricted toOPTIONAL_ENV_VARS)Severity: Low-Medium (authed local escalation, weakens defense-in-depth)
Location:
hermes_cli/web_server.py:1221—set_env_var()callssave_env_value()which only validates against_ENV_VAR_NAME_RE(POSIX env-var shape).Evidence:
Why this is a finding:
~/.hermes/.envis loaded byenv_loader.load_hermes_dotenv()on every Hermes process start withoverride=True, populatingos.environfor the parent process and all subprocesses. WritingLD_PRELOAD,PYTHONPATH, orPATHvia the dashboard plants an authed local-RCE foothold that activates on the nexthermes ...invocation. Token-required, so it's a defense-in-depth gap — but the dashboard token lives inwindow.__HERMES_SESSION_TOKEN__in the SPA's HTML, which makes it exfilable via any future plugin XSS or via any local process that can GET the dashboard.Fix direction: Enforce a writable-key allowlist. Either:
(a) Restrict writes to keys in
OPTIONAL_ENV_VARSonly (loses provider-add UX for unknown providers), or(b) Regex-bound
[A-Z_][A-Z0-9_]*AND maintain a deny set:{PATH, LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, PYTHONHOME, DYLD_*, NODE_OPTIONS, GIT_SSH_COMMAND, GIT_EXEC_PATH, BROWSER, EDITOR, PAGER}plus anything else that gets exec'd. Option (b) is more permissive but covers the realistic abuse cases.I'd ship (b). Implementation belongs in
save_env_value()(centralized — also protects the CLIhermes env setpath), not in the web handler.Items considered, not landing
open-terminalshell escape. Profile name regex ([a-z0-9][a-z0-9_-]{0,63}) blocks all shell metacharacters. Verified./api/config/raw.safe_loadrejects!!python/object/*. Verified.Path.resolve().is_relative_to(WEB_DIST)catches../, encoded variants, and absolute paths. Verified.host_header_middlewarecatches it. Verified./api/configauth bypass via path tricks (//api/config,/API/config). Initial 200 looked like a bypass but content-type analysis showed it's the SPA catchall route — server returnsindex.html(text/html), not the config JSON. Auth boundary intact.X-Forwarded-PrefixHTML injection._normalise_prefixrejects anything with quotes,.., control chars, or length > 64. Verified.Suggested PR shape
One PR titled "fix(dashboard): require auth on plugin assets + restrict env-write key namespace" with both fixes. Mention the engagement (no impact on existing tests). Add a regression test for each: (1)
/dashboard-plugins/example/manifest.jsonreturns 401 without token; (2)PUT /api/envwithkey=LD_PRELOADreturns 400.I can do this PR — wanted to surface findings first so you (and anyone watching this issue) can sanity-check the threat model.