mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(security): harden dashboard API against unauthenticated access (#9800)
Addresses responsible disclosure from FuzzMind Security Lab (CVE pending). The web dashboard API server had 36 endpoints, of which only 5 checked the session token. The token itself was served from an unauthenticated GET /api/auth/session-token endpoint, rendering the protection circular. When bound to 0.0.0.0 (--host flag), all API keys, config, and cron management were accessible to any machine on the network. Changes: - Add auth middleware requiring session token on ALL /api/ routes except a small public whitelist (status, config/defaults, config/schema, model/info) - Remove GET /api/auth/session-token endpoint entirely; inject the token into index.html via a <script> tag at serve time instead - Replace all inline token comparisons (!=) with hmac.compare_digest() to prevent timing side-channel attacks - Block non-localhost binding by default; require --insecure flag to override (with warning log) - Update frontend fetchJSON() to send Authorization header on all requests using the injected window.__HERMES_SESSION_TOKEN__ Credit: Callum (@0xca1x) and @migraine-sudo at FuzzMind Security Lab
This commit is contained in:
parent
b583210c97
commit
99bcc2de5b
4 changed files with 152 additions and 56 deletions
|
|
@ -4421,6 +4421,7 @@ def cmd_dashboard(args):
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
open_browser=not args.no_open,
|
open_browser=not args.no_open,
|
||||||
|
allow_public=getattr(args, "insecure", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5932,6 +5933,10 @@ Examples:
|
||||||
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
|
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
|
||||||
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
|
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
|
||||||
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
|
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
|
||||||
|
dashboard_parser.add_argument(
|
||||||
|
"--insecure", action="store_true",
|
||||||
|
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
|
||||||
|
)
|
||||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Usage:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -47,7 +48,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -84,6 +85,44 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints that do NOT require the session token. Everything else under
|
||||||
|
# /api/ is gated by the auth middleware below. Keep this list minimal —
|
||||||
|
# only truly non-sensitive, read-only endpoints belong here.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PUBLIC_API_PATHS: frozenset = frozenset({
|
||||||
|
"/api/status",
|
||||||
|
"/api/config/defaults",
|
||||||
|
"/api/config/schema",
|
||||||
|
"/api/model/info",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_token(request: Request) -> None:
|
||||||
|
"""Validate the ephemeral session token. Raises 401 on mismatch.
|
||||||
|
|
||||||
|
Uses ``hmac.compare_digest`` to prevent timing side-channels.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
expected = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
if not hmac.compare_digest(auth.encode(), expected.encode()):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
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:
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
expected = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
if not hmac.compare_digest(auth.encode(), expected.encode()):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Unauthorized"},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config schema — auto-generated from DEFAULT_CONFIG
|
# Config schema — auto-generated from DEFAULT_CONFIG
|
||||||
|
|
@ -607,17 +646,6 @@ async def update_config(body: ConfigUpdate):
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/session-token")
|
|
||||||
async def get_session_token():
|
|
||||||
"""Return the ephemeral session token for this server instance.
|
|
||||||
|
|
||||||
The token protects sensitive endpoints (reveal). It's served to the SPA
|
|
||||||
which stores it in memory — it's never persisted and dies when the server
|
|
||||||
process exits. CORS already restricts this to localhost origins.
|
|
||||||
"""
|
|
||||||
return {"token": _SESSION_TOKEN}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/env")
|
@app.get("/api/env")
|
||||||
async def get_env_vars():
|
async def get_env_vars():
|
||||||
env_on_disk = load_env()
|
env_on_disk = load_env()
|
||||||
|
|
@ -671,9 +699,7 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
||||||
- Audit logging
|
- Audit logging
|
||||||
"""
|
"""
|
||||||
# --- Token check ---
|
# --- Token check ---
|
||||||
auth = request.headers.get("authorization", "")
|
_require_token(request)
|
||||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
|
|
||||||
# --- Rate limit ---
|
# --- Rate limit ---
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
@ -944,9 +970,7 @@ async def list_oauth_providers():
|
||||||
@app.delete("/api/providers/oauth/{provider_id}")
|
@app.delete("/api/providers/oauth/{provider_id}")
|
||||||
async def disconnect_oauth_provider(provider_id: str, request: Request):
|
async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||||
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
||||||
auth = request.headers.get("authorization", "")
|
_require_token(request)
|
||||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
|
|
||||||
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||||
if provider_id not in valid_ids:
|
if provider_id not in valid_ids:
|
||||||
|
|
@ -1518,9 +1542,7 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
@app.post("/api/providers/oauth/{provider_id}/start")
|
||||||
async def start_oauth_login(provider_id: str, request: Request):
|
async def start_oauth_login(provider_id: str, request: Request):
|
||||||
"""Initiate an OAuth login flow. Token-protected."""
|
"""Initiate an OAuth login flow. Token-protected."""
|
||||||
auth = request.headers.get("authorization", "")
|
_require_token(request)
|
||||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
_gc_oauth_sessions()
|
_gc_oauth_sessions()
|
||||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||||
if provider_id not in valid:
|
if provider_id not in valid:
|
||||||
|
|
@ -1552,9 +1574,7 @@ class OAuthSubmitBody(BaseModel):
|
||||||
@app.post("/api/providers/oauth/{provider_id}/submit")
|
@app.post("/api/providers/oauth/{provider_id}/submit")
|
||||||
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
|
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
|
||||||
"""Submit the auth code for PKCE flows. Token-protected."""
|
"""Submit the auth code for PKCE flows. Token-protected."""
|
||||||
auth = request.headers.get("authorization", "")
|
_require_token(request)
|
||||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
if provider_id == "anthropic":
|
if provider_id == "anthropic":
|
||||||
return await asyncio.get_event_loop().run_in_executor(
|
return await asyncio.get_event_loop().run_in_executor(
|
||||||
None, _submit_anthropic_pkce, body.session_id, body.code,
|
None, _submit_anthropic_pkce, body.session_id, body.code,
|
||||||
|
|
@ -1582,9 +1602,7 @@ async def poll_oauth_session(provider_id: str, session_id: str):
|
||||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
||||||
async def cancel_oauth_session(session_id: str, request: Request):
|
async def cancel_oauth_session(session_id: str, request: Request):
|
||||||
"""Cancel a pending OAuth session. Token-protected."""
|
"""Cancel a pending OAuth session. Token-protected."""
|
||||||
auth = request.headers.get("authorization", "")
|
_require_token(request)
|
||||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess = _oauth_sessions.pop(session_id, None)
|
sess = _oauth_sessions.pop(session_id, None)
|
||||||
if sess is None:
|
if sess is None:
|
||||||
|
|
@ -1932,7 +1950,12 @@ async def get_usage_analytics(days: int = 30):
|
||||||
|
|
||||||
|
|
||||||
def mount_spa(application: FastAPI):
|
def mount_spa(application: FastAPI):
|
||||||
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||||
|
|
||||||
|
The session token is injected into index.html via a ``<script>`` tag so
|
||||||
|
the SPA can authenticate against protected API endpoints without a
|
||||||
|
separate (unauthenticated) token-dispensing endpoint.
|
||||||
|
"""
|
||||||
if not WEB_DIST.exists():
|
if not WEB_DIST.exists():
|
||||||
@application.get("/{full_path:path}")
|
@application.get("/{full_path:path}")
|
||||||
async def no_frontend(full_path: str):
|
async def no_frontend(full_path: str):
|
||||||
|
|
@ -1942,6 +1965,20 @@ def mount_spa(application: FastAPI):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_index_path = WEB_DIST / "index.html"
|
||||||
|
|
||||||
|
def _serve_index():
|
||||||
|
"""Return index.html with the session token injected."""
|
||||||
|
html = _index_path.read_text()
|
||||||
|
token_script = (
|
||||||
|
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";</script>'
|
||||||
|
)
|
||||||
|
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||||
|
return HTMLResponse(
|
||||||
|
html,
|
||||||
|
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||||
|
)
|
||||||
|
|
||||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
@application.get("/{full_path:path}")
|
@application.get("/{full_path:path}")
|
||||||
|
|
@ -1955,24 +1992,32 @@ def mount_spa(application: FastAPI):
|
||||||
and file_path.is_file()
|
and file_path.is_file()
|
||||||
):
|
):
|
||||||
return FileResponse(file_path)
|
return FileResponse(file_path)
|
||||||
return FileResponse(
|
return _serve_index()
|
||||||
WEB_DIST / "index.html",
|
|
||||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
mount_spa(app)
|
mount_spa(app)
|
||||||
|
|
||||||
|
|
||||||
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
def start_server(
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
port: int = 9119,
|
||||||
|
open_browser: bool = True,
|
||||||
|
allow_public: bool = False,
|
||||||
|
):
|
||||||
"""Start the web UI server."""
|
"""Start the web UI server."""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
|
||||||
import logging
|
if host not in _LOCALHOST and not allow_public:
|
||||||
logging.warning(
|
raise SystemExit(
|
||||||
"Binding to %s — the web UI exposes config and API keys. "
|
f"Refusing to bind to {host} — the dashboard exposes API keys "
|
||||||
"Only bind to non-localhost if you trust all users on the network.", host,
|
f"and config without robust authentication.\n"
|
||||||
|
f"Use --insecure to override (NOT recommended on untrusted networks)."
|
||||||
|
)
|
||||||
|
if host not in _LOCALHOST:
|
||||||
|
_log.warning(
|
||||||
|
"Binding to %s with --insecure — the dashboard has no robust "
|
||||||
|
"authentication. Only use on trusted networks.", host,
|
||||||
)
|
)
|
||||||
|
|
||||||
if open_browser:
|
if open_browser:
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,9 @@ class TestWebServerEndpoints:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("fastapi/starlette not installed")
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
from hermes_cli.web_server import app
|
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||||
self.client = TestClient(app)
|
self.client = TestClient(app)
|
||||||
|
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
|
||||||
def test_get_status(self):
|
def test_get_status(self):
|
||||||
resp = self.client.get("/api/status")
|
resp = self.client.get("/api/status")
|
||||||
|
|
@ -239,9 +240,13 @@ class TestWebServerEndpoints:
|
||||||
|
|
||||||
def test_reveal_env_var_no_token(self, tmp_path):
|
def test_reveal_env_var_no_token(self, tmp_path):
|
||||||
"""POST /api/env/reveal without token should return 401."""
|
"""POST /api/env/reveal without token should return 401."""
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from hermes_cli.web_server import app
|
||||||
from hermes_cli.config import save_env_value
|
from hermes_cli.config import save_env_value
|
||||||
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
|
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
|
||||||
resp = self.client.post(
|
# Use a fresh client WITHOUT the Authorization header
|
||||||
|
unauth_client = TestClient(app)
|
||||||
|
resp = unauth_client.post(
|
||||||
"/api/env/reveal",
|
"/api/env/reveal",
|
||||||
json={"key": "TEST_REVEAL_NOAUTH"},
|
json={"key": "TEST_REVEAL_NOAUTH"},
|
||||||
)
|
)
|
||||||
|
|
@ -258,12 +263,32 @@ class TestWebServerEndpoints:
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
def test_session_token_endpoint(self):
|
def test_session_token_endpoint_removed(self):
|
||||||
"""GET /api/auth/session-token should return a token."""
|
"""GET /api/auth/session-token should no longer exist (token injected via HTML)."""
|
||||||
from hermes_cli.web_server import _SESSION_TOKEN
|
|
||||||
resp = self.client.get("/api/auth/session-token")
|
resp = self.client.get("/api/auth/session-token")
|
||||||
|
# The endpoint is gone — the catch-all SPA route serves index.html
|
||||||
|
# or the middleware returns 401 for unauthenticated /api/ paths.
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
# Either way, it must NOT return the token as JSON
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" not in data
|
||||||
|
except Exception:
|
||||||
|
pass # Not JSON — that's fine (SPA HTML)
|
||||||
|
|
||||||
|
def test_unauthenticated_api_blocked(self):
|
||||||
|
"""API requests without the session token should be rejected."""
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from hermes_cli.web_server import app
|
||||||
|
# Create a client WITHOUT the Authorization header
|
||||||
|
unauth_client = TestClient(app)
|
||||||
|
resp = unauth_client.get("/api/env")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
resp = unauth_client.get("/api/config")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
# Public endpoints should still work
|
||||||
|
resp = unauth_client.get("/api/status")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["token"] == _SESSION_TOKEN
|
|
||||||
|
|
||||||
def test_path_traversal_blocked(self):
|
def test_path_traversal_blocked(self):
|
||||||
"""Verify URL-encoded path traversal is blocked."""
|
"""Verify URL-encoded path traversal is blocked."""
|
||||||
|
|
@ -358,8 +383,9 @@ class TestConfigRoundTrip:
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("fastapi/starlette not installed")
|
pytest.skip("fastapi/starlette not installed")
|
||||||
from hermes_cli.web_server import app
|
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||||
self.client = TestClient(app)
|
self.client = TestClient(app)
|
||||||
|
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
|
||||||
def test_get_config_no_internal_keys(self):
|
def test_get_config_no_internal_keys(self):
|
||||||
"""GET /api/config should not expose _config_version or _model_meta."""
|
"""GET /api/config should not expose _config_version or _model_meta."""
|
||||||
|
|
@ -490,8 +516,9 @@ class TestNewEndpoints:
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("fastapi/starlette not installed")
|
pytest.skip("fastapi/starlette not installed")
|
||||||
from hermes_cli.web_server import app
|
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||||
self.client = TestClient(app)
|
self.client = TestClient(app)
|
||||||
|
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
|
||||||
def test_get_logs_default(self):
|
def test_get_logs_default(self):
|
||||||
resp = self.client.get("/api/logs")
|
resp = self.client.get("/api/logs")
|
||||||
|
|
@ -668,11 +695,16 @@ class TestNewEndpoints:
|
||||||
assert isinstance(data["daily"], list)
|
assert isinstance(data["daily"], list)
|
||||||
assert "total_sessions" in data["totals"]
|
assert "total_sessions" in data["totals"]
|
||||||
|
|
||||||
def test_session_token_endpoint(self):
|
def test_session_token_endpoint_removed(self):
|
||||||
from hermes_cli.web_server import _SESSION_TOKEN
|
"""GET /api/auth/session-token no longer exists."""
|
||||||
resp = self.client.get("/api/auth/session-token")
|
resp = self.client.get("/api/auth/session-token")
|
||||||
assert resp.status_code == 200
|
# Should not return a JSON token object
|
||||||
assert resp.json()["token"] == _SESSION_TOKEN
|
assert resp.status_code in (200, 404)
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" not in data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
const BASE = "";
|
const BASE = "";
|
||||||
|
|
||||||
// Ephemeral session token for protected endpoints (reveal).
|
// Ephemeral session token for protected endpoints.
|
||||||
// Fetched once on first reveal request and cached in memory.
|
// Injected into index.html by the server — never fetched via API.
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_SESSION_TOKEN__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
let _sessionToken: string | null = null;
|
let _sessionToken: string | null = null;
|
||||||
|
|
||||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE}${url}`, init);
|
// Inject the session token into all /api/ requests.
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
const token = window.__HERMES_SESSION_TOKEN__;
|
||||||
|
if (token && !headers.has("Authorization")) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${BASE}${url}`, { ...init, headers });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => res.statusText);
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(`${res.status}: ${text}`);
|
throw new Error(`${res.status}: ${text}`);
|
||||||
|
|
@ -15,9 +26,12 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
|
||||||
async function getSessionToken(): Promise<string> {
|
async function getSessionToken(): Promise<string> {
|
||||||
if (_sessionToken) return _sessionToken;
|
if (_sessionToken) return _sessionToken;
|
||||||
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
|
const injected = window.__HERMES_SESSION_TOKEN__;
|
||||||
_sessionToken = resp.token;
|
if (injected) {
|
||||||
return _sessionToken;
|
_sessionToken = injected;
|
||||||
|
return _sessionToken;
|
||||||
|
}
|
||||||
|
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue