fix(desktop): refuse a foreign backend's session token after readiness
The served-token fallback adopts whatever token the dashboard HTML injects. That is correct when our own child regenerated the token (env pin lost across a shell-wrapped spawn), but wrong when the readiness probe answered from a process we did not spawn: /api/status is public, so an orphaned dashboard squatting the port passes waitForHermes while our child dies on the bind conflict. Silently adopting that process's token would authenticate the renderer against a foreign backend, possibly on the wrong profile. Discriminate on child liveness: the desktop pins HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always serves our token. Served-token mismatch + dead child = foreign backend; fail the boot loudly instead of connecting. Mismatch + live child keeps the adopt-served-token salvage from #43720.
This commit is contained in:
@@ -78,10 +78,32 @@ async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {})
|
|||||||
return servedToken || fallbackToken
|
return servedToken || fallbackToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a served-token mismatch means we are talking to a backend we
|
||||||
|
* did NOT spawn.
|
||||||
|
*
|
||||||
|
* The desktop pins HERMES_DASHBOARD_SESSION_TOKEN on every backend it spawns,
|
||||||
|
* and the dashboard honors that env at import — so a LIVE child of ours always
|
||||||
|
* serves our token. The only way the served token differs while our child is
|
||||||
|
* dead is that the readiness probe (public /api/status) answered from a
|
||||||
|
* different process: an orphaned dashboard or port squatter that won the bind
|
||||||
|
* race while our child exited. Adopting that process's token would silently
|
||||||
|
* authenticate the renderer against a foreign backend (possibly the wrong
|
||||||
|
* profile), so callers must fail loudly instead.
|
||||||
|
*
|
||||||
|
* A mismatch with a live child is the benign case the served-token fallback
|
||||||
|
* exists for: our own child served a regenerated token because the env pin
|
||||||
|
* did not survive the spawn (e.g. shell-wrapped CLI shims).
|
||||||
|
*/
|
||||||
|
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||||
|
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||||
dashboardIndexUrl,
|
dashboardIndexUrl,
|
||||||
extractInjectedDashboardToken,
|
extractInjectedDashboardToken,
|
||||||
fetchPublicText,
|
fetchPublicText,
|
||||||
|
isForeignBackendToken,
|
||||||
resolveServedDashboardToken
|
resolveServedDashboardToken
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
dashboardIndexUrl,
|
dashboardIndexUrl,
|
||||||
extractInjectedDashboardToken,
|
extractInjectedDashboardToken,
|
||||||
fetchPublicText,
|
fetchPublicText,
|
||||||
|
isForeignBackendToken,
|
||||||
resolveServedDashboardToken
|
resolveServedDashboardToken
|
||||||
} = require('./dashboard-token.cjs')
|
} = require('./dashboard-token.cjs')
|
||||||
|
|
||||||
@@ -87,3 +88,23 @@ test('resolveServedDashboardToken propagates fetch errors so callers can fall ba
|
|||||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('isForeignBackendToken flags a mismatched token from a dead child', () => {
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: 'other', spawnToken: 'mine', childAlive: false }), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isForeignBackendToken trusts a mismatched token while our child is alive', () => {
|
||||||
|
// Live child + different token = our own backend regenerated the token
|
||||||
|
// because the env pin did not survive the spawn. Adopting it is correct.
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: 'other', spawnToken: 'mine', childAlive: true }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isForeignBackendToken trusts a matching token regardless of liveness', () => {
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: 'mine', spawnToken: 'mine', childAlive: false }), false)
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: 'mine', spawnToken: 'mine', childAlive: true }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isForeignBackendToken ignores an absent served token', () => {
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: null, spawnToken: 'mine', childAlive: false }), false)
|
||||||
|
assert.equal(isForeignBackendToken({ servedToken: '', spawnToken: 'mine', childAlive: false }), false)
|
||||||
|
})
|
||||||
|
|||||||
@@ -29,7 +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 { isForeignBackendToken, 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')
|
||||||
@@ -4599,6 +4599,21 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
|
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
|
||||||
return token
|
return token
|
||||||
})
|
})
|
||||||
|
if (
|
||||||
|
isForeignBackendToken({
|
||||||
|
servedToken: authToken,
|
||||||
|
spawnToken: token,
|
||||||
|
childAlive: child.exitCode === null && !child.killed
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
// Our child is dead and the port answers with someone else's token:
|
||||||
|
// /api/status readiness was a false positive from a process we did not
|
||||||
|
// spawn. Fail loudly rather than authenticate against a foreign backend.
|
||||||
|
backendPool.delete(profile)
|
||||||
|
throw new Error(
|
||||||
|
`Hermes backend for profile "${profile}" exited and port ${port} is served by a different process; refusing its session token.`
|
||||||
|
)
|
||||||
|
}
|
||||||
entry.token = authToken
|
entry.token = authToken
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4831,6 +4846,20 @@ async function startHermes() {
|
|||||||
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
|
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
|
||||||
return token
|
return token
|
||||||
})
|
})
|
||||||
|
if (
|
||||||
|
isForeignBackendToken({
|
||||||
|
servedToken: authToken,
|
||||||
|
spawnToken: token,
|
||||||
|
childAlive: hermesProcess.exitCode === null && !hermesProcess.killed
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
// Our child is dead and the port answers with someone else's token:
|
||||||
|
// /api/status readiness was a false positive from a process we did not
|
||||||
|
// spawn. Fail loudly rather than authenticate against a foreign backend.
|
||||||
|
throw new Error(
|
||||||
|
`Hermes backend exited and port ${port} is served by a different process; refusing its session 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',
|
||||||
|
|||||||
Reference in New Issue
Block a user