diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index d5f3ffa8764..f59f4a80f88 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) - it('externalizes packaged dependencies but bundles the daemon xterm graph', () => { + it('externalizes packaged dependencies but bundles self-contained main dependencies', () => { const external = electronViteConfig.main?.build?.rollupOptions?.external if (typeof external !== 'function') { throw new Error('Expected main-process external predicate') @@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + expect(external('psl', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4255504f9a1..90b908200c4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,6 +10,7 @@ import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', + 'psl', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' diff --git a/package.json b/package.json index e8ec89daae0..cbc7862f8fd 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", + "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "^17.0.8", "serve-sim": "^0.1.40", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d53a979e1..c361a5382aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: posthog-node: specifier: ^5.33.3 version: 5.33.3 + psl: + specifier: 1.15.0 + version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -5793,9 +5796,16 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -12448,11 +12458,17 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts new file mode 100644 index 00000000000..31ca329bf8f --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Cookie } from 'electron' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + replaceCookiesForImportedDomains +} from './browser-cookie-import-policy' + +function cookie(domain: string, name: string, path = '/', secure = true): Cookie { + return { + domain, + name, + path, + secure, + sameSite: 'unspecified', + value: 'secret' + } +} + +describe('isGoogleSourceBoundCookie', () => { + it('matches the allowlisted names only on google.com and its subdomains', () => { + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('AEC', 'accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('__Secure-STRP', '.accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('SIDCC', '.notgoogle.com')).toBe(false) + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com.evil.example')).toBe(false) + expect(isGoogleSourceBoundCookie('SID', '.google.com')).toBe(false) + }) + + it('normalizes leading dots, case, and international domains consistently', () => { + expect(normalizeCookieDomain('..Accounts.Google.Com')).toBe('accounts.google.com') + expect(normalizeCookieDomain('münich.example')).toBe('xn--mnich-kva.example') + expect(normalizeCookieDomain('')).toBeNull() + }) + + it('rejects URL syntax that could normalize an invalid cookie scope to another domain', () => { + expect(normalizeCookieDomain('example.com/path')).toBeNull() + expect(normalizeCookieDomain('user@example.com')).toBeNull() + expect(normalizeCookieDomain('example.com:443')).toBeNull() + expect(normalizeCookieDomain('%65xample.com')).toBeNull() + expect(isGoogleSourceBoundCookie('SIDCC', 'user@google.com')).toBe(false) + }) +}) + +describe('replaceCookiesForImportedDomains', () => { + it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => { + const existing = [ + cookie('.google.com', 'parent'), + { ...cookie('google.com', 'host-only-parent'), hostOnly: true }, + cookie('.accounts.google.com', 'exact', '/signin'), + cookie('.child.accounts.google.com', 'child', '/nested', false), + cookie('.google.com.evil.example', 'suffix-confusion'), + cookie('.example.com', 'unrelated') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [ + 'accounts.google.com' + ]) + + expect(removed).toHaveLength(3) + expect(get).toHaveBeenCalledWith({}) + expect(remove.mock.calls).toEqual([ + ['https://google.com/', 'parent'], + ['https://accounts.google.com/signin', 'exact'], + ['http://child.accounts.google.com/nested', 'child'] + ]) + expect(set).not.toHaveBeenCalled() + }) + + it('does not replace a private-suffix host cookie for a tenant import', async () => { + const get = vi + .fn() + .mockResolvedValue([ + { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, + cookie('.user.github.io', 'tenant') + ]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io']) + + expect(removed.map(({ name }) => name)).toEqual(['tenant']) + expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') + }) + + it('does not read or mutate the store when no valid domain scope exists', async () => { + const get = vi.fn() + const remove = vi.fn() + const set = vi.fn() + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, [ + '', + '...', + 'com', + 'co.uk', + 'github.io' + ]) + ).resolves.toEqual([]) + expect(get).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('keeps single-label intranet scopes from selecting descendant hosts', async () => { + const get = vi + .fn() + .mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local']) + + expect(removed.map(({ name }) => name)).toEqual(['exact']) + expect(remove).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith('https://local/', 'exact') + }) + + it('restores cookies removed before a later removal fails', async () => { + const existing = [ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie store unavailable')) + const set = vi.fn().mockResolvedValue(undefined) + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, ['example.com']) + ).rejects.toThrow('cookie store unavailable') + expect(set).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledWith({ + url: 'https://example.com/one', + name: 'first', + value: 'secret', + domain: '.example.com', + path: '/one', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts new file mode 100644 index 00000000000..216542f08ea --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -0,0 +1,201 @@ +import type { Cookie, Cookies } from 'electron' +import { parse as parseDomain } from 'psl' + +const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ + 'SIDCC', + '__Secure-1PSIDCC', + '__Secure-3PSIDCC', + '__Secure-STRP', + 'AEC' +]) + +export type CookieImportMode = 'merge' | 'replace-imported-domains' + +export function normalizeCookieDomain(domain: string): string | null { + const candidate = domain.trim().replace(/^\.+/, '') + const isBracketedIpv6 = candidate.startsWith('[') && candidate.endsWith(']') + if (!candidate || /[/\\@?#%]/.test(candidate) || (!isBracketedIpv6 && candidate.includes(':'))) { + return null + } + try { + const parsed = new URL(`https://${candidate}/`) + const normalized = parsed.hostname.toLowerCase() + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.pathname !== '/' || + parsed.search || + parsed.hash || + normalized.endsWith('.') || + normalized.includes('..') + ) { + return null + } + return normalized + } catch { + return null + } +} + +export function normalizeCookieImportDomain(domain: string): string | null { + const normalized = normalizeCookieDomain(domain) + if (!normalized) { + return null + } + const parsed = parseDomain(normalized) + if ('error' in parsed) { + return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null + } + if (parsed.domain === null && parsed.listed) { + return null + } + return normalized +} + +export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { + if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) { + return false + } + const normalized = normalizeCookieDomain(domain) + return normalized === 'google.com' || normalized?.endsWith('.google.com') === true +} + +function domainSuffixes(domain: string): string[] { + const labels = domain.split('.') + return labels.map((_, index) => labels.slice(index).join('.')) +} + +function importDomainAncestors(domain: string): string[] { + const parsed = parseDomain(domain) + const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const ancestors: string[] = [] + for (const suffix of domainSuffixes(domain)) { + ancestors.push(suffix) + if (suffix === boundary) { + break + } + } + return ancestors +} + +function importedDomainScopes(domains: readonly string[]): { + exact: Set + ancestors: Set + descendantRoots: Set +} { + const exact = new Set() + const ancestors = new Set() + const descendantRoots = new Set() + const seen = new Set() + for (const domain of domains) { + const candidate = normalizeCookieDomain(domain) + if (!candidate || seen.has(candidate)) { + continue + } + seen.add(candidate) + const normalized = normalizeCookieImportDomain(candidate) + if (!normalized || exact.has(normalized)) { + continue + } + exact.add(normalized) + if (normalized.includes('.')) { + descendantRoots.add(normalized) + } + for (const suffix of importDomainAncestors(normalized)) { + ancestors.add(suffix) + } + } + return { exact, ancestors, descendantRoots } +} + +function overlapsImportedDomain( + cookie: Cookie, + domain: string, + scopes: ReturnType +): boolean { + if (scopes.exact.has(domain)) { + return true + } + if (cookie.hostOnly !== true && scopes.ancestors.has(domain)) { + return true + } + return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix)) +} + +function cookieRemovalUrl(cookie: Cookie, domain: string): string | null { + try { + const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`) + url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' + return url.toString() + } catch { + return null + } +} + +export async function restoreImportedDomainCookies( + store: Pick, + cookies: readonly Cookie[] +): Promise { + const failures: unknown[] = [] + for (const cookie of cookies) { + try { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + await store.set({ + url, + name: cookie.name, + value: cookie.value, + ...(cookie.hostOnly ? {} : { domain: cookie.domain }), + ...(cookie.path ? { path: cookie.path } : {}), + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}) + }) + } catch (err) { + failures.push(err) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Could not restore replaced cookies') + } +} + +export async function replaceCookiesForImportedDomains( + store: Pick, + importedDomains: readonly string[] +): Promise { + const scopes = importedDomainScopes(importedDomains) + if (scopes.exact.size === 0) { + return [] + } + + const existingCookies = await store.get({}) + const removedCookies: Cookie[] = [] + for (const cookie of existingCookies) { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { + continue + } + const url = cookieRemovalUrl(cookie, domain) + if (!url) { + continue + } + try { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } catch (err) { + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed') + } + throw err + } + } + return removedCookies +} diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts new file mode 100644 index 00000000000..c3e330f5a67 --- /dev/null +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + clearPendingCookieImportMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock, + persistUserAgent: vi.fn() + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +describe('validated cookie replacement', () => { + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-replacement-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookies(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('filters Google source-bound cookies before replacing imported domain scopes', async () => { + cookiesGetMock.mockResolvedValue([ + cookie('.google.com', 'old-google'), + cookie('.accounts.google.com', 'old-accounts', '/signin'), + cookie('.unrelated.com', 'keep'), + cookie('.google.com.evil.example', 'keep-suffix-confusion') + ]) + const filePath = writeCookies([ + { domain: '.google.com', name: 'SIDCC', value: 'source-bound', secure: true }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session', secure: true }, + { domain: '.example.com', name: 'SIDCC', value: 'not-google', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://google.com/', 'old-google'], + ['https://accounts.google.com/signin', 'old-accounts'] + ]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC']) + expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...cookiesSetMock.mock.invocationCallOrder) + ) + }) + + it('does not touch the store when every valid entry is source-bound', async () => { + const filePath = writeCookies([ + { domain: '.google.com', name: 'AEC', value: 'source-bound', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toEqual({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + domains: [] + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('rejects URL-shaped domains before replacement can clear their normalized scope', async () => { + const filePath = writeCookies([ + { domain: 'example.com/path', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: 'No valid cookies found. 1 entries were skipped due to missing or invalid fields.' + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('restores the previous snapshot when an incoming cookie is rejected', async () => { + cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')]) + cookiesSetMock + .mockResolvedValue(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie rejected')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'first', value: 'new', secure: true }, + { domain: '.example.com', name: 'second', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://example.com/', 'existing'], + ['https://example.com/', 'first'] + ]) + expect(cookiesSetMock).toHaveBeenLastCalledWith({ + url: 'https://example.com/', + name: 'existing', + value: 'old', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) + + it('fails closed when existing cookies cannot be replaced', async () => { + cookiesGetMock.mockRejectedValue(new Error('cookie store unavailable')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + +describe('native Chromium integrity-cookie accounting', () => { + let clearStorageDataMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-accounting-test-')) + appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData')) + execFileSyncMock.mockReset().mockImplementation(() => { + throw new Error('OS browser version lookup unavailable') + }) + clearPendingCookieImportMock.mockClear() + setPendingCookieImportMock.mockClear() + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + set: cookiesSetMock + }, + clearStorageData: clearStorageDataMock + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('includes domain-scoped integrity cookies in skippedCookies', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session' }, + { domain: '.example.com', name: 'AEC', value: 'not-google' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC']) + } finally { + platformSpy.mockRestore() + } + }) + + it('preserves the destination when every source cookie is filtered or invalid', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.accounts.google.com', name: 'SIDCC', value: 'source-bound' }, + { domain: 'example.com/path', name: 'invalid-domain', value: 'invalid' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, [ + { domain: '.example.com', name: 'existing', value: 'keep' } + ]).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok && result.summary).toEqual({ + totalCookies: 3, + importedCookies: 0, + skippedCookies: 3, + domains: [] + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).not.toHaveBeenCalled() + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) +}) + +function cookie(domain: string, name: string, path = '/') { + return { domain, name, path, secure: true, sameSite: 'unspecified', value: 'old' } +} + +function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index ead377e5b46..1f38f0adc36 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite' import { join } from 'node:path' type ChromiumCookieTestRow = { + domain?: string name: string value: string encryptedValue?: Buffer @@ -61,7 +62,7 @@ export function createChromiumCookieTestDatabase( rows.forEach((row, index) => { insert.run( 133_000_000_000_000 + index, - '.example.com', + row.domain ?? '.example.com', row.name, row.value, row.encryptedValue ?? Buffer.alloc(0), diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 657215a6fbc..d657b8ec56e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer { describe('importCookiesFromFile', () => { let tmpDir: string + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType let cookiesSetMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) cookiesSetMock = vi.fn().mockResolvedValue(undefined) sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ - cookies: { set: cookiesSetMock } + cookies: { + get: cookiesGetMock, + remove: cookiesRemoveMock, + set: cookiesSetMock + } }) }) @@ -362,7 +370,7 @@ describe('importCookiesFromFile', () => { expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/') }) - it('counts cookies that fail to set', async () => { + it('rolls back replacement when a cookie fails to set', async () => { cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) const filePath = writeCookieFile([ @@ -371,12 +379,8 @@ describe('importCookiesFromFile', () => { ]) const result = await importCookiesFromFile(filePath, 'persist:test') - expect(result.ok).toBe(true) - if (!result.ok) { - return - } - expect(result.summary.importedCookies).toBe(1) - expect(result.summary.skippedCookies).toBe(1) + expect(result.ok).toBe(false) + expect(cookiesRemoveMock).toHaveBeenCalledWith('http://a.com/', 'ok') }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 23bdd248777..131adcca66b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' +import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { @@ -75,6 +75,14 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + restoreImportedDomainCookies, + type CookieImportMode +} from './browser-cookie-import-policy' import { createChromiumCookieSnapshot, type ChromiumCookieSnapshot @@ -500,13 +508,13 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la // Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag. function deriveUrl(domain: string, secure: boolean): string | null { - const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain - if (!cleanDomain || cleanDomain.includes(' ')) { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { return null } const protocol = secure ? 'https' : 'http' try { - const url = new URL(`${protocol}://${cleanDomain}/`) + const url = new URL(`${protocol}://${normalizedDomain}/`) return url.toString() } catch { return null @@ -552,40 +560,79 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { async function importValidatedCookies( cookies: ValidatedCookie[], totalInput: number, - targetPartition: string + targetPartition: string, + mode: CookieImportMode ): Promise { + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const importableCookies = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + const integritySkipped = validDomainCookies.length - importableCookies.length + const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( - `importValidatedCookies: ${cookies.length} validated of ${totalInput} total, partition="${targetPartition}"` + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"` ) const targetSession = session.fromPartition(targetPartition) let importedCount = 0 - let skipped = totalInput - cookies.length + let skipped = totalInput - importableCookies.length const domainSet = new Set() + let replacedCookies: Cookie[] | null = null + + if (mode === 'replace-imported-domains' && importableCookies.length > 0) { + try { + replacedCookies = await replaceCookiesForImportedDomains( + targetSession.cookies, + importableCookies.map((cookie) => cookie.domain) + ) + diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + const importedCookieKeys: { url: string; name: string }[] = [] + let setFailure: unknown = null - for (const cookie of cookies) { + for (const cookie of importableCookies) { try { // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. const isHostPrefixed = cookie.name.startsWith('__Host-') + const path = isHostPrefixed ? '/' : cookie.path await targetSession.cookies.set({ url: cookie.url, name: cookie.name, value: stripNonPrintable(cookie.value), ...(isHostPrefixed ? {} : { domain: cookie.domain }), - path: isHostPrefixed ? '/' : cookie.path, + path, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, expirationDate: cookie.expirationDate }) + const removalUrl = new URL(cookie.url) + removalUrl.pathname = path.startsWith('/') ? path : '/' + importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) importedCount++ // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain domainSet.add(cleanDomain) } catch (err) { skipped++ + setFailure = err if (skipped <= 5) { // Find the exact offending character position and code const val = cookie.value @@ -601,6 +648,32 @@ async function importValidatedCookies( ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` ) } + if (replacedCookies) { + break + } + } + } + + if (setFailure && replacedCookies) { + const rollbackFailures: unknown[] = [] + for (const cookie of importedCookieKeys.toReversed()) { + try { + await targetSession.cookies.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + try { + await restoreImportedDomainCookies(targetSession.cookies, replacedCookies) + } catch (err) { + rollbackFailures.push(err) + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') } } @@ -690,7 +763,12 @@ export async function importCookiesFromFile( } } - return importValidatedCookies(validated, parsed.length, targetPartition) + return importValidatedCookies( + validated, + parsed.length, + targetPartition, + 'replace-imported-domains' + ) } // --------------------------------------------------------------------------- @@ -1338,7 +1416,12 @@ async function importCookiesFromFirefox( return { ok: false, reason: 'No valid cookies found in Firefox.' } } - return importValidatedCookies(validated, rows.length, targetPartition) + return importValidatedCookies( + validated, + rows.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) diag(` Firefox import failed: ${String(err)}`) @@ -1392,7 +1475,12 @@ async function importCookiesFromSafari( return { ok: false, reason: 'All Safari cookies are expired.' } } - return importValidatedCookies(valid, cookies.length, targetPartition) + return importValidatedCookies( + valid, + cookies.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { diag(` Safari import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Safari.' } @@ -1572,22 +1660,6 @@ export async function importCookiesFromBrowser( } } - // Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue. - const INTEGRITY_COOKIE_NAMES = new Set([ - 'SIDCC', - '__Secure-1PSIDCC', - '__Secure-3PSIDCC', - '__Secure-STRP', - 'AEC' - ]) - function isIntegrityCookie(name: string, domain: string): boolean { - if (!INTEGRITY_COOKIE_NAMES.has(name)) { - return false - } - const d = domain.startsWith('.') ? domain.slice(1) : domain - return d === 'google.com' || d.endsWith('.google.com') - } - let imported = 0 let skipped = 0 let integritySkipped = 0 @@ -1608,6 +1680,7 @@ export async function importCookiesFromBrowser( } const decryptedCookies: DecryptedCookie[] = [] + const sourceDomainValidity = new Map() // Why: staging only backs the cold-restart replay, so any failure writing it disables that // fallback instead of aborting an import whose in-memory half still works. @@ -1658,11 +1731,21 @@ export async function importCookiesFromBrowser( const domain = sourceRow.host_key as string const name = sourceRow.name as string - if (isIntegrityCookie(name, domain)) { + if (isGoogleSourceBoundCookie(name, domain)) { integritySkipped++ continue } + let validDomain = sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + skipped++ + continue + } + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain domainSet.add(cleanDomain) @@ -1704,6 +1787,21 @@ export async function importCookiesFromBrowser( } diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + if (decryptedCookies.length === 0) { + closeStagingDb() + discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: sourceRows.length, + importedCookies: 0, + skippedCookies: skipped + integritySkipped, + domains: [] + } + } + } + if (stagingDb) { try { stagingDb.exec('COMMIT') @@ -1786,7 +1884,7 @@ export async function importCookiesFromBrowser( const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, - skippedCookies: skipped, + skippedCookies: skipped + integritySkipped, domains: [...domainSet].sort(), ...(warning ? { warning } : {}) }