NousResearch/hermes-agent

Require dashboard auth for plugin API routes

Cluster 19533. Data as of

Nothing open on GitHub. Fix merged in #23220 on 10 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #23220. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #23220. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple related fixes enforcing dashboard authentication on plugin API routes. The members appear to be iterations or salvage attempts of the same security patch across different plugin contexts.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23220 fix(security): require dashboard auth for plugin API routes (salvage #19541)

    merged to main

    files: hermes_cli/web_server.py, plugins/kanban/dashboard/plugin_api.py, tests/hermes_cli/test_web_server.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -225,7 +225,7 @@ async def host_header_middleware(request: Request, call_next):
       async def auth_middleware(request: Request, call_next):
           """Require the session token on all /api/ routes except the public list."""
           path = request.url.path
      -    if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"):
      +    if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
               if not _has_valid_session_token(request):
                   return JSONResponse(
                       status_code=401,
    • plugins/kanban/dashboard/plugin_api.py
      @@ -13,15 +13,24 @@
       
       Security note
       -------------
      -The dashboard's HTTP auth middleware (``web_server.auth_middleware``)
      -explicitly skips ``/api/plugins/`` — plugin routes are unauthenticated by
      -design because the dashboard binds to localhost by default. For the
      -WebSocket we still require the session token as a ``?token=`` query
      -parameter (browsers cannot set the ``Authorization`` header on an upgrade
      -request), matching the established pattern used by the in-browser PTY
      -bridge in ``hermes_cli/web_server.py``. If you run the dashboard with
      -``--host 0.0.0.0``, every plugin route — kanban included — becomes
      -reachable from the network. Don't do that on a shared host.
      +Plugin HTTP routes go through the dashboard's session-token auth middleware
      +(``web_server.auth_middleware``) just like core API routes — every
      +``/api/plugins/...`` request must present the session bearer token (or the
      +session cookie set when you load the dashboard HTML). The token is the
      +random per-process ``_SESSION_TOKEN`` printed at startup; the dashboard's
      +own pages inject it via ``window.__HERMES_SESSION_TOKEN__`` so logged-in
      +browsers don't have to handle it manually.
      +
      +For the ``/events`` WebSocket we still require the session token as a
      +``?token=`` query parameter (browsers cannot set the ``Authorization``
      +header on an upgrade request), matching the established pattern used by
      +the in-browser PTY bridge in ``hermes_cli/web_server.py``.
      +
      +This means ``hermes dashboard --host 0.0.0.0`` is safe to run on a LAN:
      +plugin routes are no longer an unauthenticated exception. The auth still
      +isn't multi-user — anyone who can read the printed URL+token gets full
      +dashboard access — but they can't ride along just because they can reach
      +the port.
       """
       
       from __future__ import annotations
    • tests/hermes_cli/test_web_server.py
      @@ -1826,6 +1826,117 @@ def test_component_styles_accepts_numeric_values(self):
               assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
       
       
      +class TestPluginAPIAuth:
      +    """Tests that plugin API routes require the session token (issue #19533)."""
      +
      +    @pytest.fixture(autouse=True)
      +    def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
      +        """Create a TestClient without the session token header."""
      +        try:
      +            from starlette.testclient import TestClient
      +        except ImportError:
      +            pytest.skip("fastapi/starlette not installed")
      +
      +        import hermes_state
      +        from hermes_constants import get_hermes_home
      +        from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
      +
      +        monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
      +
      +        self.client = TestClient(app)
      +        self.auth_client = TestClient(app)
      +        self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
      +
      +    def test_plugin_route_requires_auth(self):
      +        """Plugin API routes should return 401 without a valid session token."""
      +        # Use a known plugin route (kanban board)
      +        resp = self.client.get("/api/plugins/kanban/board")
      +        assert resp.status_code == 401
      +
      +    def test_plugin_route_allows_auth(self):
      +        """Plugin API routes should work with a valid session token.
      +
      +        Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
      +        a stable, side-effect-free GET that's always loaded in tests. With a
      +        valid token the handler should run (200); without one the middleware
      +        should 401 before the handler is reached.
      +        """
      +        # Without auth: middleware blocks before reaching the handler.
      +        resp = self.client.get("/api/plugins/example/hello")
      +        assert resp.status_code == 401
      +
      +        # With auth: handler runs.
      +        resp = self.auth_client.get("/api/plugins/example/hello")
      +        assert resp.status_code == 200
      +
      +    def test_plugin_post_requires_auth(self):
      +        """Plugin POST routes should return 401 without a valid session token."""
      +        resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
      +        assert resp.status_code == 401
      +
      +    def test_plugin_patch_requires_auth(self):
      +        """Plugin PATCH routes should return 401 without a valid session token.
      +
      +        PATCH is the mutation method most commonly used by the dashboard for
      +        kanban task edits — explicitly cover it so a future middleware
      +        regression that whitelists non-GET methods can't sneak through.
      +        """
      +        resp = self.client.patch(
      +            "/api/plugins/kanban/tasks/t_fake",
      +            json={"title": "renamed"},
      +        )
      +        assert resp.status_code == 401
      +
      +    def test_plugin_delete_requires_auth(self):
      +        """Plugin DELETE routes should return 401 without a valid session token."""
      +        resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
      +        assert resp.status_code == 401
      +
      +    def test_non_kanban_plugin_route_requires_auth(self):
      +        """Auth must be plugin-agnostic, not kanban-specific.
      +
      +        The middleware fix is at the gate level (no per-plugin allowlist),
      +        so any plugin's API surface — kanban, hermes-achievements, future
      +        plugins — must require the session token. Hit a non-kanban plugin
      +        path to lock that in.
      +        """
      +        # Real plugin path (hermes-achievements is loaded by default).
      +        resp = self.client.get("/api/plugins/hermes-achievements/overview")
      +        assert resp.status_code == 401
      +        # Same for an arbitrary plugin namespace that doesn't even exist —
      +        # the middleware should 401 before routing decides 404, so an
      +        # attacker can't fingerprint plugin names by status codes.
      +        resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
      +        assert resp.status_code == 401
      +
      +    def test_plugin_websocket_unaffected_by_http_middleware(self):
      +        """The kanban /events WebSocket has its own ``?token=`` check;
      +        the HTTP middleware change must not start gating WS upgrades.
      +
      +        Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
      +        but pin the behavior so a future refactor that moves auth into a
      +        shared layer can't silently break the WS auth contract.
      +        """
      +        from starlette.websockets import WebSocketDisconnect
      +        from hermes_cli.web_server import _SESSION_TOKEN
      +
      +        # Without a token the WS endpoint must close the upgrade itself
      +        # (its own _check_ws_token), NOT 401 from the HTTP middleware.
      +        try:
      +            with self.client.websocket_connect(
      +                "/api/plugins/kanban/events"
      +            ):
      +                pass  # if we got here without disconnect, the WS accepted us
      +        except WebSocketDisconnect:
      +            pass  # expected — WS endpoint rejected via its own check
      +        except Exception:
      +            # The kanban plugin may not be mounted in this test environment,
      +            # in which case the route doesn't exist at all (3xx/4xx during
      +            # upgrade). That's fine for this regression — it only matters
      +            # that the HTTP middleware didn't start intercepting WS upgrades.
      +            pass
      +
      +
       class TestDashboardPluginManifestExtensions:
           """Tests for the extended plugin manifest fields (tab.override,
           tab.hidden, slots) read by _discover_dashboard_plugins()."""

1 PR closed without merging

From the close comments: merged via another change 1

  • #19541 fix(security): require dashboard auth for plugin API routes · merged via another change → 9ab2c3d, #23220
Closed issues · 1
  • Closed#19533fix(security): require dashboard auth for Kanban plugin API
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#23220
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
10 May 2026

For your agent

The same facts, typed.