mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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
417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
const BASE = "";
|
|
|
|
// Ephemeral session token for protected endpoints.
|
|
// Injected into index.html by the server — never fetched via API.
|
|
declare global {
|
|
interface Window {
|
|
__HERMES_SESSION_TOKEN__?: string;
|
|
}
|
|
}
|
|
let _sessionToken: string | null = null;
|
|
|
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|
// 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) {
|
|
const text = await res.text().catch(() => res.statusText);
|
|
throw new Error(`${res.status}: ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function getSessionToken(): Promise<string> {
|
|
if (_sessionToken) return _sessionToken;
|
|
const injected = window.__HERMES_SESSION_TOKEN__;
|
|
if (injected) {
|
|
_sessionToken = injected;
|
|
return _sessionToken;
|
|
}
|
|
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
|
}
|
|
|
|
export const api = {
|
|
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
|
getSessions: (limit = 20, offset = 0) =>
|
|
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
|
getSessionMessages: (id: string) =>
|
|
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
|
deleteSession: (id: string) =>
|
|
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
}),
|
|
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
|
const qs = new URLSearchParams();
|
|
if (params.file) qs.set("file", params.file);
|
|
if (params.lines) qs.set("lines", String(params.lines));
|
|
if (params.level && params.level !== "ALL") qs.set("level", params.level);
|
|
if (params.component && params.component !== "all") qs.set("component", params.component);
|
|
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
|
},
|
|
getAnalytics: (days: number) =>
|
|
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
|
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
|
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
|
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
|
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
|
saveConfig: (config: Record<string, unknown>) =>
|
|
fetchJSON<{ ok: boolean }>("/api/config", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ config }),
|
|
}),
|
|
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
|
saveConfigRaw: (yaml_text: string) =>
|
|
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ yaml_text }),
|
|
}),
|
|
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
|
|
setEnvVar: (key: string, value: string) =>
|
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key, value }),
|
|
}),
|
|
deleteEnvVar: (key: string) =>
|
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key }),
|
|
}),
|
|
revealEnvVar: async (key: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ key }),
|
|
});
|
|
},
|
|
|
|
// Cron jobs
|
|
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
|
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
|
fetchJSON<CronJob>("/api/cron/jobs", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(job),
|
|
}),
|
|
pauseCronJob: (id: string) =>
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
|
resumeCronJob: (id: string) =>
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
|
triggerCronJob: (id: string) =>
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
|
deleteCronJob: (id: string) =>
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
|
|
|
// Skills & Toolsets
|
|
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
|
toggleSkill: (name: string, enabled: boolean) =>
|
|
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, enabled }),
|
|
}),
|
|
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
|
|
|
// Session search (FTS5)
|
|
searchSessions: (q: string) =>
|
|
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
|
|
|
// OAuth provider management
|
|
getOAuthProviders: () =>
|
|
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
|
disconnectOAuthProvider: async (providerId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<{ ok: boolean; provider: string }>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
},
|
|
startOAuthLogin: async (providerId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<OAuthStartResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: "{}",
|
|
},
|
|
);
|
|
},
|
|
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<OAuthSubmitResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ session_id: sessionId, code }),
|
|
},
|
|
);
|
|
},
|
|
pollOAuthSession: (providerId: string, sessionId: string) =>
|
|
fetchJSON<OAuthPollResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
|
|
),
|
|
cancelOAuthSession: async (sessionId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<{ ok: boolean }>(
|
|
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
},
|
|
};
|
|
|
|
export interface PlatformStatus {
|
|
error_code?: string;
|
|
error_message?: string;
|
|
state: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface StatusResponse {
|
|
active_sessions: number;
|
|
config_path: string;
|
|
config_version: number;
|
|
env_path: string;
|
|
gateway_exit_reason: string | null;
|
|
gateway_pid: number | null;
|
|
gateway_platforms: Record<string, PlatformStatus>;
|
|
gateway_running: boolean;
|
|
gateway_state: string | null;
|
|
gateway_updated_at: string | null;
|
|
hermes_home: string;
|
|
latest_config_version: number;
|
|
release_date: string;
|
|
version: string;
|
|
}
|
|
|
|
export interface SessionInfo {
|
|
id: string;
|
|
source: string | null;
|
|
model: string | null;
|
|
title: string | null;
|
|
started_at: number;
|
|
ended_at: number | null;
|
|
last_active: number;
|
|
is_active: boolean;
|
|
message_count: number;
|
|
tool_call_count: number;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
preview: string | null;
|
|
}
|
|
|
|
export interface PaginatedSessions {
|
|
sessions: SessionInfo[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
export interface EnvVarInfo {
|
|
is_set: boolean;
|
|
redacted_value: string | null;
|
|
description: string;
|
|
url: string | null;
|
|
category: string;
|
|
is_password: boolean;
|
|
tools: string[];
|
|
advanced: boolean;
|
|
}
|
|
|
|
export interface SessionMessage {
|
|
role: "user" | "assistant" | "system" | "tool";
|
|
content: string | null;
|
|
tool_calls?: Array<{
|
|
id: string;
|
|
function: { name: string; arguments: string };
|
|
}>;
|
|
tool_name?: string;
|
|
tool_call_id?: string;
|
|
timestamp?: number;
|
|
}
|
|
|
|
export interface SessionMessagesResponse {
|
|
session_id: string;
|
|
messages: SessionMessage[];
|
|
}
|
|
|
|
export interface LogsResponse {
|
|
file: string;
|
|
lines: string[];
|
|
}
|
|
|
|
export interface AnalyticsDailyEntry {
|
|
day: string;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cache_read_tokens: number;
|
|
reasoning_tokens: number;
|
|
estimated_cost: number;
|
|
actual_cost: number;
|
|
sessions: number;
|
|
}
|
|
|
|
export interface AnalyticsModelEntry {
|
|
model: string;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
estimated_cost: number;
|
|
sessions: number;
|
|
}
|
|
|
|
export interface AnalyticsResponse {
|
|
daily: AnalyticsDailyEntry[];
|
|
by_model: AnalyticsModelEntry[];
|
|
totals: {
|
|
total_input: number;
|
|
total_output: number;
|
|
total_cache_read: number;
|
|
total_reasoning: number;
|
|
total_estimated_cost: number;
|
|
total_actual_cost: number;
|
|
total_sessions: number;
|
|
};
|
|
}
|
|
|
|
export interface CronJob {
|
|
id: string;
|
|
name?: string;
|
|
prompt: string;
|
|
schedule: { kind: string; expr: string; display: string };
|
|
schedule_display: string;
|
|
enabled: boolean;
|
|
state: string;
|
|
deliver?: string;
|
|
last_run_at?: string | null;
|
|
next_run_at?: string | null;
|
|
last_error?: string | null;
|
|
}
|
|
|
|
export interface SkillInfo {
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface ToolsetInfo {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
configured: boolean;
|
|
tools: string[];
|
|
}
|
|
|
|
export interface SessionSearchResult {
|
|
session_id: string;
|
|
snippet: string;
|
|
role: string | null;
|
|
source: string | null;
|
|
model: string | null;
|
|
session_started: number | null;
|
|
}
|
|
|
|
export interface SessionSearchResponse {
|
|
results: SessionSearchResult[];
|
|
}
|
|
|
|
// ── Model info types ──────────────────────────────────────────────────
|
|
|
|
export interface ModelInfoResponse {
|
|
model: string;
|
|
provider: string;
|
|
auto_context_length: number;
|
|
config_context_length: number;
|
|
effective_context_length: number;
|
|
capabilities: {
|
|
supports_tools?: boolean;
|
|
supports_vision?: boolean;
|
|
supports_reasoning?: boolean;
|
|
context_window?: number;
|
|
max_output_tokens?: number;
|
|
model_family?: string;
|
|
};
|
|
}
|
|
|
|
// ── OAuth provider types ────────────────────────────────────────────────
|
|
|
|
export interface OAuthProviderStatus {
|
|
logged_in: boolean;
|
|
source?: string | null;
|
|
source_label?: string | null;
|
|
token_preview?: string | null;
|
|
expires_at?: string | null;
|
|
has_refresh_token?: boolean;
|
|
last_refresh?: string | null;
|
|
error?: string;
|
|
}
|
|
|
|
export interface OAuthProvider {
|
|
id: string;
|
|
name: string;
|
|
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
|
|
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
|
|
flow: "pkce" | "device_code" | "external";
|
|
cli_command: string;
|
|
docs_url: string;
|
|
status: OAuthProviderStatus;
|
|
}
|
|
|
|
export interface OAuthProvidersResponse {
|
|
providers: OAuthProvider[];
|
|
}
|
|
|
|
/** Discriminated union — the shape of /start depends on the flow. */
|
|
export type OAuthStartResponse =
|
|
| {
|
|
session_id: string;
|
|
flow: "pkce";
|
|
auth_url: string;
|
|
expires_in: number;
|
|
}
|
|
| {
|
|
session_id: string;
|
|
flow: "device_code";
|
|
user_code: string;
|
|
verification_url: string;
|
|
expires_in: number;
|
|
poll_interval: number;
|
|
};
|
|
|
|
export interface OAuthSubmitResponse {
|
|
ok: boolean;
|
|
status: "approved" | "error";
|
|
message?: string;
|
|
}
|
|
|
|
export interface OAuthPollResponse {
|
|
session_id: string;
|
|
status: "pending" | "approved" | "denied" | "expired" | "error";
|
|
error_message?: string | null;
|
|
expires_at?: number | null;
|
|
}
|