fix(browser): align cookie import safeguards (#12607)

* fix(browser): align cookie import safeguards

* fix(browser): preserve sessions on failed cookie imports

* fix(browser): bound single-label cookie replacement

* fix(browser): preserve host-only parent cookies

* fix(build): bundle cookie scope parser
This commit is contained in:
Brennan Benson
2026-08-05 12:51:05 -07:00
committed by GitHub
parent b1b291db08
commit 38ba22ecd1
10 changed files with 791 additions and 40 deletions
@@ -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')
})
+1
View File
@@ -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'
+1
View File
@@ -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",
+16
View File
@@ -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
@@ -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'
})
})
})
@@ -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<string>
ancestors: Set<string>
descendantRoots: Set<string>
} {
const exact = new Set<string>()
const ancestors = new Set<string>()
const descendantRoots = new Set<string>()
const seen = new Set<string>()
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<typeof importedDomainScopes>
): 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, 'set'>,
cookies: readonly Cookie[]
): Promise<void> {
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<Cookies, 'get' | 'remove' | 'set'>,
importedDomains: readonly string[]
): Promise<Cookie[]> {
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
}
@@ -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<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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'
}
}
@@ -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),
+12 -8
View File
@@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer {
describe('importCookiesFromFile', () => {
let tmpDir: string
let cookiesGetMock: ReturnType<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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')
})
})
+128 -30
View File
@@ -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<BrowserCookieImportResult> {
const importDomainCache = new Map<string, boolean>()
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<string>()
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<string, boolean>()
// 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 } : {})
}