hermes - 💡(How to fix) Fix Dashboard hardening: auth gate on /dashboard-plugins/ + writable env-key allowlist [1 pull requests]

Official PRs (…)
ON THIS PAGE

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

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.

Root Cause

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.

Fix Action

Fixed

Code Example

$ 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():
    ...

---

$ 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
RAW_BUFFERClick to expand / collapse

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:4540serve_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:1221set_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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix Dashboard hardening: auth gate on /dashboard-plugins/ + writable env-key allowlist [1 pull requests]