fix(desktop): key cookie-auth session partitions on connection identity, not auth mode (#92183)
Two registered basic-auth gateways shared the single persist:hermes-remote-oauth cookie jar, so signing in to gateway B evicted gateway A's session cookies (Chromium jars ignore the port) and A's cookie was silently presented to B on every request. Non-primary v2 registry remotes with cookie auth now ride a per-connection partition (persist:hermes-remote-oauth:conn:<id>) resolved at the jar boundary; the registry primary, v1 remote, cloud cascade, and portal flows keep the legacy shared jar so upgrades do not sign anyone out. Fail closed: a connection's requests can never see another connection's cookies.
This commit is contained in:
@@ -150,6 +150,7 @@ import {
|
||||
upsertConnection
|
||||
} from './connection-registry'
|
||||
import type { RosterProfileMetadata } from './connection-registry'
|
||||
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
|
||||
import { describeCrashReason, installCrashForensics } from './crash-forensics'
|
||||
import { adoptServedDashboardToken } from './dashboard-token'
|
||||
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
@@ -1316,7 +1317,7 @@ function registerMediaProtocol() {
|
||||
method
|
||||
}),
|
||||
fetchRemoteWithCookies: (url, headers, method) => {
|
||||
const oauthSession = getOauthSession()
|
||||
const oauthSession = getOauthSessionForUrl(url)
|
||||
|
||||
if (!oauthSession) {
|
||||
throw new Error('OAuth session partition is unavailable.')
|
||||
@@ -6913,7 +6914,7 @@ function installMediaPermissions() {
|
||||
// "is the user signed in at all?" gate / display signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
||||
const OAUTH_SESSION_PARTITION = LEGACY_OAUTH_PARTITION
|
||||
|
||||
function getOauthSession() {
|
||||
if (oauthSession || !app.isReady()) {
|
||||
@@ -6925,6 +6926,47 @@ function getOauthSession() {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
// Per-connection cookie jars (#92183). A NON-primary v2 registry remote with
|
||||
// cookie auth rides its own partition so two registered gateways can never
|
||||
// evict — or be handed — each other's session cookies (Chromium jars ignore
|
||||
// the port, so two dashboards on one VPN host used to collide in the shared
|
||||
// jar above). The primary / v1 remote / cloud / portal flows keep the legacy
|
||||
// shared partition; see oauth-partition.ts for the full rules.
|
||||
const oauthSessionsByPartition = new Map()
|
||||
|
||||
function resolveOauthPartitionForUrl(url) {
|
||||
try {
|
||||
return resolveOauthPartition(url, {
|
||||
registry: readDesktopConnectionsRegistry(),
|
||||
v1RemoteUrl: readDesktopConnectionConfig()?.remote?.url
|
||||
})
|
||||
} catch {
|
||||
// A broken registry read must never take cookie auth down with it.
|
||||
return OAUTH_SESSION_PARTITION
|
||||
}
|
||||
}
|
||||
|
||||
function getOauthSessionForUrl(url) {
|
||||
const partition = resolveOauthPartitionForUrl(url)
|
||||
|
||||
if (partition === OAUTH_SESSION_PARTITION) {
|
||||
return getOauthSession()
|
||||
}
|
||||
|
||||
if (!app.isReady()) {
|
||||
return null
|
||||
}
|
||||
|
||||
let sess = oauthSessionsByPartition.get(partition)
|
||||
|
||||
if (!sess) {
|
||||
sess = session.fromPartition(partition)
|
||||
oauthSessionsByPartition.set(partition, sess)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// Cold-start cookie-jar warm-up. A `persist:` partition materialized via
|
||||
// session.fromPartition() loads its on-disk cookie store LAZILY: the very first
|
||||
// cookies.get() on a fresh cold start can resolve BEFORE the jar has finished
|
||||
@@ -6938,19 +6980,23 @@ function getOauthSession() {
|
||||
// throwaway cookies.get(). The promise is memoized so every caller awaits the
|
||||
// same single warm-up. Best-effort — any error resolves so we fall back to the
|
||||
// live read (which then does its own bounded re-check).
|
||||
let oauthCookieWarmup: Promise<void> | null = null
|
||||
// Memoized per PARTITION: per-connection jars (#92183) hydrate independently.
|
||||
const oauthCookieWarmups = new Map()
|
||||
|
||||
function warmOauthCookieStore() {
|
||||
if (oauthCookieWarmup) {
|
||||
return oauthCookieWarmup
|
||||
function warmOauthCookieStore(url?) {
|
||||
const partition = resolveOauthPartitionForUrl(url)
|
||||
const pending = oauthCookieWarmups.get(partition)
|
||||
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
oauthCookieWarmup = (async () => {
|
||||
const sess = getOauthSession()
|
||||
const warmup = (async () => {
|
||||
const sess = getOauthSessionForUrl(url)
|
||||
|
||||
if (!sess) {
|
||||
// App not ready yet — don't memoize a no-op; let a later call retry.
|
||||
oauthCookieWarmup = null
|
||||
oauthCookieWarmups.delete(partition)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -6966,7 +7012,9 @@ function warmOauthCookieStore() {
|
||||
}
|
||||
})()
|
||||
|
||||
return oauthCookieWarmup
|
||||
oauthCookieWarmups.set(partition, warmup)
|
||||
|
||||
return warmup
|
||||
}
|
||||
|
||||
// Bare + prefixed variants of the session cookies live in
|
||||
@@ -6974,7 +7022,7 @@ function warmOauthCookieStore() {
|
||||
// that module for details.
|
||||
|
||||
async function hasOauthSessionCookie(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(baseUrl)
|
||||
|
||||
if (!sess) {
|
||||
return false
|
||||
@@ -7007,7 +7055,7 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
|
||||
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
|
||||
async function hasLiveOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(baseUrl)
|
||||
|
||||
if (!sess) {
|
||||
return false
|
||||
@@ -7043,7 +7091,7 @@ async function hasLiveOauthSession(baseUrl) {
|
||||
// trusting a negative, force the store to hydrate and re-read a couple of
|
||||
// times with a short backoff. A genuinely signed-out user still resolves
|
||||
// false quickly (≤ ~180ms); a signed-in user racing the load now wins.
|
||||
await warmOauthCookieStore()
|
||||
await warmOauthCookieStore(baseUrl)
|
||||
|
||||
for (const delayMs of [30, 60, 90]) {
|
||||
if (await readLive()) {
|
||||
@@ -7057,7 +7105,7 @@ async function hasLiveOauthSession(baseUrl) {
|
||||
}
|
||||
|
||||
async function clearOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(baseUrl)
|
||||
|
||||
if (!sess) {
|
||||
return
|
||||
@@ -7104,7 +7152,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(baseUrl)
|
||||
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
@@ -7237,7 +7285,7 @@ function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
||||
// authed REST against a gated gateway, including minting WS tickets.
|
||||
function fetchJsonViaOauthSession(url, options: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(url)
|
||||
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
@@ -7488,7 +7536,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
|
||||
// is cleared once the response headers arrive.
|
||||
function downloadViaOauthSessionToFile(url, ctx, options: any = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sess = getOauthSession()
|
||||
const sess = getOauthSessionForUrl(url)
|
||||
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
|
||||
148
apps/desktop/electron/oauth-partition.test.ts
Normal file
148
apps/desktop/electron/oauth-partition.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition'
|
||||
|
||||
// #92183 — two basic-auth (cookie-flow) gateways registered in the v2
|
||||
// connections registry must not share one cookie jar. Chromium cookie jars
|
||||
// ignore the port, so two gateways on the same VPN host (different ports)
|
||||
// evict each other's `hermes_session*` cookies when they ride the single
|
||||
// shared `persist:hermes-remote-oauth` partition — and, worse, gateway A's
|
||||
// cookie is silently PRESENTED to gateway B on every request. The resolver
|
||||
// under test keys the jar on the registry connection's identity instead.
|
||||
|
||||
const registry = (primary: string, connections: any[]) => ({ primary, connections })
|
||||
|
||||
const remote = (id: string, url: string, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
kind: 'remote',
|
||||
label: id,
|
||||
url,
|
||||
authMode: 'oauth',
|
||||
...extra
|
||||
})
|
||||
|
||||
describe('resolveOauthPartition (#92183 per-connection cookie jars)', () => {
|
||||
it('gives two same-host different-port registry gateways DISTINCT partitions (eviction fix)', () => {
|
||||
const reg = registry('local', [
|
||||
{ id: 'local', kind: 'local' },
|
||||
remote('conn-a', 'https://10.27.27.7:9119'),
|
||||
remote('conn-b', 'https://10.27.27.7:9220')
|
||||
])
|
||||
|
||||
const a = resolveOauthPartition('https://10.27.27.7:9119', { registry: reg })
|
||||
const b = resolveOauthPartition('https://10.27.27.7:9220', { registry: reg })
|
||||
|
||||
expect(a).not.toBe(LEGACY_OAUTH_PARTITION)
|
||||
expect(b).not.toBe(LEGACY_OAUTH_PARTITION)
|
||||
// Fail closed: B's requests must never ride a jar that can hold A's cookie.
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('scopes a full request URL (REST/ws-ticket path) to its connection jar via longest base-url prefix', () => {
|
||||
const reg = registry('local', [
|
||||
remote('conn-a', 'https://gw.example.com'),
|
||||
remote('conn-b', 'https://gw.example.com/team-b')
|
||||
])
|
||||
|
||||
const a = resolveOauthPartition('https://gw.example.com/api/auth/ws-ticket', { registry: reg })
|
||||
const b = resolveOauthPartition('https://gw.example.com/team-b/api/auth/ws-ticket', { registry: reg })
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(b).toContain('conn-b')
|
||||
})
|
||||
|
||||
it('keeps the v1 primary remote on the LEGACY partition so upgrades do not sign the user out', () => {
|
||||
const reg = registry('mig-1', [remote('mig-1', 'https://gw-a.example.com')])
|
||||
|
||||
expect(
|
||||
resolveOauthPartition('https://gw-a.example.com', {
|
||||
registry: reg,
|
||||
v1RemoteUrl: 'https://gw-a.example.com'
|
||||
})
|
||||
).toBe(LEGACY_OAUTH_PARTITION)
|
||||
})
|
||||
|
||||
it('keeps the registry PRIMARY connection on the legacy partition', () => {
|
||||
const reg = registry('conn-a', [
|
||||
remote('conn-a', 'https://gw-a.example.com'),
|
||||
remote('conn-b', 'https://gw-b.example.com')
|
||||
])
|
||||
|
||||
expect(resolveOauthPartition('https://gw-a.example.com/api/status', { registry: reg })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
expect(resolveOauthPartition('https://gw-b.example.com/api/status', { registry: reg })).not.toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cloud connections on the legacy partition (silent portal cascade needs the shared jar)', () => {
|
||||
const reg = registry('local', [
|
||||
{ id: 'cloud-1', kind: 'cloud', url: 'https://agent.nousresearch.com', authMode: 'oauth' }
|
||||
])
|
||||
|
||||
expect(resolveOauthPartition('https://agent.nousresearch.com/api/status', { registry: reg })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps token-auth registry remotes on the legacy partition (no cookies involved)', () => {
|
||||
const reg = registry('local', [remote('tok-1', 'https://gw-t.example.com', { authMode: 'token' })])
|
||||
|
||||
expect(resolveOauthPartition('https://gw-t.example.com', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
|
||||
})
|
||||
|
||||
it('falls back to the legacy partition for unmatched, portal, and malformed inputs', () => {
|
||||
const reg = registry('local', [remote('conn-a', 'https://gw-a.example.com')])
|
||||
|
||||
expect(resolveOauthPartition('https://portal.nousresearch.com/api/agents', { registry: reg })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
expect(resolveOauthPartition('not a url', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
|
||||
expect(resolveOauthPartition('', { registry: reg })).toBe(LEGACY_OAUTH_PARTITION)
|
||||
expect(resolveOauthPartition('https://gw-a.example.com', { registry: null as any })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
expect(resolveOauthPartition('https://gw-a.example.com', { registry: { primary: 'x', connections: 'junk' } as any })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat a hostname PREFIX as a base-url match', () => {
|
||||
const reg = registry('local', [remote('conn-a', 'https://gw.example.com')])
|
||||
|
||||
expect(resolveOauthPartition('https://gw.example.com.evil.tld/login', { registry: reg })).toBe(
|
||||
LEGACY_OAUTH_PARTITION
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes trailing slashes and default ports when matching entry URLs', () => {
|
||||
const reg = registry('local', [remote('conn-a', 'https://gw-a.example.com:443/')])
|
||||
|
||||
const got = resolveOauthPartition('https://gw-a.example.com/api/auth/ws-ticket', { registry: reg })
|
||||
|
||||
expect(got).not.toBe(LEGACY_OAUTH_PARTITION)
|
||||
expect(got).toContain('conn-a')
|
||||
})
|
||||
|
||||
it('produces a deterministic, partition-safe name from hostile connection ids', () => {
|
||||
const reg = registry('local', [remote('we ird/id:€', 'https://gw-a.example.com')])
|
||||
|
||||
const got = resolveOauthPartition('https://gw-a.example.com', { registry: reg })
|
||||
|
||||
expect(got.startsWith('persist:')).toBe(true)
|
||||
expect(got).not.toMatch(/[\s/€]/)
|
||||
expect(resolveOauthPartition('https://gw-a.example.com', { registry: reg })).toBe(got)
|
||||
})
|
||||
|
||||
it('breaks same-URL ties deterministically (identical jar for identical gateway)', () => {
|
||||
const reg = registry('local', [
|
||||
remote('zeta', 'https://gw-a.example.com'),
|
||||
remote('alpha', 'https://gw-a.example.com')
|
||||
])
|
||||
|
||||
const got = resolveOauthPartition('https://gw-a.example.com', { registry: reg })
|
||||
|
||||
expect(got).toContain('alpha')
|
||||
})
|
||||
})
|
||||
159
apps/desktop/electron/oauth-partition.ts
Normal file
159
apps/desktop/electron/oauth-partition.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* oauth-partition.ts
|
||||
*
|
||||
* Per-connection cookie-jar isolation for cookie-authenticated (OAuth /
|
||||
* dashboard basic-auth) remote gateways (#92183).
|
||||
*
|
||||
* Historically every cookie-mode remote rode ONE Electron session partition
|
||||
* (`persist:hermes-remote-oauth`) — the jar was keyed on the auth *mode*, not
|
||||
* on the connection's identity. Chromium cookie jars scope by host and ignore
|
||||
* the port, so two registered gateways on the same host (the #92183 VPN
|
||||
* setup: one box, two dashboards) fought over the same `hermes_session*`
|
||||
* cookies: signing in to gateway B evicted gateway A's session, and A's
|
||||
* cookie was silently PRESENTED to B on every request — a cross-connection
|
||||
* credential leak.
|
||||
*
|
||||
* This module is the pure decision seam: given a request/base URL and a
|
||||
* snapshot of the v2 connections registry, decide which session partition the
|
||||
* request must ride. Rules:
|
||||
*
|
||||
* - A NON-primary v2 registry `remote` entry with cookie auth
|
||||
* (`authMode: 'oauth'`, which covers dashboard basic/password providers —
|
||||
* they authenticate via session cookies too) gets its own partition
|
||||
* derived from the connection id. Fail closed: its requests can never see
|
||||
* another connection's cookies, and its login window can never evict them.
|
||||
* - The registry PRIMARY and the v1 single-connection remote stay on the
|
||||
* LEGACY shared partition, so existing signed-in users are not signed out
|
||||
* by the upgrade.
|
||||
* - `cloud` entries stay on the legacy partition: the silent per-agent
|
||||
* cascade deliberately shares one jar with the Nous Portal session.
|
||||
* - Token-auth remotes, portal URLs, and anything unmatched or malformed
|
||||
* fall back to the legacy partition (cookie-free flows are unaffected).
|
||||
*
|
||||
* Kept free of `electron` imports so it unit-tests in the electron vitest
|
||||
* project; main.ts owns session.fromPartition() and injects nothing here.
|
||||
*/
|
||||
|
||||
export const LEGACY_OAUTH_PARTITION = 'persist:hermes-remote-oauth'
|
||||
|
||||
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}:conn:`
|
||||
|
||||
export interface PartitionRegistrySnapshot {
|
||||
primary?: unknown
|
||||
connections?: unknown
|
||||
}
|
||||
|
||||
export interface ResolveOauthPartitionOptions {
|
||||
registry?: PartitionRegistrySnapshot | null
|
||||
/** v1 single-connection remote URL (connection.json `remote.url`), when set. */
|
||||
v1RemoteUrl?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a URL for base-url matching: lowercased scheme+host, explicit
|
||||
* default ports elided (URL does this), trailing slashes trimmed, query and
|
||||
* fragment dropped. Returns null for anything that is not a plain http(s) URL.
|
||||
*/
|
||||
function normalizeForMatch(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw.trim())
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const path = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${parsed.protocol}//${parsed.host}${path}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `requestNorm` is the entry base URL itself or a path underneath
|
||||
* it. Both sides are pre-normalized; the '/' requirement is what stops
|
||||
* `https://gw.example.com.evil.tld` from matching `https://gw.example.com`.
|
||||
*/
|
||||
function matchesBase(requestNorm: string, baseNorm: string): boolean {
|
||||
return requestNorm === baseNorm || requestNorm.startsWith(`${baseNorm}/`)
|
||||
}
|
||||
|
||||
/** Partition names must stay printable/simple; connection ids are user data. */
|
||||
function sanitizePartitionComponent(id: string): string {
|
||||
return encodeURIComponent(id).replace(/%/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the Electron session partition a cookie-authenticated request against
|
||||
* `requestUrl` must ride. See the module header for the rules.
|
||||
*/
|
||||
export function resolveOauthPartition(requestUrl: unknown, opts: ResolveOauthPartitionOptions = {}): string {
|
||||
const requestNorm = normalizeForMatch(requestUrl)
|
||||
|
||||
if (!requestNorm) {
|
||||
return LEGACY_OAUTH_PARTITION
|
||||
}
|
||||
|
||||
const registry = opts.registry
|
||||
|
||||
if (!registry || typeof registry !== 'object' || !Array.isArray(registry.connections)) {
|
||||
return LEGACY_OAUTH_PARTITION
|
||||
}
|
||||
|
||||
const primaryId = typeof registry.primary === 'string' ? registry.primary : ''
|
||||
const v1Norm = normalizeForMatch(opts.v1RemoteUrl)
|
||||
|
||||
let best: { baseNorm: string; id: string } | null = null
|
||||
|
||||
for (const entry of registry.connections) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const id = typeof (entry as any).id === 'string' ? (entry as any).id.trim() : ''
|
||||
|
||||
// Only NON-primary v2 `remote` entries with cookie-flow auth get their own
|
||||
// jar. Cloud entries need the shared portal jar; token entries never use
|
||||
// cookies; the primary (and the v1 remote it migrated from) keeps the
|
||||
// legacy jar so an upgrade does not sign the user out.
|
||||
if (!id || id === primaryId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((entry as any).kind !== 'remote' || (entry as any).authMode !== 'oauth') {
|
||||
continue
|
||||
}
|
||||
|
||||
const baseNorm = normalizeForMatch((entry as any).url)
|
||||
|
||||
if (!baseNorm || (v1Norm && baseNorm === v1Norm)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!matchesBase(requestNorm, baseNorm)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Longest base-url prefix wins (sub-path gateways behind one proxy);
|
||||
// identical URLs tie-break on the lexicographically smallest id so the
|
||||
// choice is deterministic across processes and launches.
|
||||
if (
|
||||
!best ||
|
||||
baseNorm.length > best.baseNorm.length ||
|
||||
(baseNorm.length === best.baseNorm.length && id < best.id)
|
||||
) {
|
||||
best = { baseNorm, id }
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
return LEGACY_OAUTH_PARTITION
|
||||
}
|
||||
|
||||
return `${CONNECTION_PARTITION_PREFIX}${sanitizePartitionComponent(best.id)}`
|
||||
}
|
||||
Reference in New Issue
Block a user