mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 02:13:14 +08:00
Compare commits
8 Commits
dependabot
...
feat/remov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1443be72f7 | ||
|
|
4ad1655211 | ||
|
|
85d9d27043 | ||
|
|
6cf12eef4e | ||
|
|
25da2472ac | ||
|
|
52e51de69d | ||
|
|
cde893cce6 | ||
|
|
3ca9c72d84 |
@@ -78,6 +78,24 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a credential-free WS URL for the LOCAL spawned backend. The desktop's
|
||||
* own dashboard binds to loopback (127.0.0.1), where the gateway gates the WS
|
||||
* upgrade purely on the peer-IP + Host/Origin guard and IGNORES any token. So
|
||||
* the local renderer connects to a bare `ws(s)://host/prefix/api/ws` with no
|
||||
* `?token=` — there is no credential to send.
|
||||
*
|
||||
* This is distinct from buildGatewayWsUrl (the REMOTE `token` auth mode, which
|
||||
* still appends `?token=` for a user-saved remote-gateway token).
|
||||
*/
|
||||
function buildGatewayWsUrlNoAuth(baseUrl) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
@@ -274,6 +292,7 @@ module.exports = {
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
@@ -201,6 +202,24 @@ test('buildGatewayWsUrl url-encodes the token', () => {
|
||||
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlNoAuth (local loopback, credential-free) ---
|
||||
|
||||
test('buildGatewayWsUrlNoAuth builds a bare ws URL with no token param', () => {
|
||||
assert.equal(buildGatewayWsUrlNoAuth('http://127.0.0.1:9119'), 'ws://127.0.0.1:9119/api/ws')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlNoAuth uses wss for https', () => {
|
||||
assert.equal(buildGatewayWsUrlNoAuth('https://gw.example.com'), 'wss://gw.example.com/api/ws')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlNoAuth honors a path prefix and never adds a credential', () => {
|
||||
const url = buildGatewayWsUrlNoAuth('http://127.0.0.1:9119/hermes/')
|
||||
assert.equal(url, 'ws://127.0.0.1:9119/hermes/api/ws')
|
||||
assert.ok(!url.includes('token='))
|
||||
assert.ok(!url.includes('ticket='))
|
||||
assert.ok(!url.includes('?'))
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlWithTicket (oauth) ---
|
||||
|
||||
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
@@ -34,7 +34,6 @@ const {
|
||||
} = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
@@ -57,6 +56,7 @@ const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./wo
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
@@ -2638,7 +2638,10 @@ function fetchJson(url, token, options = {}) {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hermes-Session-Token': token,
|
||||
// The LOCAL loopback backend needs no credential — the server ignores
|
||||
// any identity token there — so a null/empty token omits the header
|
||||
// entirely. The REMOTE 'token' auth mode still sends its token.
|
||||
...(token ? { 'X-Hermes-Session-Token': token } : {}),
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
},
|
||||
@@ -3268,6 +3271,9 @@ function closePreviewWatchers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Poll /api/status until the backend answers. `token` is optional: the LOCAL
|
||||
// loopback backend sends no credential (the server ignores it there), so it's
|
||||
// omitted; the REMOTE 'token' auth mode still passes its user-saved token.
|
||||
async function waitForHermes(baseUrl, token) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
@@ -4696,7 +4702,6 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
@@ -4720,7 +4725,6 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
@@ -4731,7 +4735,6 @@ async function spawnPoolBackend(profile, entry) {
|
||||
})
|
||||
)
|
||||
entry.process = child
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
child.stderr.on('data', rememberLog)
|
||||
@@ -4761,23 +4764,20 @@ async function spawnPoolBackend(profile, entry) {
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
await Promise.race([waitForHermes(baseUrl), startFailed])
|
||||
ready = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
// The local backend binds to loopback, where the gateway ignores any
|
||||
// identity token (peer-IP + Host/Origin guard is the boundary). No
|
||||
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
|
||||
authMode: 'token',
|
||||
token: authToken,
|
||||
token: null,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4899,7 +4899,6 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
@@ -4937,7 +4936,6 @@ async function startHermes() {
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
@@ -5001,13 +4999,8 @@ async function startHermes() {
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
await Promise.race([waitForHermes(baseUrl), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
@@ -5020,9 +5013,12 @@ async function startHermes() {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
// The local backend binds to loopback, where the gateway ignores any
|
||||
// identity token (peer-IP + Host/Origin guard is the boundary). No
|
||||
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
|
||||
authMode: 'token',
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
token: null,
|
||||
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -16,7 +16,6 @@ import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
@@ -174,23 +173,12 @@ def _get_event_state(app: "FastAPI"):
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session token for protecting sensitive endpoints (reveal).
|
||||
# The desktop shell mints the token and injects it via
|
||||
# HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the
|
||||
# /api calls it makes on the user's behalf; otherwise we generate one fresh
|
||||
# on every server start. Either way it dies when the process exits and is
|
||||
# injected into the SPA HTML so only the legitimate web UI can use it.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)
|
||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||
|
||||
# In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the
|
||||
# desktop app and the dashboard's own Chat tab both drive the agent over the
|
||||
# `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an
|
||||
# unconditional part of the dashboard. Kept as a module-level constant (rather
|
||||
# than inlining ``True`` at every gate) so the WS endpoints and the SPA token
|
||||
# injection share a single, testable seam.
|
||||
# than inlining ``True`` at every gate) so the WS endpoints and the SPA
|
||||
# bootstrap share a single, testable seam.
|
||||
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
|
||||
|
||||
# Simple rate limiter for the reveal endpoint
|
||||
@@ -227,57 +215,20 @@ from hermes_cli.dashboard_auth.public_paths import (
|
||||
)
|
||||
|
||||
|
||||
def _has_valid_session_token(request: Request) -> bool:
|
||||
"""True if the request carries a valid dashboard session token.
|
||||
|
||||
The dedicated session header avoids collisions with reverse proxies that
|
||||
already use ``Authorization`` (for example Caddy ``basic_auth``). We still
|
||||
accept the legacy Bearer path for backward compatibility with older
|
||||
dashboard bundles.
|
||||
"""
|
||||
session_header = request.headers.get(_SESSION_HEADER_NAME, "")
|
||||
if session_header and hmac.compare_digest(
|
||||
session_header.encode(),
|
||||
_SESSION_TOKEN.encode(),
|
||||
):
|
||||
return True
|
||||
|
||||
auth = request.headers.get("authorization", "")
|
||||
expected = f"Bearer {_SESSION_TOKEN}"
|
||||
return hmac.compare_digest(auth.encode(), expected.encode())
|
||||
|
||||
|
||||
# Routes that may also authenticate via a ``?token=`` query param, for download
|
||||
# links opened by the OS shell or a new browser tab where the session header
|
||||
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
|
||||
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
|
||||
|
||||
|
||||
def _has_valid_query_token(request: Request, path: str) -> bool:
|
||||
if path not in _QUERY_TOKEN_API_PATHS:
|
||||
return False
|
||||
token = request.query_params.get("token", "")
|
||||
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
|
||||
|
||||
Two auth schemes protect the dashboard, exactly one active per bind:
|
||||
Two regimes, exactly one active per bind:
|
||||
|
||||
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
|
||||
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
|
||||
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
|
||||
Validate it here.
|
||||
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
|
||||
NOT injected (the SPA authenticates with a session cookie), so there is
|
||||
no token to check. The ``gated_auth_middleware`` has already verified the
|
||||
cookie before the request reached this handler — any non-public ``/api/``
|
||||
route it lets through carries a verified ``request.state.session``. The
|
||||
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
|
||||
the (absent) token here would 401 every cookie-authenticated request,
|
||||
making plugin install/enable/disable and the other ``_require_token``
|
||||
endpoints permanently unreachable behind the gate. Defer to the gate.
|
||||
* **Gated / OAuth mode** (``auth_required`` True): the
|
||||
``gated_auth_middleware`` has already verified the session cookie
|
||||
before the request reached this handler — any non-public ``/api/``
|
||||
route it lets through carries a verified ``request.state.session``.
|
||||
Accept iff that session is present; 401 otherwise.
|
||||
* **Loopback / ``--insecure`` mode** (``auth_required`` False): there is
|
||||
NO identity gate. The loopback bind is the boundary, the CSRF guard
|
||||
blocks cross-origin mutations, and CORS blocks cross-origin reads. A
|
||||
local user is entitled to call these routes, so allow them.
|
||||
"""
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
# Gate is authoritative. It attaches ``request.state.session`` on
|
||||
@@ -286,8 +237,9 @@ def _require_token(request: Request) -> None:
|
||||
if getattr(request.state, "session", None) is not None:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not _has_valid_session_token(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Loopback / --insecure: no identity gate. CSRF guard + bind boundary
|
||||
# protect these routes; a local user is entitled to call them.
|
||||
return
|
||||
|
||||
|
||||
# Accepted Host header values for loopback binds. DNS rebinding attacks
|
||||
@@ -391,12 +343,72 @@ async def host_header_middleware(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF guard — reject cross-origin state-changing requests via Sec-Fetch-Site.
|
||||
#
|
||||
# This is the credential-free replacement for the legacy ``_SESSION_TOKEN``'s
|
||||
# only robust contribution: blocking drive-by CSRF from a web page the user
|
||||
# visits. It applies in BOTH auth regimes (loopback and gated).
|
||||
#
|
||||
# Middleware order note: ``@app.middleware`` prepends, so the runtime order
|
||||
# (outermost→innermost) is auth_middleware → _dashboard_auth_gate →
|
||||
# csrf_guard_middleware → host_header_middleware → CORS → route. So an
|
||||
# UNAUTHENTICATED cross-site mutation is already rejected by the outer auth
|
||||
# layer (401 token in loopback, 401 cookie in gated); the CSRF guard's job is
|
||||
# to reject an AUTHENTICATED cross-site mutation (403) — the genuine CSRF
|
||||
# case where the victim's own credentials ride along. Both regimes covered.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Methods whose side effects a cross-origin page could trigger WITHOUT a CORS
|
||||
# preflight ("simple requests" plus anything the browser will send cross-site).
|
||||
# Reads are not guarded here — the CORSMiddleware (localhost-only origin regex,
|
||||
# allow_credentials off) already prevents a foreign origin from reading any
|
||||
# /api/* response body, so a cross-origin GET leaks nothing.
|
||||
_CSRF_GUARDED_METHODS: frozenset = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
# Sec-Fetch-Site values that indicate a same-origin or user-initiated request.
|
||||
# ``Sec-Fetch-Site`` is a forbidden header name (browser-set, JS cannot forge
|
||||
# it), Baseline-available since 2023. ``none`` covers user navigation AND the
|
||||
# packaged desktop renderer's file:// origin.
|
||||
_CSRF_SAFE_FETCH_SITES: frozenset = frozenset({"same-origin", "none"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_guard_middleware(request: Request, call_next):
|
||||
"""Reject cross-origin state-changing requests via Sec-Fetch-Site.
|
||||
|
||||
Fail-open on an ABSENT header so non-browser clients (curl, the NAS
|
||||
liveness probe, the desktop main process) are unaffected — those carry
|
||||
no CSRF risk and the real auth gate (cookie / Origin guard) still
|
||||
applies to them. Only a PRESENT, hostile value (``cross-site`` /
|
||||
``same-site``) is rejected.
|
||||
"""
|
||||
if (
|
||||
request.method in _CSRF_GUARDED_METHODS
|
||||
and request.url.path.startswith("/api/")
|
||||
):
|
||||
sfs = request.headers.get("sec-fetch-site")
|
||||
if sfs is not None and sfs not in _CSRF_SAFE_FETCH_SITES:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "cross_origin_blocked",
|
||||
"detail": (
|
||||
"Cross-origin state-changing request rejected. The "
|
||||
"dashboard only accepts mutations from its own origin."
|
||||
),
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard OAuth auth gate — engaged only when start_server flags the
|
||||
# bind as non-loopback-without-insecure. No-op pass-through in loopback
|
||||
# mode so the legacy auth_middleware (below) handles those binds via
|
||||
# the injected ``_SESSION_TOKEN``. Registered between host_header and
|
||||
# auth_middleware so the order is: host check → cookie auth → token auth.
|
||||
# bind as non-loopback-without-insecure (``app.state.auth_required``). It is
|
||||
# a no-op pass-through on a loopback bind, where the dashboard runs no
|
||||
# identity gate at all: the loopback bind is the security boundary, the
|
||||
# csrf_guard_middleware blocks cross-origin mutations, and the localhost-only
|
||||
# CORS policy blocks cross-origin reads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -406,24 +418,6 @@ async def _dashboard_auth_gate(request: Request, call_next):
|
||||
return await gated_auth_middleware(request, call_next)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""Require the session token on all /api/ routes except the public list."""
|
||||
# When the OAuth gate is active, cookie-based auth (gated_auth_middleware
|
||||
# above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only
|
||||
# and is skipped here so the gate's session attachment isn't overridden.
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
|
||||
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config schema — auto-generated from DEFAULT_CONFIG
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -10106,10 +10100,12 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
# WebSocket. The browser renders the ANSI through xterm.js (see
|
||||
# web/src/pages/ChatPage.tsx).
|
||||
#
|
||||
# Auth: ``?token=<session_token>`` query param (browsers can't set
|
||||
# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
|
||||
# REST. Localhost-only — we defensively reject non-loopback clients even
|
||||
# though uvicorn binds to 127.0.0.1.
|
||||
# Auth: loopback binds require no credential on the WS upgrade — the
|
||||
# peer-IP loopback gate + Host/Origin guard are the boundary. Gated
|
||||
# (non-loopback) binds require a single-use ``?ticket=`` (browser) or the
|
||||
# process-lifetime ``?internal=`` credential (server-spawned PTY child);
|
||||
# browsers can't set Authorization on a WS upgrade. Localhost-only on a
|
||||
# loopback bind — we defensively reject non-loopback clients.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
|
||||
@@ -10172,9 +10168,10 @@ def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
|
||||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
Loopback bind: only loopback clients allowed — the legacy
|
||||
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
||||
don't want LAN hosts guessing tokens.
|
||||
Loopback bind: only loopback clients allowed — there is no identity
|
||||
token on a loopback WS upgrade anymore, so the loopback-only peer gate
|
||||
(plus the Host/Origin guard) IS the boundary; we don't want LAN hosts
|
||||
reaching the credential-free loopback WS.
|
||||
|
||||
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
|
||||
specific address such as a Tailscale/LAN IP, always with
|
||||
@@ -10282,11 +10279,12 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
||||
machine-parseable token explaining the rejection (``no_credential``,
|
||||
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
|
||||
``credential`` names which credential type was presented (``ticket``,
|
||||
``internal``, ``token``, or ``none``) so the accepted path can log *how*
|
||||
a peer authed, not just that it did.
|
||||
``internal``, or ``none``/``loopback``) so the accepted path can log
|
||||
*how* a peer authed, not just that it did.
|
||||
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
Loopback / ``--insecure``: NO credential is consulted (returns
|
||||
``(None, "loopback")``). The peer-IP loopback gate + Host/Origin guard
|
||||
are the boundary.
|
||||
|
||||
Gated (public bind, no ``--insecure``): one of two credentials —
|
||||
|
||||
@@ -10304,6 +10302,13 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
||||
(the SPA bundle isn't carrying the token any longer, and a leaked
|
||||
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
||||
|
||||
Loopback / ``--insecure``: NO per-connection identity token. The
|
||||
loopback peer-IP gate (``_ws_client_is_allowed``) and the Host/Origin
|
||||
guard (``_ws_host_origin_is_allowed``) are the boundary here — the WS
|
||||
analogue of "the loopback bind is the security boundary" on the HTTP
|
||||
side. There is no token to present (the legacy ``_SESSION_TOKEN`` is
|
||||
being removed).
|
||||
|
||||
Audit-logs the rejection so operators can debug "WS keeps closing"
|
||||
issues from the log.
|
||||
"""
|
||||
@@ -10351,12 +10356,10 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
||||
)
|
||||
return "ticket_invalid", "ticket"
|
||||
|
||||
token = ws.query_params.get("token", "")
|
||||
if not token:
|
||||
return "no_credential", "none"
|
||||
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||
return None, "token"
|
||||
return "token_mismatch", "token"
|
||||
# Loopback / --insecure: no identity token. The peer-IP loopback gate
|
||||
# and Host/Origin guard (applied by the WS handlers via
|
||||
# _ws_request_is_allowed) are the boundary; there is no token to check.
|
||||
return None, "loopback"
|
||||
|
||||
|
||||
def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
@@ -10454,10 +10457,11 @@ def _resolve_chat_argv(
|
||||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
||||
|
||||
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
||||
Loopback / ``--insecure``: a bare ``/api/ws`` URL with no credential —
|
||||
the child connects from loopback, which the WS peer-IP + Host/Origin
|
||||
guard accepts without a token (there is no identity token anymore).
|
||||
|
||||
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
||||
server-spawned PTY child authenticates with the process-lifetime internal
|
||||
Gated mode: the child authenticates with the process-lifetime internal
|
||||
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
||||
the child reads this URL once at startup and reuses it on every reconnect,
|
||||
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
||||
@@ -10478,16 +10482,17 @@ def _build_gateway_ws_url() -> Optional[str]:
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
# Loopback: no credential needed (peer-IP + Host/Origin guard is the gate).
|
||||
return f"ws://{netloc}/api/ws"
|
||||
|
||||
|
||||
def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
"""ws:// URL the PTY child should publish events to, or None when unbound.
|
||||
|
||||
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
||||
Loopback / ``--insecure``: a bare ``/api/pub`` URL with no credential
|
||||
(the child connects from loopback; the peer-IP + Host/Origin guard is
|
||||
the gate).
|
||||
|
||||
Gated mode: authenticates with the process-lifetime internal credential
|
||||
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
||||
@@ -10514,9 +10519,9 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
qs = urllib.parse.urlencode(
|
||||
{"internal": internal_ws_credential(), "channel": channel}
|
||||
)
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
||||
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
# Loopback: no credential; only the channel is needed.
|
||||
qs = urllib.parse.urlencode({"channel": channel})
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
|
||||
|
||||
@@ -10846,37 +10851,30 @@ def mount_spa(application: FastAPI):
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
"""Return index.html with the base-path + auth-mode flag injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
|
||||
When the OAuth auth gate is active (``app.state.auth_required``),
|
||||
the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads
|
||||
identity from ``/api/auth/me`` over cookie auth instead. The
|
||||
``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right
|
||||
auth scheme for /api/pty and /api/ws (ticket vs token).
|
||||
No identity token is injected in either mode. On a loopback bind the
|
||||
SPA needs no credential (the bind is the boundary; the CSRF guard
|
||||
covers mutations). When the OAuth gate is active
|
||||
(``app.state.auth_required``) the SPA reads identity from
|
||||
``/api/auth/me`` over cookie auth. The ``__HERMES_AUTH_REQUIRED__``
|
||||
flag lets the SPA pick the right WS-auth scheme for /api/pty and
|
||||
/api/ws (ticket in gated mode, no credential on loopback).
|
||||
"""
|
||||
html = _index_path.read_text(encoding="utf-8")
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
gated = bool(getattr(app.state, "auth_required", False))
|
||||
gated_js = "true" if gated else "false"
|
||||
if gated:
|
||||
bootstrap_script = (
|
||||
f"<script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
else:
|
||||
bootstrap_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
bootstrap_script = (
|
||||
f"<script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
@@ -12024,10 +12022,13 @@ def start_server(
|
||||
", ".join(p.name for p in list_providers()),
|
||||
)
|
||||
elif host not in _LOOPBACK_HOST_VALUES and allow_public:
|
||||
# --insecure path — no auth, loud warning.
|
||||
# --insecure path — no identity gate, loud warning.
|
||||
_log.warning(
|
||||
"Binding to %s with --insecure — the dashboard has no robust "
|
||||
"authentication. Only use on trusted networks.", host,
|
||||
"Binding to %s with --insecure — no identity authentication. "
|
||||
"The Sec-Fetch-Site CSRF guard and the WebSocket Host/Origin "
|
||||
"guard still apply, but anyone who can reach this address can "
|
||||
"use the dashboard. Rely on network controls; only use on "
|
||||
"trusted networks.", host,
|
||||
)
|
||||
|
||||
# Record the bound host so host_header_middleware can validate incoming
|
||||
|
||||
136
tests/hermes_cli/test_csrf_sec_fetch_guard.py
Normal file
136
tests/hermes_cli/test_csrf_sec_fetch_guard.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Sec-Fetch-Site CSRF guard on mutating /api/* routes.
|
||||
|
||||
The guard replaces the legacy ``_SESSION_TOKEN``'s only robust
|
||||
contribution — blocking drive-by CSRF from a web page the user visits —
|
||||
with a credential-free, browser-asserted check that applies in BOTH auth
|
||||
regimes. ``Sec-Fetch-Site`` is a forbidden header name (JS cannot forge
|
||||
it), so a cross-origin page cannot spoof ``same-origin``.
|
||||
|
||||
Scope decision (plan Q2): mutating methods only. Reads are already
|
||||
neutralised by the CORSMiddleware (localhost-only origin regex,
|
||||
allow_credentials off), which prevents a foreign origin from reading any
|
||||
``/api/*`` response body.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
yield client
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# A real state-changing route. The CSRF guard runs BEFORE auth, so the
|
||||
# blocked cases 403 regardless of token; the allowed cases carry a valid
|
||||
# token so a non-403 proves the guard let them through to auth+handler.
|
||||
_MUTATING_ROUTE = "/api/providers/validate"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
|
||||
def test_cross_origin_mutation_blocked(loopback_client, sfs):
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("error") == "cross_origin_blocked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
|
||||
def test_same_origin_mutation_allowed(loopback_client, sfs):
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
# Reaches the handler (any non-403): the CSRF guard let it through.
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_absent_header_fails_open(loopback_client):
|
||||
"""Non-browser clients (curl, NAS probe, desktop) send no
|
||||
Sec-Fetch-Site and must NOT be blocked."""
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_cross_site_get_not_blocked(loopback_client):
|
||||
"""Reads are CORS-covered, not CSRF-guarded (mutations-only scope)."""
|
||||
r = loopback_client.get(
|
||||
"/api/status", headers={"Sec-Fetch-Site": "cross-site"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_guard_applies_in_gated_mode():
|
||||
"""The guard is mode-agnostic: a cross-site mutation from an
|
||||
AUTHENTICATED session is still blocked in gated mode by the CSRF guard.
|
||||
|
||||
A cookieless gated request 401s at the cookie gate before the CSRF
|
||||
guard runs (Starlette runs last-registered-middleware outermost, so
|
||||
the auth gate is outer). To prove the CSRF guard actually fires in
|
||||
gated mode we must carry a valid session cookie so the request gets
|
||||
past the gate and reaches the guard, which then 403s the cross-site
|
||||
mutation.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
clear_providers()
|
||||
provider = StubAuthProvider()
|
||||
register_provider(provider)
|
||||
web_server.app.state.auth_required = True
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
try:
|
||||
# Mint a real session via the stub's login round trip.
|
||||
start = provider.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
|
||||
state = start.cookie_payload["hermes_session_pkce"].split("state=")[1].split(";")[0]
|
||||
verifier = start.cookie_payload["hermes_session_pkce"].split("verifier=")[1]
|
||||
session = provider.complete_login(
|
||||
code="stub_code", state=state, code_verifier=verifier,
|
||||
redirect_uri="https://fly-app.fly.dev/auth/callback",
|
||||
)
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
client.cookies.set(SESSION_AT_COOKIE, session.access_token)
|
||||
r = client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={"Sec-Fetch-Site": "cross-site"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("error") == "cross_origin_blocked"
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.auth_required = prev_required
|
||||
web_server.app.state.bound_host = prev_host
|
||||
@@ -17,14 +17,16 @@ def _client():
|
||||
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
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
client = TestClient(app)
|
||||
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed. A literal
|
||||
# header name is returned so the "bogus token is ignored" tests can still
|
||||
# send an arbitrary header under it.
|
||||
# Keep the state DB under the isolated HERMES_HOME for any handler that
|
||||
# touches it.
|
||||
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
return client, _SESSION_HEADER_NAME
|
||||
return client, "X-Hermes-Session-Token"
|
||||
|
||||
|
||||
class TestMcpEndpoints:
|
||||
@@ -677,15 +679,33 @@ class TestWebhookToggleEndpoint:
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
"""Every admin endpoint must sit behind the dashboard auth gate.
|
||||
|
||||
Identity enforcement lives in the pluggable OAuth gate (gated mode),
|
||||
not on loopback — after the legacy-token teardown, a loopback bind has
|
||||
no identity gate (the bind is the boundary). So this exercises the
|
||||
GATED regime: a cookieless request to each admin endpoint must 401.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
# No session header → must be rejected.
|
||||
self.client = TestClient(app)
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
self._prev_host = getattr(app.state, "bound_host", None)
|
||||
self._prev_required = getattr(app.state, "auth_required", None)
|
||||
app.state.bound_host = "fly-app.fly.dev"
|
||||
app.state.auth_required = True
|
||||
# Cookieless client → the gate must reject every admin endpoint.
|
||||
self.client = TestClient(app, base_url="https://fly-app.fly.dev")
|
||||
yield
|
||||
clear_providers()
|
||||
app.state.bound_host = self._prev_host
|
||||
app.state.auth_required = self._prev_required
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
@@ -923,15 +943,20 @@ class TestDebugShareEndpoint:
|
||||
r = self.client.post("/api/ops/debug-share", json={"redact": True})
|
||||
assert r.status_code == 502
|
||||
|
||||
def test_requires_session_token(self):
|
||||
# Drop the token header and confirm the global auth gate rejects it.
|
||||
def test_loopback_has_no_identity_gate(self):
|
||||
# After the legacy-token teardown, loopback enforces no identity
|
||||
# gate: the bind is the boundary and the CSRF guard covers
|
||||
# cross-origin mutations. A bogus token is simply ignored, and the
|
||||
# request reaches the handler (any non-401). Identity enforcement
|
||||
# for this endpoint is exercised in gated mode by
|
||||
# TestAdminEndpointsAuthGate.
|
||||
bare = self.client
|
||||
r = bare.post(
|
||||
"/api/ops/debug-share",
|
||||
json={"redact": True},
|
||||
headers={self.header: "wrong-token"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
class TestToolsConfigEndpoints:
|
||||
@@ -1049,7 +1074,11 @@ class TestToolsConfigEndpoints:
|
||||
assert body["pid"] == 4321
|
||||
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
|
||||
|
||||
def test_endpoints_require_session_token(self):
|
||||
def test_loopback_endpoints_have_no_identity_gate(self):
|
||||
# Loopback: no identity gate after the legacy-token teardown. A
|
||||
# bogus token is ignored and the request reaches the handler (any
|
||||
# non-401). The gated regime enforces identity (see
|
||||
# TestAdminEndpointsAuthGate).
|
||||
for method, path, payload in [
|
||||
("get", "/api/tools/toolsets/web/config", None),
|
||||
("put", "/api/tools/toolsets/web/env", {"env": {}}),
|
||||
@@ -1060,4 +1089,4 @@ class TestToolsConfigEndpoints:
|
||||
if payload is not None:
|
||||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
assert r.status_code != 401, f"{method} {path} unexpectedly gated"
|
||||
|
||||
@@ -40,36 +40,16 @@ def test_loopback_status_is_public(client_loopback):
|
||||
assert "version" in body
|
||||
|
||||
|
||||
def test_loopback_protected_route_requires_token(client_loopback):
|
||||
"""Any non-public /api/ route must require the session token."""
|
||||
# /api/sessions exists and is auth-gated by auth_middleware.
|
||||
r = client_loopback.get("/api/sessions")
|
||||
assert r.status_code == 401
|
||||
def test_loopback_protected_route_no_identity_gate(client_loopback):
|
||||
"""Loopback has no identity gate (the bind is the boundary).
|
||||
|
||||
|
||||
def test_loopback_protected_route_accepts_session_token(client_loopback):
|
||||
"""The injected SPA token unlocks protected /api/ routes."""
|
||||
r = client_loopback.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
|
||||
# 500 is also acceptable if there's a downstream issue unrelated to auth.
|
||||
assert r.status_code != 401, (
|
||||
f"Expected auth to succeed but got 401; body: {r.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_loopback_index_injects_session_token(client_loopback):
|
||||
"""Loopback mode keeps injecting the SPA token into index.html.
|
||||
|
||||
This is the property that the new auth gate MUST disable once a gated
|
||||
bind is detected. Phase 3 will add an inverse test for the gated path.
|
||||
Pre-teardown this route 401'd without the session token. After the
|
||||
legacy-token teardown (Phase 2), loopback ``/api/`` routes are served
|
||||
without an identity check — the loopback bind + CSRF guard + CORS are
|
||||
the security boundary, not a per-request token.
|
||||
"""
|
||||
r = client_loopback.get("/")
|
||||
if r.status_code == 404:
|
||||
pytest.skip("WEB_DIST not built in this env")
|
||||
assert "__HERMES_SESSION_TOKEN__" in r.text
|
||||
r = client_loopback.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_loopback_host_header_validation_still_enforced(client_loopback):
|
||||
|
||||
@@ -205,26 +205,32 @@ def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/p
|
||||
|
||||
|
||||
class TestWsAuthOkLoopback:
|
||||
"""Gate OFF — legacy token path."""
|
||||
"""Gate OFF — loopback has NO per-connection identity token.
|
||||
|
||||
def test_correct_token_accepted(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
|
||||
After the legacy-token teardown, ``_ws_auth_ok`` accepts every
|
||||
loopback WS upgrade: the real boundary is the peer-IP loopback gate
|
||||
(``_ws_client_is_allowed``) + the Host/Origin guard
|
||||
(``_ws_host_origin_is_allowed``), applied by the WS handlers via
|
||||
``_ws_request_is_allowed`` — the WS analogue of "the loopback bind is
|
||||
the HTTP security boundary". Any token/ticket/internal query param is
|
||||
simply ignored.
|
||||
"""
|
||||
|
||||
def test_no_token_accepted(self, loopback_app):
|
||||
ws = _fake_ws(query={})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_wrong_token_rejected(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": "not-the-real-token"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_missing_token_rejected(self, loopback_app):
|
||||
ws = _fake_ws(query={})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
def test_stale_token_ignored_still_accepted(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": "anything-at-all"})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_ticket_param_ignored_in_loopback(self, loopback_app):
|
||||
# Even if someone sneaks a ticket through, loopback mode only
|
||||
# cares about ?token=. A naked ticket isn't a token.
|
||||
# Loopback consults no credential; a ticket query param is neither
|
||||
# required nor rejected — the request is accepted on the bind/origin
|
||||
# boundary alone.
|
||||
ticket = mint_ticket(user_id="u1", provider="stub")
|
||||
ws = _fake_ws(query={"ticket": ticket})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
|
||||
class TestWsAuthOkGated:
|
||||
@@ -255,7 +261,7 @@ class TestWsAuthOkGated:
|
||||
"""Critical: gated mode must NOT honour the legacy token path
|
||||
even when someone has access to the in-process value of
|
||||
_SESSION_TOKEN (e.g. a leaked log line)."""
|
||||
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
|
||||
ws = _fake_ws(query={"token": "stale-token-ignored"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
|
||||
@@ -301,12 +307,13 @@ class TestWsAuthOkGated:
|
||||
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
|
||||
A naked internal credential must not authenticate."""
|
||||
def test_internal_credential_param_ignored_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode there is no credential check at all — loopback
|
||||
accepts the upgrade on the bind/origin boundary. An ``?internal=``
|
||||
query param is neither required nor rejected; it's simply ignored."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
|
||||
class TestWsRequestIsAllowedGated:
|
||||
@@ -495,11 +502,16 @@ class TestWsHostOriginGuardOrigins:
|
||||
|
||||
|
||||
class TestSidecarUrl:
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
def test_loopback_has_no_credential(self, loopback_app):
|
||||
# Loopback child connects from localhost; the peer-IP + Host/Origin
|
||||
# guard is the gate, so the sidecar URL carries no credential — just
|
||||
# the channel. (The legacy ?token= is gone.)
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
assert url is not None
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "token=" not in url
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" not in url
|
||||
assert "channel=ch-1" in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
@@ -532,11 +544,13 @@ class TestSidecarUrl:
|
||||
|
||||
|
||||
class TestGatewayWsUrl:
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
def test_loopback_has_no_credential(self, loopback_app):
|
||||
# Loopback: bare /api/ws with no credential (peer-IP + Host/Origin
|
||||
# guard is the gate; the legacy ?token= is gone).
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert "/api/ws?" in url
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert url.endswith("/api/ws")
|
||||
assert "token=" not in url
|
||||
assert "internal=" not in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
|
||||
203
tests/hermes_cli/test_legacy_token_teardown_baseline.py
Normal file
203
tests/hermes_cli/test_legacy_token_teardown_baseline.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Baseline harness for the legacy-session-token teardown.
|
||||
|
||||
Pins the CURRENT (pre-teardown) auth contract of BOTH dashboard regimes
|
||||
so the phased removal of ``_SESSION_TOKEN`` can prove it didn't regress
|
||||
the gated path or silently widen the public surface.
|
||||
|
||||
This file ADDS the contracts not already covered by
|
||||
``test_dashboard_auth_gate.py`` (which already pins loopback token
|
||||
enforcement, the ``should_require_auth`` truth table, and ``start_server``
|
||||
flag-stashing):
|
||||
|
||||
* gated mode IGNORES the legacy ``X-Hermes-Session-Token`` header
|
||||
* the WS auth matrix via ``_ws_auth_reason`` (loopback token vs gated
|
||||
ticket/internal)
|
||||
* no ``_require_token``-guarded sensitive path is in PUBLIC_API_PATHS
|
||||
|
||||
The expectations in this file are intentionally the PRE-teardown contract.
|
||||
Later phases edit the specific assertions they intentionally change (and
|
||||
the commit that changes them documents why).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests mutate ``web_server.app.state.auth_required`` at module
|
||||
# scope; share the xdist group used by every dashboard-auth gate test so
|
||||
# they don't race against each other.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
yield client
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_client():
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
yield client
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gated mode ignores the legacy session token (mutual-exclusivity invariant)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_ignores_legacy_token_header(gated_client):
|
||||
"""In gated mode the legacy token header is inert: a request carrying
|
||||
a *valid* ``X-Hermes-Session-Token`` and no cookie must still 401."""
|
||||
r = gated_client.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get("error") in ("unauthenticated", "session_expired")
|
||||
|
||||
|
||||
def test_gated_status_still_public(gated_client):
|
||||
"""``/api/status`` stays public in gated mode (NAS liveness probe)."""
|
||||
assert gated_client.get("/api/status").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback has no identity gate (post-Phase-2 contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_loopback_no_identity_gate(loopback_client):
|
||||
"""Loopback: the bind + CSRF guard + CORS are the boundary, not an
|
||||
identity token. A tokenless read is allowed."""
|
||||
r = loopback_client.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_loopback_still_blocks_cross_site_mutation(loopback_client):
|
||||
"""The CSRF guard (not an identity token) is what protects loopback
|
||||
mutations from a drive-by cross-origin page."""
|
||||
r = loopback_client.post(
|
||||
"/api/providers/validate",
|
||||
headers={"Sec-Fetch-Site": "cross-site"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WS auth matrix (via _ws_auth_reason — TestClient.websocket_connect is
|
||||
# unreliable for handshake-rejection assertions, so test the function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_ws(params: dict):
|
||||
class _Client:
|
||||
host = "127.0.0.1"
|
||||
|
||||
class _URL:
|
||||
path = "/api/ws"
|
||||
|
||||
class _WS:
|
||||
query_params = params
|
||||
client = _Client()
|
||||
url = _URL()
|
||||
|
||||
return _WS()
|
||||
|
||||
|
||||
def test_ws_loopback_no_token_required():
|
||||
"""Loopback WS accepts without a token: the peer-IP loopback gate +
|
||||
Host/Origin guard are the boundary (the WS analogue of the loopback
|
||||
bind being the HTTP boundary)."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({}))
|
||||
assert reason is None and cred == "loopback"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_loopback_token_ignored():
|
||||
"""A stale/garbage ``?token=`` on loopback is simply ignored (no
|
||||
identity token is consulted anymore)."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({"token": "anything"}))
|
||||
assert reason is None and cred == "loopback"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_gated_rejects_legacy_token():
|
||||
"""Gated mode never consults the legacy ``?token=`` path."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": "stale-token-ignored"})
|
||||
)
|
||||
assert reason == "no_credential" # token ignored; no ticket present
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _require_token gating invariant — no sensitive guarded path is public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_token_call_sites_exist():
|
||||
"""At least one handler still guards via ``_require_token``."""
|
||||
text = open(web_server.__file__).read()
|
||||
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
|
||||
assert n_sites >= 1
|
||||
|
||||
|
||||
def test_sensitive_paths_not_in_public_allowlist():
|
||||
"""The public allowlist must never contain a sensitive route. This is
|
||||
the audit invariant the gate relies on (a _require_token route that is
|
||||
also public-allowlisted gets no session attached and 401s under the
|
||||
gate even after the loopback teardown)."""
|
||||
for sensitive in (
|
||||
"/api/env/reveal",
|
||||
"/api/providers/validate",
|
||||
"/api/dashboard/agent-plugins/install",
|
||||
):
|
||||
assert sensitive not in PUBLIC_API_PATHS
|
||||
@@ -187,12 +187,11 @@ def test_migration_disables_existing_dangerous_entry(tmp_path):
|
||||
|
||||
def test_dashboard_mcp_add_rejects_dangerous_entry():
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/mcp/servers",
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
json={"name": "evil", **_dangerous_entry()},
|
||||
)
|
||||
|
||||
|
||||
@@ -28,10 +28,12 @@ import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli.web_server import _SESSION_TOKEN, app
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
client = TestClient(app)
|
||||
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
|
||||
# Loopback bind has no identity gate; no session header needed. Kept as an
|
||||
# empty mapping so the existing call sites can keep passing headers=HEADERS.
|
||||
HEADERS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):
|
||||
|
||||
@@ -184,35 +184,6 @@ class TestRedactKey:
|
||||
assert "not set" in result.lower() or result == "***" or "\x1b" in result
|
||||
|
||||
|
||||
class TestSessionTokenInjection:
|
||||
"""The desktop shell mints HERMES_DASHBOARD_SESSION_TOKEN and signs its
|
||||
/api + /api/ws calls with it. The backend must adopt that token, else every
|
||||
desktop request 401s ("gateway is offline"). A main-merge once silently
|
||||
dropped this read — this guards the contract, not a literal value.
|
||||
"""
|
||||
|
||||
def test_honors_injected_token(self, monkeypatch):
|
||||
import importlib
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
|
||||
try:
|
||||
importlib.reload(ws)
|
||||
assert ws._SESSION_TOKEN == "desktop-seeded-token"
|
||||
finally:
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
importlib.reload(ws)
|
||||
|
||||
def test_falls_back_to_random_token(self, monkeypatch):
|
||||
import importlib
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
importlib.reload(ws)
|
||||
|
||||
assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# web_server tests (FastAPI endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -231,12 +202,12 @@ class TestWebServerEndpoints:
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def test_get_status(self):
|
||||
resp = self.client.get("/api/status")
|
||||
@@ -305,15 +276,23 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.get("/api/media", params={"path": str(missing)})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_media_requires_auth(self):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
def test_get_media_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
Pre-teardown a wrong/absent session token 401'd this route. Now the
|
||||
loopback bind itself is the security boundary (plus the Sec-Fetch-Site
|
||||
CSRF guard for mutations and CORS for cross-origin reads), so the
|
||||
request reaches the handler regardless of the token. ``/tmp/x.png`` is
|
||||
outside the media roots, so the handler returns 403 — the point is it's
|
||||
no longer 401. Identity is enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.get(
|
||||
"/api/media",
|
||||
params={"path": "/tmp/x.png"},
|
||||
headers={_SESSION_HEADER_NAME: "wrong-token"},
|
||||
headers={"X-Hermes-Session-Token": "wrong-token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 403
|
||||
|
||||
# ── Dashboard font override ─────────────────────────────────────────
|
||||
|
||||
@@ -1358,12 +1337,10 @@ class TestWebServerEndpoints:
|
||||
def test_reveal_env_var(self, tmp_path):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_KEY"},
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@@ -1372,19 +1349,32 @@ class TestWebServerEndpoints:
|
||||
|
||||
def test_reveal_env_var_not_found(self):
|
||||
"""POST /api/env/reveal should 404 for unknown keys."""
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "NONEXISTENT_KEY_XYZ"},
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_reveal_env_var_no_token(self, tmp_path):
|
||||
"""POST /api/env/reveal without token should return 401."""
|
||||
def test_reveal_env_var_no_identity_gate_on_loopback(self, tmp_path):
|
||||
"""POST /api/env/reveal has no identity gate on loopback.
|
||||
|
||||
After the legacy-token teardown, loopback ``/api/`` routes are served
|
||||
without an identity check — the loopback bind is the security boundary
|
||||
(plus the Sec-Fetch-Site CSRF guard for mutations and CORS for reads),
|
||||
not a per-request token. So a tokenless loopback request reaches the
|
||||
handler and reveals the value. Identity for this sensitive endpoint is
|
||||
enforced in gated mode (see
|
||||
``test_reveal_env_var_requires_auth_in_gated_mode`` below).
|
||||
"""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.config import save_env_value
|
||||
# The reveal endpoint's module-global rate limiter (5/30s) is now
|
||||
# exercised by tokenless tests that reach the handler (pre-teardown
|
||||
# they 401'd before it). Reset it so the shared window doesn't bleed
|
||||
# 429s across reveal tests.
|
||||
web_server._reveal_timestamps.clear()
|
||||
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
|
||||
# Use a fresh client WITHOUT the dashboard session header
|
||||
unauth_client = TestClient(app)
|
||||
@@ -1392,31 +1382,70 @@ class TestWebServerEndpoints:
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_NOAUTH"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_reveal_env_var_bad_token(self, tmp_path):
|
||||
"""POST /api/env/reveal with wrong token should return 401."""
|
||||
def test_reveal_env_var_requires_auth_in_gated_mode(self, tmp_path):
|
||||
"""In gated mode (non-loopback bind + registered provider), the
|
||||
sensitive /api/env/reveal endpoint requires a session cookie and 401s
|
||||
without one. This preserves identity coverage for the secret-revealing
|
||||
endpoint that loopback mode intentionally no longer gates.
|
||||
"""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
save_env_value("TEST_REVEAL_GATED", "secret-value")
|
||||
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_req = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
resp = client.post("/api/env/reveal", json={"key": "TEST_REVEAL_GATED"})
|
||||
assert resp.status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = prev_req
|
||||
|
||||
def test_reveal_env_var_bad_token_no_identity_gate_on_loopback(self, tmp_path):
|
||||
"""POST /api/env/reveal with a wrong token still serves on loopback.
|
||||
|
||||
The legacy session token is ignored on loopback (it's slated for
|
||||
removal). Loopback has no identity gate — the bind + CSRF guard are the
|
||||
boundary — so a request with a bogus token reaches the handler instead
|
||||
of 401ing. Identity is enforced only in gated mode.
|
||||
"""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_BADAUTH"},
|
||||
headers={_SESSION_HEADER_NAME: "wrong-token-here"},
|
||||
headers={"X-Hermes-Session-Token": "wrong-token-here"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path):
|
||||
"""A valid dashboard session header should coexist with proxy auth."""
|
||||
"""A stale dashboard session header should be ignored, not break the
|
||||
request: on loopback there's no identity gate, and a proxy
|
||||
``Authorization`` header must not interfere with the reveal."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
save_env_value("TEST_REVEAL_PROXY_AUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_PROXY_AUTH"},
|
||||
headers={
|
||||
_SESSION_HEADER_NAME: _SESSION_TOKEN,
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
"Authorization": "Basic dXNlcjpwYXNz",
|
||||
},
|
||||
)
|
||||
@@ -1424,19 +1453,26 @@ class TestWebServerEndpoints:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path):
|
||||
"""Keep old dashboard bundles working while the new header rolls out."""
|
||||
def test_reveal_env_var_legacy_authorization_header_ignored_on_loopback(self, tmp_path):
|
||||
"""The legacy ``Authorization: Bearer <token>`` mechanism is being
|
||||
removed. On loopback there is no identity gate, so the request succeeds
|
||||
regardless of the legacy header — it's served because the loopback bind
|
||||
is the security boundary, NOT because the Bearer token authenticated.
|
||||
(Previously this test asserted the legacy header itself authenticated;
|
||||
that token mechanism is slated for deletion.)
|
||||
"""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_TOKEN
|
||||
|
||||
save_env_value("TEST_REVEAL_LEGACY_AUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_LEGACY_AUTH"},
|
||||
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
|
||||
headers={"Authorization": "Bearer stale-token-ignored"},
|
||||
)
|
||||
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_get_messaging_platforms(self):
|
||||
resp = self.client.get("/api/messaging/platforms")
|
||||
@@ -1904,23 +1940,37 @@ class TestWebServerEndpoints:
|
||||
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."""
|
||||
def test_api_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
Pre-teardown, ``/api/*`` requests without the session token 401'd
|
||||
(except a public allowlist). Now the loopback bind itself is the
|
||||
security boundary — plus the Sec-Fetch-Site CSRF guard for mutations
|
||||
and CORS for cross-origin reads — so a tokenless loopback request
|
||||
reaches the handler. Both the formerly-gated routes and the
|
||||
formerly-public routes now serve. Identity is enforced only in gated
|
||||
mode.
|
||||
"""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
# Create a client WITHOUT the dashboard session header
|
||||
unauth_client = TestClient(app)
|
||||
# Formerly-gated routes now serve without a token (no identity gate).
|
||||
resp = unauth_client.get("/api/env")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
resp = unauth_client.get("/api/config")
|
||||
assert resp.status_code == 401
|
||||
# Public endpoints should still work
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
# Public endpoints still work, as before.
|
||||
resp = unauth_client.get("/api/status")
|
||||
assert resp.status_code == 200
|
||||
resp = unauth_client.get("/api/dashboard/plugins")
|
||||
assert resp.status_code == 200
|
||||
# Formerly-gated rescan endpoint now serves on loopback too.
|
||||
resp = unauth_client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
resp = self.client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -2461,9 +2511,9 @@ class TestConfigRoundTrip:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def test_get_config_no_internal_keys(self):
|
||||
"""GET /api/config should not expose _config_version or _model_meta."""
|
||||
@@ -2597,12 +2647,12 @@ class TestNewEndpoints:
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def test_get_logs_default(self):
|
||||
resp = self.client.get("/api/logs")
|
||||
@@ -3939,9 +3989,9 @@ class TestStatusRemoteGateway:
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def test_status_falls_back_to_remote_probe(self, monkeypatch):
|
||||
"""When local PID check fails and remote probe succeeds, gateway shows running."""
|
||||
@@ -4359,7 +4409,7 @@ class TestBulkDeleteSessionsEndpoint:
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
|
||||
@@ -4367,7 +4417,7 @@ class TestBulkDeleteSessionsEndpoint:
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def _seed(self, ids):
|
||||
from hermes_state import SessionDB
|
||||
@@ -4379,9 +4429,18 @@ class TestBulkDeleteSessionsEndpoint:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_requires_auth(self):
|
||||
def test_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
Pre-teardown this destructive route 401'd without the session token.
|
||||
Now the loopback bind is the security boundary (the Sec-Fetch-Site CSRF
|
||||
guard blocks cross-origin mutations and CORS blocks cross-origin reads),
|
||||
so a tokenless same-origin loopback request reaches the handler.
|
||||
Identity is enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_deletes_listed_sessions_only(self):
|
||||
from hermes_state import SessionDB
|
||||
@@ -4483,7 +4542,7 @@ class TestDeleteEmptySessionsEndpoint:
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
# Pin the SessionDB to the isolated HERMES_HOME so each test
|
||||
# starts with a clean state.db.
|
||||
@@ -4493,7 +4552,7 @@ class TestDeleteEmptySessionsEndpoint:
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def _seed(self):
|
||||
"""Build the standard test corpus:
|
||||
@@ -4524,19 +4583,31 @@ class TestDeleteEmptySessionsEndpoint:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_count_endpoint_requires_auth(self):
|
||||
"""GET /api/sessions/empty/count must 401 without the session token."""
|
||||
def test_count_endpoint_no_identity_gate_on_loopback(self):
|
||||
"""GET /api/sessions/empty/count has no identity gate on loopback.
|
||||
|
||||
After the legacy-token teardown, the loopback bind is the security
|
||||
boundary (plus the Sec-Fetch-Site CSRF guard and CORS), so a tokenless
|
||||
loopback request reaches the handler instead of 401ing. Identity is
|
||||
enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.get("/api/sessions/empty/count")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_delete_endpoint_requires_auth(self):
|
||||
"""DELETE /api/sessions/empty must 401 without the session token.
|
||||
def test_delete_endpoint_no_identity_gate_on_loopback(self):
|
||||
"""DELETE /api/sessions/empty has no identity gate on loopback.
|
||||
|
||||
Regression guard for issue #19533 — the bulk-delete is a strictly
|
||||
destructive primitive, the middleware must gate it even if a
|
||||
future refactor introduces a non-auth path."""
|
||||
Pre-teardown (issue #19533) this destructive route 401'd without the
|
||||
session token. After the legacy-token teardown, loopback has no
|
||||
identity gate — the loopback bind is the boundary and the
|
||||
Sec-Fetch-Site CSRF guard blocks cross-origin mutations — so a
|
||||
tokenless same-origin loopback request reaches the handler. Identity is
|
||||
enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.delete("/api/sessions/empty")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_count_returns_only_empty_ended_unarchived(self):
|
||||
"""With the standard corpus, the count is exactly 2 — only
|
||||
@@ -4601,13 +4672,23 @@ class TestDeleteEmptySessionsEndpoint:
|
||||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
"""Plugin API routes have no identity gate on loopback (post-teardown).
|
||||
|
||||
Pre-teardown (issue #19533) plugin ``/api/plugins/*`` routes required the
|
||||
session token and 401'd without it. After the legacy-token teardown,
|
||||
loopback has no identity gate — the loopback bind is the security boundary,
|
||||
the Sec-Fetch-Site CSRF guard blocks cross-origin mutations, and CORS blocks
|
||||
cross-origin reads. So tokenless loopback plugin requests now reach the
|
||||
handler (or the router's own 404/422), never 401. Identity is enforced only
|
||||
in gated mode.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
|
||||
"""Create a TestClient without the session token header.
|
||||
|
||||
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
|
||||
Pulls in ``_install_example_plugin`` so
|
||||
``test_plugin_route_serves_on_loopback_with_or_without_token``
|
||||
has the ``/api/plugins/example/hello`` endpoint available — the
|
||||
example plugin is no longer a bundled plugin, so the fixture
|
||||
installs it into the per-test ``HERMES_HOME``.
|
||||
@@ -4619,77 +4700,95 @@ class TestPluginAPIAuth:
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
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
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def test_plugin_route_requires_auth(self):
|
||||
"""Plugin API routes should return 401 without a valid session token."""
|
||||
def test_plugin_route_no_identity_gate_on_loopback(self):
|
||||
"""Plugin API GET routes serve on loopback without a session token."""
|
||||
# Use a known plugin route (kanban board)
|
||||
resp = self.client.get("/api/plugins/kanban/board")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
def test_plugin_route_serves_on_loopback_with_or_without_token(self):
|
||||
"""Plugin API routes serve on loopback regardless of the session token.
|
||||
|
||||
Uses ``/api/plugins/example/hello`` from the example-dashboard
|
||||
test fixture (installed into HERMES_HOME by the class-level
|
||||
``_install_example_plugin`` fixture) — a stable, side-effect-free
|
||||
GET that's only loaded for tests. With a valid token the handler
|
||||
should run (200); without one the middleware should 401 before
|
||||
the handler is reached.
|
||||
GET that's only loaded for tests. Pre-teardown a tokenless request
|
||||
401'd; now loopback has no identity gate, so the handler runs (200)
|
||||
whether or not the legacy token is present. Identity is enforced only
|
||||
in gated mode.
|
||||
"""
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
# Without a token: loopback has no identity gate, handler runs.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
# With auth: handler runs.
|
||||
# With the (now-ignored) token: handler still 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_post_no_identity_gate_on_loopback(self):
|
||||
"""Plugin POST routes serve on loopback without a session token.
|
||||
|
||||
def test_plugin_patch_requires_auth(self):
|
||||
"""Plugin PATCH routes should return 401 without a valid session token.
|
||||
The Sec-Fetch-Site CSRF guard blocks cross-origin mutations, but a
|
||||
same-origin loopback POST has no hostile Sec-Fetch-Site header and
|
||||
reaches the handler.
|
||||
"""
|
||||
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_patch_no_identity_gate_on_loopback(self):
|
||||
"""Plugin PATCH routes serve on loopback without a 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.
|
||||
kanban task edits. Pre-teardown a tokenless PATCH 401'd; now loopback
|
||||
has no identity gate (the bind + Sec-Fetch-Site CSRF guard are the
|
||||
boundary) so the request reaches the handler. ``t_fake`` doesn't exist,
|
||||
so the handler/router responds non-401 (e.g. 404/422) — the point is
|
||||
it's no longer gated. Identity is enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.patch(
|
||||
"/api/plugins/kanban/tasks/t_fake",
|
||||
json={"title": "renamed"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
|
||||
def test_plugin_delete_requires_auth(self):
|
||||
"""Plugin DELETE routes should return 401 without a valid session token."""
|
||||
def test_plugin_delete_no_identity_gate_on_loopback(self):
|
||||
"""Plugin DELETE routes serve on loopback without a session token.
|
||||
|
||||
Loopback has no identity gate; ``t_fake`` doesn't exist so the handler
|
||||
responds non-401 (404). Identity is enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
|
||||
def test_non_kanban_plugin_route_requires_auth(self):
|
||||
"""Auth must be plugin-agnostic, not kanban-specific.
|
||||
def test_non_kanban_plugin_route_no_identity_gate_on_loopback(self):
|
||||
"""The loopback no-identity-gate behavior is plugin-agnostic.
|
||||
|
||||
The middleware fix is at the gate level (no per-plugin allowlist),
|
||||
The gate change is at the middleware 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.
|
||||
plugins — and even a non-existent plugin namespace are no longer 401'd
|
||||
on loopback. Pre-teardown these 401'd before routing could 404. Now the
|
||||
router decides: a missing route/plugin yields 404 (not 401). Identity is
|
||||
enforced only in gated mode.
|
||||
"""
|
||||
# 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.
|
||||
assert resp.status_code != 401
|
||||
# A plugin namespace that doesn't exist: now 404 from the router,
|
||||
# not a 401 from a removed identity gate.
|
||||
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_plugin_websocket_unaffected_by_http_middleware(self):
|
||||
"""The kanban /events WebSocket has its own ``?token=`` check;
|
||||
@@ -4861,7 +4960,9 @@ class TestPtyWebSocket:
|
||||
# its own fake argv via ``ws._resolve_chat_argv``.
|
||||
self.ws_module = ws
|
||||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
self.token = ws._SESSION_TOKEN
|
||||
# Loopback ignores any ?token= on the WS upgrade (no identity gate);
|
||||
# a literal keeps _url() working for the connect tests below.
|
||||
self.token = "ignored"
|
||||
self.client = TestClient(ws.app)
|
||||
|
||||
def _url(self, token: str | None = None, **params: str) -> str:
|
||||
@@ -4930,31 +5031,62 @@ class TestPtyWebSocket:
|
||||
pass
|
||||
assert exc.value.code == 4404
|
||||
|
||||
def test_rejects_missing_token(self, monkeypatch):
|
||||
def test_loopback_accepts_without_token(self, monkeypatch):
|
||||
"""Loopback /api/pty needs no token after the legacy-token teardown.
|
||||
|
||||
The peer-IP loopback gate + Host/Origin guard are the boundary
|
||||
(the WS analogue of the loopback bind being the HTTP boundary), so
|
||||
a tokenless upgrade is accepted and the PTY child spawns. WS auth
|
||||
rejection in GATED mode is covered by test_dashboard_auth_ws_auth.py.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
# No token in the query string at all → still connects on loopback.
|
||||
with self.client.websocket_connect("/api/pty") as conn:
|
||||
import time
|
||||
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with self.client.websocket_connect("/api/pty"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
buf += conn.receive_bytes()
|
||||
except Exception:
|
||||
break
|
||||
if b"hermes-ws-ok" in buf:
|
||||
break
|
||||
assert b"hermes-ws-ok" in buf
|
||||
|
||||
def test_rejects_bad_token(self, monkeypatch):
|
||||
def test_loopback_ignores_stale_token(self, monkeypatch):
|
||||
"""A stale/garbage ``?token=`` on loopback is ignored, not rejected."""
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
with self.client.websocket_connect(self._url(token="wrong")) as conn:
|
||||
import time
|
||||
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with self.client.websocket_connect(self._url(token="wrong")):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
buf += conn.receive_bytes()
|
||||
except Exception:
|
||||
break
|
||||
if b"hermes-ws-ok" in buf:
|
||||
break
|
||||
assert b"hermes-ws-ok" in buf
|
||||
|
||||
def test_streams_child_stdout_to_client(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
@@ -5118,7 +5250,9 @@ class TestPtyWebSocket:
|
||||
url = captured.get("sidecar_url") or ""
|
||||
assert url.startswith("ws://127.0.0.1:9119/api/pub?")
|
||||
assert "channel=abc-123" in url
|
||||
assert "token=" in url
|
||||
# Loopback sidecar URL carries no credential — the bind + peer-IP guard
|
||||
# are the boundary (the legacy ?token= is gone).
|
||||
assert "token=" not in url
|
||||
|
||||
def test_pub_broadcasts_to_events_subscribers(self):
|
||||
"""A frame handed to _broadcast_event is sent verbatim to every
|
||||
@@ -5204,8 +5338,10 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
|
||||
|
||||
assert env is not None
|
||||
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
|
||||
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
|
||||
assert "token=" in gateway_url
|
||||
# Loopback gateway URL is a bare /api/ws with no credential (the legacy
|
||||
# ?token= is gone; the loopback bind + peer-IP guard are the boundary).
|
||||
assert gateway_url == "ws://127.0.0.1:9119/api/ws"
|
||||
assert "token=" not in gateway_url
|
||||
|
||||
|
||||
class TestDashboardPluginStaticAssetAllowlist:
|
||||
@@ -5331,10 +5467,10 @@ class TestValidateProviderCredential:
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
|
||||
def _post(self, key, value):
|
||||
return self.client.post("/api/providers/validate", json={"key": key, "value": value})
|
||||
|
||||
@@ -7,6 +7,10 @@ from starlette.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
# These tests mutate web_server.app.state (auth_required / bound_host); share
|
||||
# the dashboard-auth xdist group so they don't race other app.state mutators.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
|
||||
def _client_with_app_state():
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
@@ -15,7 +19,7 @@ def _client_with_app_state():
|
||||
web_server.app.state.bound_host = None
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
return client, prev_auth_required, prev_bound_host
|
||||
|
||||
|
||||
@@ -276,42 +280,56 @@ def test_download_returns_file_as_attachment(forced_files_client):
|
||||
assert "hello.txt" in disposition
|
||||
|
||||
|
||||
def test_download_authenticates_via_query_token(forced_files_client):
|
||||
def test_download_no_identity_gate_on_loopback(forced_files_client):
|
||||
"""Loopback download needs no credential after the legacy-token teardown.
|
||||
|
||||
The browser/shell-opened download (which can't set a session header) just
|
||||
works on a loopback bind — the bind is the security boundary. The old
|
||||
``?token=`` query-param escape hatch is gone with the token. Gated-mode
|
||||
enforcement is pinned by test_download_requires_auth_in_gated_mode below.
|
||||
"""
|
||||
client, root = forced_files_client
|
||||
file_path = _seed_file(client, root)
|
||||
|
||||
# Drop the session header so only the ?token= query param authenticates —
|
||||
# mirrors a browser/shell-opened download that can't set the session header.
|
||||
del client.headers[web_server._SESSION_HEADER_NAME]
|
||||
|
||||
ok = client.get(
|
||||
"/api/files/download",
|
||||
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
ok = client.get("/api/files/download", params={"path": str(file_path)})
|
||||
assert ok.status_code == 200
|
||||
assert ok.content == b"hello"
|
||||
|
||||
assert client.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "nope"}
|
||||
).status_code == 401
|
||||
assert client.get(
|
||||
"/api/files/download", params={"path": str(file_path)}
|
||||
).status_code == 401
|
||||
# A stale/garbage ?token= is simply ignored, not rejected, on loopback.
|
||||
still_ok = client.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "anything"}
|
||||
)
|
||||
assert still_ok.status_code == 200
|
||||
|
||||
|
||||
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
|
||||
def test_download_requires_auth_in_gated_mode(forced_files_client, monkeypatch):
|
||||
"""In gated (non-loopback) mode the download endpoint requires a verified
|
||||
session cookie — a cookieless request 401s at the gate, and there is no
|
||||
``?token=`` query-param bypass."""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
client, root = forced_files_client
|
||||
file_path = _seed_file(client, root)
|
||||
|
||||
del client.headers[web_server._SESSION_HEADER_NAME]
|
||||
|
||||
# The query-token escape hatch is scoped to /api/files/download only; it must
|
||||
# not unlock the rest of the API surface.
|
||||
leaked = client.get(
|
||||
"/api/files/read",
|
||||
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
assert leaked.status_code == 401
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
gated = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
# Cookieless → 401 at the gate, with or without a bogus ?token=.
|
||||
assert gated.get(
|
||||
"/api/files/download", params={"path": str(file_path)}
|
||||
).status_code == 401
|
||||
assert gated.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "anything"}
|
||||
).status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = False
|
||||
|
||||
|
||||
def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
||||
|
||||
@@ -5,6 +5,11 @@ import pytest
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
# These tests mutate ``web_server.app.state`` (auth_required / bound_host);
|
||||
# share the xdist group used by every dashboard-auth gate test so they
|
||||
# don't race against each other across workers.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
pytest.importorskip("starlette.testclient")
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
@@ -14,7 +19,7 @@ def client(monkeypatch):
|
||||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
test_client = TestClient(web_server.app)
|
||||
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
@@ -174,15 +179,54 @@ def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path
|
||||
assert response.json() == {"cwd": str(fallback), "branch": ""}
|
||||
|
||||
|
||||
def test_fs_endpoints_require_auth(tmp_path):
|
||||
client = TestClient(web_server.app)
|
||||
def test_fs_endpoints_no_identity_gate_on_loopback(tmp_path):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
The /api/fs/* endpoints read arbitrary files, but on a loopback bind
|
||||
the OS boundary + CSRF guard are the protection, not a per-request
|
||||
token. A tokenless local request is served (reaches the handler — any
|
||||
non-401). Identity enforcement for these endpoints in GATED mode is
|
||||
pinned by test_fs_endpoints_require_auth_in_gated_mode below.
|
||||
"""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
try:
|
||||
client = TestClient(web_server.app)
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
assert list_response.status_code != 401
|
||||
assert read_response.status_code != 401
|
||||
assert default_response.status_code != 401
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert list_response.status_code == 401
|
||||
assert read_response.status_code == 401
|
||||
assert default_response.status_code == 401
|
||||
def test_fs_endpoints_require_auth_in_gated_mode(tmp_path):
|
||||
"""In gated (non-loopback) mode the /api/fs/* endpoints require a
|
||||
verified session cookie — a cookieless request 401s at the gate."""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_req = getattr(web_server.app.state, "auth_required", None)
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
assert client.get(
|
||||
"/api/fs/list", params={"path": str(tmp_path)}
|
||||
).status_code == 401
|
||||
assert client.get(
|
||||
"/api/fs/read-text", params={"path": str(target)}
|
||||
).status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = prev_req
|
||||
|
||||
@@ -161,7 +161,7 @@ class TestWebSocketHostOriginGuard:
|
||||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
url = "/api/events?channel=security-test"
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
@@ -184,7 +184,7 @@ class TestWebSocketHostOriginGuard:
|
||||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
url = "/api/events?channel=security-test"
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
@@ -206,7 +206,7 @@ class TestWebSocketHostOriginGuard:
|
||||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
url = "/api/events?channel=security-test"
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
headers={
|
||||
|
||||
@@ -43,14 +43,13 @@ def client(monkeypatch, isolated_profiles):
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
# The dashboard process's os.environ may carry root-install credentials;
|
||||
# make sure the scoped path never falls back to them.
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -38,11 +38,10 @@ def client(monkeypatch, isolated_profiles):
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -61,11 +61,10 @@ def client(monkeypatch, isolated_profiles):
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
@@ -204,14 +203,20 @@ class TestEditorEndpointsAuth:
|
||||
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
|
||||
],
|
||||
)
|
||||
def test_endpoints_401_without_token(
|
||||
def test_endpoints_no_identity_gate_on_loopback(
|
||||
self, client, isolated_profiles, method, path, kwargs
|
||||
):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
client.headers.pop(_SESSION_HEADER_NAME, None)
|
||||
Pre-teardown these endpoints 401'd without the session token. Now
|
||||
the loopback bind + CSRF guard are the boundary, so a tokenless
|
||||
local request is served (reaches the handler — any non-401). The
|
||||
gated (non-loopback) path is where identity is enforced, covered by
|
||||
the dashboard-auth gate tests.
|
||||
"""
|
||||
client.headers.pop("X-Hermes-Session-Token", None)
|
||||
resp = getattr(client, method)(path, **kwargs)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code != 401
|
||||
|
||||
|
||||
class TestCronJobSkills:
|
||||
|
||||
@@ -50,11 +50,10 @@ def client(monkeypatch, isolated_profiles):
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
||||
@@ -184,7 +184,8 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
// In loopback mode the legacy ?token=<session> path is fine; in gated
|
||||
// In loopback mode the WS needs no auth param (the server accepts
|
||||
// loopback connections on the peer-IP + Host/Origin guard); in gated
|
||||
// mode we have to mint a single-use ticket from the cookie. The IIFE
|
||||
// keeps the outer effect synchronous so its ``return cleanup`` stays
|
||||
// at the top level; the local ``ws`` is hoisted to a closed-over
|
||||
@@ -193,11 +194,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
|
||||
let ws: WebSocket | null = null;
|
||||
void (async () => {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
if (!authValue || unmounting) {
|
||||
if (unmounting) {
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ [authName]: authValue, channel });
|
||||
const qs = new URLSearchParams({ channel });
|
||||
if (authName) qs.set(authName, authValue);
|
||||
ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
);
|
||||
|
||||
@@ -19,27 +19,16 @@ const BASE = HERMES_BASE_PATH;
|
||||
|
||||
import type { DashboardTheme } from "@/themes/types";
|
||||
|
||||
// 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;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
|
||||
* engaged (public bind, no ``--insecure``). Toggles the SPA's
|
||||
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
|
||||
* fetched via :func:`getWsTicket`. */
|
||||
* WS-upgrade path to single-use ``?ticket=`` fetched via
|
||||
* :func:`getWsTicket`; loopback connects with no auth param. */
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
const SESSION_HEADER = "X-Hermes-Session-Token";
|
||||
|
||||
function setSessionHeader(headers: Headers, token: string): void {
|
||||
if (!headers.has(SESSION_HEADER)) {
|
||||
headers.set(SESSION_HEADER, token);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global management-profile scope ──────────────────────────────────
|
||||
// The dashboard is a machine-level management surface: one header switcher
|
||||
@@ -92,19 +81,13 @@ export async function fetchJSON<T>(
|
||||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
url = withManagementProfile(url);
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
// ``credentials: 'include'`` so the cookie-auth path (gated mode) works
|
||||
// for any fetch routed through here. Loopback mode is unaffected — the
|
||||
// server doesn't read cookies and the legacy session-token header is
|
||||
// already attached above.
|
||||
// server doesn't read cookies and enforces no identity gate.
|
||||
credentials: init?.credentials ?? "include",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
@@ -141,43 +124,6 @@ export async function fetchJSON<T>(
|
||||
// Never resolve — the page is about to unload.
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
// Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart
|
||||
// (``hermes update``, ``hermes gateway restart``, etc.). A tab kept
|
||||
// open across the restart holds the OLD token in
|
||||
// ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render,
|
||||
// so every fetch returns 401. The HTML is served ``Cache-Control:
|
||||
// no-store`` so a reload picks up the freshly-injected token. Trigger
|
||||
// that reload once on the first stale-token 401 — gated mode is
|
||||
// handled above, so reaching here in gated mode means a real
|
||||
// middleware failure that should not reload-loop.
|
||||
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
|
||||
let alreadyReloaded = false;
|
||||
try {
|
||||
alreadyReloaded =
|
||||
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
|
||||
} catch {
|
||||
/* SSR / privacy mode — fall through to throw */
|
||||
}
|
||||
if (!alreadyReloaded) {
|
||||
try {
|
||||
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
|
||||
} catch {
|
||||
/* SSR / privacy mode — best effort */
|
||||
}
|
||||
window.location.reload();
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (res.ok) {
|
||||
// Clear the stale-token reload guard: a successful 2xx proves the
|
||||
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
|
||||
// 401 — if any — should be allowed to trigger its own reload cycle.
|
||||
try {
|
||||
sessionStorage.removeItem("hermes.tokenReloadAttempted");
|
||||
} catch {
|
||||
/* SSR / privacy mode — ignore */
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
@@ -191,16 +137,6 @@ function pluginPath(name: string): string {
|
||||
return name.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
|
||||
*
|
||||
@@ -227,15 +163,15 @@ export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: numb
|
||||
/**
|
||||
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
|
||||
* connect. In gated mode mints a fresh single-use ticket; in loopback
|
||||
* mode returns the injected session token.
|
||||
* mode returns an empty pair (the server accepts loopback WS with no auth
|
||||
* param — peer-IP + Host/Origin guard is the boundary).
|
||||
*/
|
||||
export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
return ["ticket", ticket];
|
||||
}
|
||||
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
return ["token", token];
|
||||
return ["", ""];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,10 +180,11 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
|
||||
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
|
||||
*
|
||||
* Auth, in both modes, exactly as ``fetchJSON`` does it:
|
||||
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
|
||||
* - gated OAuth: no token header (it's absent by design); the
|
||||
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
|
||||
* Auth, in both modes:
|
||||
* - loopback / ``--insecure``: no credential needed; the server enforces
|
||||
* no identity gate on a loopback bind.
|
||||
* - gated OAuth: the ``hermes_session_at`` cookie rides along via
|
||||
* ``credentials: 'include'``.
|
||||
*
|
||||
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
|
||||
* non-2xx (the caller decides — a 404 on a download is meaningful), and
|
||||
@@ -260,10 +197,6 @@ export async function authedFetch(
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
return fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
@@ -274,10 +207,9 @@ export async function authedFetch(
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
|
||||
* with the correct auth query param appended for the active mode (fresh
|
||||
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
|
||||
* the SPA should use this instead of hand-assembling a WS URL + reading
|
||||
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
|
||||
* path can never be forgotten.
|
||||
* single-use ``ticket`` in gated mode, no auth param in loopback). Plugins
|
||||
* and the SPA should use this instead of hand-assembling a WS URL, so the
|
||||
* gated-mode ticket path can never be forgotten.
|
||||
*
|
||||
* ``path`` is the dashboard-relative path (e.g.
|
||||
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
|
||||
@@ -291,8 +223,11 @@ export async function buildWsUrl(
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
if (authName) {
|
||||
qs.set(authName, authValue);
|
||||
}
|
||||
const query = qs.toString();
|
||||
return `${proto}//${window.location.host}${BASE}${path}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
|
||||
@@ -319,13 +254,8 @@ export const api = {
|
||||
* AuthWidget component swallows 401s from this call: if the gate isn't
|
||||
* engaged, /api/auth/me returns 401 and the widget renders nothing.
|
||||
*
|
||||
* ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint
|
||||
* 401s by design, and fetchJSON's default loopback behaviour treats a
|
||||
* 401 as a rotated session token and full-page-reloads to pick up a
|
||||
* fresh one. Because every *other* dashboard request succeeds (and so
|
||||
* clears the one-shot reload guard), that turns this expected 401 into
|
||||
* an infinite reload loop. Opting out keeps the 401 a plain throw the
|
||||
* widget can catch.
|
||||
* ``allowUnauthorized`` keeps the expected loopback 401 a plain throw the
|
||||
* widget can catch, rather than routing it through any shared 401 handling.
|
||||
*/
|
||||
getAuthMe: () =>
|
||||
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
|
||||
@@ -481,17 +411,14 @@ export const api = {
|
||||
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", {
|
||||
revealEnvVar: (key: string) =>
|
||||
fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: token,
|
||||
},
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: (profile = "all") =>
|
||||
@@ -716,58 +643,46 @@ export const api = {
|
||||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
||||
disconnectOAuthProvider: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean; provider: string }>(
|
||||
disconnectOAuthProvider: (providerId: string) =>
|
||||
fetchJSON<{ ok: boolean; provider: string }>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { [SESSION_HEADER]: token },
|
||||
},
|
||||
);
|
||||
},
|
||||
startOAuthLogin: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthStartResponse>(
|
||||
),
|
||||
startOAuthLogin: (providerId: string) =>
|
||||
fetchJSON<OAuthStartResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: token,
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
},
|
||||
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthSubmitResponse>(
|
||||
),
|
||||
submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
|
||||
fetchJSON<OAuthSubmitResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: 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 }>(
|
||||
cancelOAuthSession: (sessionId: string) =>
|
||||
fetchJSON<{ ok: boolean }>(
|
||||
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { [SESSION_HEADER]: token },
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Messaging platforms (gateway channels)
|
||||
getMessagingPlatforms: () =>
|
||||
|
||||
@@ -109,9 +109,10 @@ export class GatewayClient {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
// Gated mode: the SPA must fetch a single-use ticket via
|
||||
// /api/auth/ws-ticket; loopback mode needs no auth param (the server
|
||||
// accepts loopback WS on the peer-IP + Host/Origin guard). An explicit
|
||||
// ``token`` overrides both (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
@@ -122,19 +123,16 @@ export class GatewayClient {
|
||||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
authParamName = "";
|
||||
authParamValue = "";
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const authQuery = authParamName
|
||||
? `?${authParamName}=${encodeURIComponent(authParamValue)}`
|
||||
: "";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws${authQuery}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
@@ -247,7 +245,6 @@ export class GatewayClient {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
|
||||
* │ write(data) PTY output bytes → VT100 parser .
|
||||
* ▼ .
|
||||
* WebSocket /api/pty?token=<session> .
|
||||
* WebSocket /api/pty?ticket=<minted> (gated; none on loopback) .
|
||||
* ▼ .
|
||||
* FastAPI pty_ws (hermes_cli/web_server.py) .
|
||||
* ▼ .
|
||||
@@ -46,10 +46,13 @@ function buildWsUrl(
|
||||
profile: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
// ``authParam`` is ``["ticket", <minted>]`` in gated mode and an empty
|
||||
// pair ``["", ""]`` in loopback mode (the server accepts loopback WS with
|
||||
// no auth param — peer-IP + Host/Origin guard is the boundary). The
|
||||
// server-side helper ``_ws_auth_ok`` picks whichever shape matches the
|
||||
// current gate state.
|
||||
const qs = new URLSearchParams({ channel });
|
||||
if (authParam[0]) qs.set(authParam[0], authParam[1]);
|
||||
if (resume) qs.set("resume", resume);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
@@ -125,18 +128,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// collapses the host's box, so ResizeObserver never fires on return).
|
||||
const syncMetricsRef = useRef<(() => void) | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
// In gated (OAuth) mode the server intentionally omits the session token —
|
||||
// the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
|
||||
// so a missing token there is expected, not an error.
|
||||
const [banner, setBanner] = useState<string | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
!window.__HERMES_SESSION_TOKEN__ &&
|
||||
!window.__HERMES_AUTH_REQUIRED__
|
||||
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
|
||||
: null,
|
||||
);
|
||||
// Connection status banner — populated by the WS ``onclose`` handler when
|
||||
// a connection is refused (auth failure, host/origin mismatch, etc.).
|
||||
// There's no client-side credential to be "missing" anymore: loopback
|
||||
// needs none and gated mints a WS ticket on demand (buildWsAuthParam).
|
||||
const [banner, setBanner] = useState<string | null>(null);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Raw state for the mobile side-sheet + a derived value that force-
|
||||
@@ -296,15 +292,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
const gated = !!window.__HERMES_AUTH_REQUIRED__;
|
||||
// Banner already initialised above; just bail before wiring xterm/WS.
|
||||
// In gated mode the token is absent by design — buildWsAuthParam() mints
|
||||
// a WS ticket instead, so don't bail; let the effect reach that path.
|
||||
if (!token && !gated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No client-side credential gate here: loopback WS needs no auth param
|
||||
// and gated mode mints a single-use ticket in buildWsAuthParam(). Wire
|
||||
// up xterm/WS unconditionally.
|
||||
const tierW0 = terminalTierWidthPx(host);
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
@@ -941,7 +931,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,11 +1105,6 @@ export default function SessionsPage() {
|
||||
try {
|
||||
const res = await fetch(api.exportSessionUrl(id), {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"X-Hermes-Session-Token":
|
||||
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
|
||||
.__HERMES_SESSION_TOKEN__ ?? "",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
|
||||
@@ -128,12 +128,12 @@ export function exposePluginSDK() {
|
||||
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||
fetchJSON,
|
||||
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||
// Handles gated-cookie auth (and needs no credential on loopback) so
|
||||
// plugins never have to manage dashboard auth themselves.
|
||||
authedFetch,
|
||||
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||
// plugin WebSocket instead of hand-assembling the URL.
|
||||
// (single-use ticket in gated mode, no auth param in loopback). Use this
|
||||
// for any plugin WebSocket instead of hand-assembling the URL.
|
||||
buildWsUrl,
|
||||
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||
// plugins that need to build the WS URL themselves.
|
||||
|
||||
7
web/src/plugins/sdk.d.ts
vendored
7
web/src/plugins/sdk.d.ts
vendored
@@ -58,15 +58,16 @@ export type FetchJSON = <T = unknown>(
|
||||
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||
* of calling ``fetch`` directly so dashboard auth (gated cookie / loopback)
|
||||
* is handled for them.
|
||||
*/
|
||||
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||
* gated OAuth mode, no auth param in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL.
|
||||
*/
|
||||
export type BuildWsUrl = (
|
||||
path: string,
|
||||
|
||||
@@ -236,30 +236,30 @@ The dashboard uses three endpoints. Useful for scripting:
|
||||
|
||||
```bash
|
||||
# List authenticated providers + curated model lists
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
|
||||
curl http://localhost:PORT/api/model/options
|
||||
|
||||
# Read current main + auxiliary assignments
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
|
||||
curl http://localhost:PORT/api/model/auxiliary
|
||||
|
||||
# Set the main model
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Override a single auxiliary task
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Assign one model to every auxiliary task
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Reset all auxiliary tasks to auto
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
```
|
||||
|
||||
The session token is injected into the dashboard HTML at startup and rotates on every server restart. Grab it from the browser devtools (`window.__HERMES_SESSION_TOKEN__`) if you're scripting against a running dashboard.
|
||||
A local (loopback) dashboard needs no auth for scripting — the curl calls above work as-is against `127.0.0.1`. If you're scripting against a gated (remote / non-loopback) dashboard, authenticate with the session cookie the browser already uses (the gate sets it on login); there is no static token to grab anymore.
|
||||
|
||||
@@ -743,7 +743,7 @@ Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
|
||||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
|
||||
Plugin API routes require no identity authentication on a loopback bind — the loopback bind is the security boundary. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
|
||||
|
||||
#### Accessing Hermes internals
|
||||
|
||||
|
||||
@@ -571,7 +571,7 @@ The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer wi
|
||||
|
||||
### REST surface
|
||||
|
||||
All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral session token:
|
||||
All routes are mounted under `/api/plugins/kanban/`. On a loopback dashboard they require no credential — the loopback bind is the security boundary. On a gated (non-loopback) dashboard they're authenticated by the session cookie, like every other `/api/` route:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
@@ -615,7 +615,7 @@ Each key is optional and falls back to the shown default.
|
||||
|
||||
The dashboard's HTTP auth middleware [explicitly skips `/api/plugins/`](./extending-the-dashboard#backend-api-routes) — plugin routes are unauthenticated by design because the dashboard binds to localhost by default. That means the kanban REST surface is reachable from any process on the host.
|
||||
|
||||
The WebSocket takes one additional step: it requires the dashboard's ephemeral session token as a `?token=…` query parameter (browsers can't set `Authorization` on an upgrade request), matching the pattern used by the in-browser PTY bridge.
|
||||
The WebSocket follows the same model: on a loopback dashboard it needs no credential, and on a gated dashboard it uses a single-use `?ticket=…` query parameter (minted via `/api/auth/ws-ticket`) because browsers can't set `Authorization` on an upgrade request — matching the pattern used by the in-browser PTY bridge.
|
||||
|
||||
If you run `hermes dashboard --host 0.0.0.0`, every plugin route — kanban included — becomes reachable from the network. **Don't do that on a shared host.** The board contains task bodies, comments, and workspace paths; an attacker reaching these routes gets read access to your entire collaboration surface and can also create / reassign / archive tasks.
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ The **Chat** tab embeds the full Hermes TUI (the same interface you get from `he
|
||||
|
||||
**How it works:**
|
||||
|
||||
- `/api/pty` opens a WebSocket authenticated with the dashboard's session token
|
||||
- `/api/pty` opens a WebSocket — on a loopback dashboard it needs no credential; on a gated dashboard it authenticates with a single-use ticket
|
||||
- The server spawns `hermes --tui` behind a POSIX pseudo-terminal
|
||||
- Keystrokes travel to the PTY; ANSI output streams back to the browser
|
||||
- xterm.js's WebGL renderer paints each cell to an integer-pixel grid; mouse tracking (SGR 1006), wide characters (Unicode 11), and box-drawing glyphs all render natively
|
||||
@@ -380,7 +380,7 @@ Creating a shell hook (note the consent checkbox and the run-arbitrary-commands
|
||||

|
||||
|
||||
:::warning Security
|
||||
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
|
||||
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. On a loopback bind there is no identity gate: the loopback bind itself is the security boundary, backed by a `Sec-Fetch-Site` CSRF guard that blocks cross-origin mutating requests and a localhost-only CORS policy that blocks cross-origin reads. To expose the dashboard beyond your machine, bind to a non-loopback address (which engages the [auth gate](#authentication-gated-mode)) rather than relying on loopback.
|
||||
:::
|
||||
|
||||
## `/reload` Slash Command
|
||||
|
||||
@@ -208,30 +208,30 @@ hermes model # 交互式提供商 + 模型选择器(切换默认值
|
||||
|
||||
```bash
|
||||
# 列出已认证的提供商及精选模型列表
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
|
||||
curl http://localhost:PORT/api/model/options
|
||||
|
||||
# 读取当前主模型及辅助任务分配
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
|
||||
curl http://localhost:PORT/api/model/auxiliary
|
||||
|
||||
# 设置主模型
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 覆盖单个辅助任务
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 将一个模型分配给所有辅助任务
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 将所有辅助任务重置为 auto
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
```
|
||||
|
||||
session token 在启动时注入仪表板 HTML,每次服务器重启后轮换。如需对运行中的仪表板编写脚本,可从浏览器开发者工具中获取(`window.__HERMES_SESSION_TOKEN__`)。
|
||||
本地(回环)仪表板无需任何认证即可编写脚本——上面的 curl 调用直接针对 `127.0.0.1` 即可工作。如需对受保护的(远程 / 非回环)仪表板编写脚本,请使用浏览器登录时获得的会话 cookie(与浏览器使用的同一个)进行认证;不再有可供获取的静态 token。
|
||||
@@ -727,7 +727,7 @@ async def do_action(body: dict):
|
||||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
插件 API 路由绕过会话 token 认证,因为 dashboard 服务器默认绑定到 localhost。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
|
||||
插件 API 路由在回环绑定上无需任何身份验证——回环绑定就是安全边界。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
|
||||
|
||||
#### 访问 Hermes 内部模块
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@ GUI 严格是一个**通过 DB 读取 + 通过 kanban_db 写入**的层,没有
|
||||
|
||||
### REST 接口
|
||||
|
||||
所有路由挂载在 `/api/plugins/kanban/` 下,并受仪表盘的临时会话 token 保护:
|
||||
所有路由挂载在 `/api/plugins/kanban/` 下。在回环仪表盘上,它们无需任何凭据——回环绑定就是安全边界。在受保护的(非回环)仪表盘上,它们与其他所有 `/api/` 路由一样,由会话 cookie 进行认证:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
@@ -511,7 +511,7 @@ dashboard:
|
||||
|
||||
仪表盘的 HTTP 认证中间件[显式跳过 `/api/plugins/`](./extending-the-dashboard#backend-api-routes) —— 插件路由在设计上是未认证的,因为仪表盘默认绑定到 localhost。这意味着 kanban REST 接口可以从主机上的任何进程访问。
|
||||
|
||||
WebSocket 额外增加了一步:它要求仪表盘的临时会话 token 作为 `?token=…` 查询参数(浏览器无法在升级请求上设置 `Authorization`),与浏览器内 PTY 桥使用的模式一致。
|
||||
WebSocket 遵循同样的模型:在回环仪表盘上无需任何凭据;在受保护的仪表盘上,它使用一次性的 `?ticket=…` 查询参数(通过 `/api/auth/ws-ticket` 签发),因为浏览器无法在升级请求上设置 `Authorization`——这与浏览器内 PTY 桥使用的模式一致。
|
||||
|
||||
如果你运行 `hermes dashboard --host 0.0.0.0`,每个插件路由 —— 包括 kanban —— 都可以从网络访问。**不要在共享主机上这样做。** 看板包含任务正文、评论和工作区路径;攻击者访问这些路由可以读取你整个协作界面,还可以创建 / 重新分配 / 归档任务。
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
|
||||
|
||||
**工作原理:**
|
||||
|
||||
- `/api/pty` 打开一个经 Dashboard 会话 token 认证的 WebSocket
|
||||
- `/api/pty` 打开一个 WebSocket——在回环仪表盘上无需任何凭据;在受保护的仪表盘上,它使用一次性 ticket 进行认证
|
||||
- 服务器在 POSIX 伪终端后面启动 `hermes --tui`
|
||||
- 按键传输到 PTY;ANSI 输出流式返回浏览器
|
||||
- xterm.js 的 WebGL 渲染器将每个单元格绘制到整数像素网格;鼠标追踪(SGR 1006)、宽字符(Unicode 11)和方框绘制字形均原生渲染
|
||||
@@ -178,7 +178,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
|
||||
- **Toolsets** — 单独的部分显示内置工具集(文件操作、Web 浏览等),包含其活跃/非活跃状态、设置要求和包含的工具列表
|
||||
|
||||
:::warning 安全提示
|
||||
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。Dashboard 本身没有任何认证机制。
|
||||
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。在回环绑定上没有身份验证门:回环绑定本身就是安全边界,并由一个 `Sec-Fetch-Site` CSRF 防护(阻止跨源的变更请求)和一个仅限 localhost 的 CORS 策略(阻止跨源读取)作为后盾。要将仪表板暴露到本机之外,请绑定到非回环地址(这会启用[认证门](#authentication-gated-mode)),而不要依赖回环。
|
||||
:::
|
||||
|
||||
## `/reload` 斜杠命令
|
||||
|
||||
Reference in New Issue
Block a user