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