fix(desktop): use served dashboard token for websocket auth
(cherry picked from commitf8209f91d3) (cherry picked from commit72290f0809)
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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 http = require('node:http')
|
||||||
|
const https = require('node:https')
|
||||||
|
|
||||||
|
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||||
|
|
||||||
|
function fetchPublicText(url, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = new URL(url)
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Invalid URL: ${error.message}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = parsed.protocol === 'https:' ? https : http
|
||||||
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||||
|
const req = client.request(parsed, { method: options.method || 'GET' }, res => {
|
||||||
|
const chunks = []
|
||||||
|
res.on('data', chunk => chunks.push(chunk))
|
||||||
|
res.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8')
|
||||||
|
if ((res.statusCode || 500) >= 400) {
|
||||||
|
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(text)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', reject)
|
||||||
|
req.setTimeout(timeoutMs, () => {
|
||||||
|
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||||||
|
})
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||||
|
dashboardIndexUrl,
|
||||||
|
extractInjectedDashboardToken,
|
||||||
|
fetchPublicText,
|
||||||
|
resolveServedDashboardToken
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
dashboardIndexUrl,
|
||||||
|
extractInjectedDashboardToken,
|
||||||
|
fetchPublicText,
|
||||||
|
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/)
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
|
|||||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||||
|
const { resolveServedDashboardToken } = require('./dashboard-token.cjs')
|
||||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||||
@@ -4594,15 +4595,20 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
const baseUrl = `http://127.0.0.1:${port}`
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||||
ready = true
|
ready = true
|
||||||
|
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
|
||||||
|
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
entry.token = authToken
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
mode: 'local',
|
mode: 'local',
|
||||||
source: 'local',
|
source: 'local',
|
||||||
authMode: 'token',
|
authMode: 'token',
|
||||||
token,
|
token: authToken,
|
||||||
profile,
|
profile,
|
||||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||||
logs: hermesLog.slice(-80),
|
logs: hermesLog.slice(-80),
|
||||||
...getWindowState()
|
...getWindowState()
|
||||||
}
|
}
|
||||||
@@ -4821,6 +4827,10 @@ async function startHermes() {
|
|||||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||||
backendReady = true
|
backendReady = true
|
||||||
|
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
|
||||||
|
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
|
||||||
|
return token
|
||||||
|
})
|
||||||
updateBootProgress({
|
updateBootProgress({
|
||||||
phase: 'backend.ready',
|
phase: 'backend.ready',
|
||||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||||
@@ -4834,8 +4844,8 @@ async function startHermes() {
|
|||||||
mode: 'local',
|
mode: 'local',
|
||||||
source: 'local',
|
source: 'local',
|
||||||
authMode: 'token',
|
authMode: 'token',
|
||||||
token,
|
token: authToken,
|
||||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||||
logs: hermesLog.slice(-80),
|
logs: hermesLog.slice(-80),
|
||||||
...getWindowState()
|
...getWindowState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const path = require('node:path')
|
|||||||
const ELECTRON_DIR = __dirname
|
const ELECTRON_DIR = __dirname
|
||||||
|
|
||||||
function readElectronFile(name) {
|
function readElectronFile(name) {
|
||||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireHiddenChildOptions(source, needle) {
|
function requireHiddenChildOptions(source, needle) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"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-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",
|
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.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",
|
||||||
"typecheck": "tsc -p . --noEmit",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
|
|||||||
Reference in New Issue
Block a user