From 31250da5050da2b62ca9181f7ceb1da019a19c96 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:39:11 -0700 Subject: [PATCH] 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:) 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. --- apps/desktop/electron/main.ts | 82 +++++++-- apps/desktop/electron/oauth-partition.test.ts | 148 ++++++++++++++++ apps/desktop/electron/oauth-partition.ts | 159 ++++++++++++++++++ 3 files changed, 372 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/electron/oauth-partition.test.ts create mode 100644 apps/desktop/electron/oauth-partition.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a79ab38f62..3c42a749bd 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 | 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 // 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.')) diff --git a/apps/desktop/electron/oauth-partition.test.ts b/apps/desktop/electron/oauth-partition.test.ts new file mode 100644 index 0000000000..13a47b4ba5 --- /dev/null +++ b/apps/desktop/electron/oauth-partition.test.ts @@ -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 = {}) => ({ + 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') + }) +}) diff --git a/apps/desktop/electron/oauth-partition.ts b/apps/desktop/electron/oauth-partition.ts new file mode 100644 index 0000000000..420c9b309a --- /dev/null +++ b/apps/desktop/electron/oauth-partition.ts @@ -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)}` +}