# NousResearch/hermes-agent · Require dashboard auth for plugin API routes

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #23220. Verify it addresses your case.
- **Recommended:** read PR #23220 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-23220`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #19533: fix(security): require dashboard auth for Kanban plugin API

## Prior & competing PRs

- **#23220: fix(security): require dashboard auth for plugin API routes (salvage #19541)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/web_server.py`, `plugins/kanban/dashboard/plugin_api.py`, `tests/hermes_cli/test_web_server.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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()."""
    ```
- **#19541: fix(security): require dashboard auth for plugin API routes**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `9ab2c3dfc`, `#23220`

## Fix status

- Merged to main in PR #23220 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-10`.
