From efa6b7b945ceebf99d84ae08a52436e0ec006f9c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:12:27 -0700 Subject: [PATCH] fix(browser): snapshot Chromium cookie WAL safely (#8195) * fix(browser): snapshot Chromium cookie WAL safely * fix(browser): clean partial cookie staging copies --- .../browser/browser-cookie-import.test.ts | 33 +++- src/main/browser/browser-cookie-import.ts | 43 ++++- .../browser/chromium-cookie-snapshot.test.ts | 170 ++++++++++++++++++ src/main/browser/chromium-cookie-snapshot.ts | 140 +++++++++++++++ 4 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 src/main/browser/chromium-cookie-snapshot.test.ts create mode 100644 src/main/browser/chromium-cookie-snapshot.ts diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 1b4b0e1b1b7..27663017097 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -47,7 +47,7 @@ import { createChromiumCookieTestDatabase, encryptMacChromiumCookie } from './browser-cookie-import-test-database' -import { existsSync, writeFileSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, writeFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -404,8 +404,8 @@ describe('importCookiesFromBrowser Chromium', () => { it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => { const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') - // Why: keeping the WAL writer open proves import reads Chromium's live SQLite - // state instead of relying on a closed-file snapshot. + // Why: keeping the writer open leaves the committed row in WAL, matching a + // running Chromium profile whose latest auth cookies are not checkpointed. const sourceDb = createChromiumCookieTestDatabase( sourceCookiesPath, [{ name: 'sid', value: 'source-value' }], @@ -418,6 +418,9 @@ describe('importCookiesFromBrowser Chromium', () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') try { expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) + const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) => + readFileSync(sourceCookiesPath + suffix) + ) const result = await importCookiesFromBrowser( chromeBrowser(sourceCookiesPath), @@ -434,8 +437,14 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( - false + true ) + expect( + copyFileSyncMock.mock.calls.some(([source]) => source === `${sourceCookiesPath}-wal`) + ).toBe(true) + expect( + ['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix)) + ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) } finally { @@ -505,6 +514,22 @@ describe('importCookiesFromBrowser Chromium', () => { platformSpy.mockRestore() } }) + + it('removes partial staging data when the target database copy fails', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, []).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + copyFileSyncMock.mockImplementationOnce((_source: string, destination: string) => { + writeFileSync(destination, 'partial cookie database') + throw new Error('simulated copy failure') + }) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result).toEqual({ ok: false, reason: 'Could not create staging cookie database.' }) + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + }) }) describe('detectInstalledBrowsers', () => { diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index efe2441b8a7..3191509d30c 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -78,6 +78,10 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { + createChromiumCookieSnapshot, + type ChromiumCookieSnapshot +} from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' // --------------------------------------------------------------------------- @@ -1455,9 +1459,6 @@ export async function importCookiesFromBrowser( return importCookiesFromSafari(browser, targetPartition) } - // Why: SQLite can coordinate with Chromium's live WAL locks without a pre-copy. - const sourceCookiesPath = browser.cookiesPath - // Why: Electron's cookies.set() API rejects many valid cookie values (binary // bytes > 0x7F etc). Instead, decrypt from the source browser and write // plaintext directly to the SQLite `value` column. CookieMonster reads @@ -1507,16 +1508,44 @@ export async function importCookiesFromBrowser( mkdirSync(stagingDir, { recursive: true }) copyFileSync(liveCookiesPath, stagingCookiesPath) } catch { + // Why: copyFile is not atomic and can leave a partial database after an + // I/O failure, so failed imports must not retain sensitive cookie data. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } return { ok: false, reason: 'Could not create staging cookie database.' } } + let sourceSnapshot: ChromiumCookieSnapshot + try { + // Why: the browser can commit cookies only to WAL while it remains open; + // snapshot retries prevent pairing the main DB with a racing WAL generation. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${err}`) + return { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + let sourceDb: InstanceType | null = null let stagingDb: InstanceType | null = null try { // Why: Chromium stores timestamps as microseconds since 1601, which can exceed // Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss. - sourceDb = new DatabaseSync(sourceCookiesPath, { readOnly: true, readBigInts: true }) + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { + readOnly: true, + readBigInts: true + }) stagingDb = new DatabaseSync(stagingCookiesPath) const targetColumnInfo = stagingDb @@ -1784,5 +1813,11 @@ export async function importCookiesFromBrowser( `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` ) } + } finally { + try { + sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${err}`) + } } } diff --git a/src/main/browser/chromium-cookie-snapshot.test.ts b/src/main/browser/chromium-cookie-snapshot.test.ts new file mode 100644 index 00000000000..16fbd50a63f --- /dev/null +++ b/src/main/browser/chromium-cookie-snapshot.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' + +const { beforeCopyMock } = vi.hoisted(() => ({ beforeCopyMock: vi.fn() })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + beforeCopyMock(...args) + return actual.copyFileSync(...args) + } + } +}) + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { createChromiumCookieSnapshot } from './chromium-cookie-snapshot' + +function sourceFiles(databasePath: string): Map { + const files = new Map() + for (const suffix of ['', '-wal', '-shm'] as const) { + const path = databasePath + suffix + if (existsSync(path)) { + files.set(suffix, readFileSync(path)) + } + } + return files +} + +function expectSourceFilesUnchanged(databasePath: string, before: Map): void { + expect(sourceFiles(databasePath)).toEqual(before) +} + +describe('createChromiumCookieSnapshot', () => { + let root: string + let writer: DatabaseSync | null + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-chromium-snapshot-test-')) + writer = null + beforeCopyMock.mockReset() + }) + + afterEach(() => { + writer?.close() + rmSync(root, { recursive: true, force: true }) + }) + + it('reads committed WAL-only rows without changing any live source file', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Network', 'Cookies') + writer = createChromiumCookieTestDatabase( + sourcePath, + [{ name: 'wal-session', value: 'fresh-value' }], + { journalMode: 'wal' } + ) + const before = sourceFiles(sourcePath) + + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const snapshotDir = dirname(snapshot.databasePath) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const rows = database.prepare('SELECT name, value FROM cookies').all() + database.close() + + expect(rows).toEqual([expect.objectContaining({ name: 'wal-session', value: 'fresh-value' })]) + expect(existsSync(`${snapshot.databasePath}-wal`)).toBe(true) + expect(existsSync(`${snapshot.databasePath}-shm`)).toBe(true) + expectSourceFilesUnchanged(sourcePath, before) + + snapshot.cleanup() + expect(existsSync(snapshotDir)).toBe(false) + }) + + it('snapshots a database when WAL and SHM sidecars are absent', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + createChromiumCookieTestDatabase(sourcePath, [ + { name: 'main-session', value: 'persisted-value' } + ]).close() + + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const row = database.prepare('SELECT name, value FROM cookies').get() + database.close() + + expect(row).toEqual(expect.objectContaining({ name: 'main-session', value: 'persisted-value' })) + expect(existsSync(`${snapshot.databasePath}-wal`)).toBe(false) + snapshot.cleanup() + }) + + it('retries when a WAL appears while the main database is being copied', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + createChromiumCookieTestDatabase(sourcePath, []).close() + beforeCopyMock.mockImplementationOnce((source: string) => { + if (source === sourcePath) { + writer = new DatabaseSync(sourcePath) + writer.exec( + "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; INSERT INTO cookies (creation_utc, host_key, name, value, path, expires_utc, is_secure, is_httponly, samesite) VALUES (1, '.example.com', 'raced-in', 'wal-value', '/', 0, 0, 0, 0)" + ) + } + }) + + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const row = database.prepare("SELECT value FROM cookies WHERE name = 'raced-in'").get() + database.close() + + expect(row).toEqual(expect.objectContaining({ value: 'wal-value' })) + expect(beforeCopyMock.mock.calls.filter(([source]) => source === sourcePath)).toHaveLength(2) + snapshot.cleanup() + }) + + it('retries when sidecars disappear after Chromium checkpoints on close', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + writer = createChromiumCookieTestDatabase( + sourcePath, + [{ name: 'checkpointed', value: 'main-value' }], + { journalMode: 'wal' } + ) + beforeCopyMock.mockImplementationOnce((source: string) => { + if (source === sourcePath) { + writer?.close() + writer = null + } + }) + + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const row = database.prepare("SELECT value FROM cookies WHERE name = 'checkpointed'").get() + database.close() + + expect(row).toEqual(expect.objectContaining({ value: 'main-value' })) + expect(beforeCopyMock.mock.calls.filter(([source]) => source === sourcePath)).toHaveLength(2) + snapshot.cleanup() + }) + + it('removes a partial snapshot when a WAL copy fails', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + const snapshotsRoot = join(root, 'snapshots') + mkdirSync(snapshotsRoot) + writer = createChromiumCookieTestDatabase(sourcePath, [{ name: 'session', value: 'value' }], { + journalMode: 'wal' + }) + beforeCopyMock.mockImplementation((source: string) => { + if (source === `${sourcePath}-wal`) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + }) + + expect(() => createChromiumCookieSnapshot(sourcePath, { tempRoot: snapshotsRoot })).toThrow( + 'permission denied' + ) + expect(readdirSync(snapshotsRoot)).toEqual([]) + }) + + it('removes its temporary directory when the source database is missing', () => { + const snapshotsRoot = join(root, 'snapshots') + mkdirSync(snapshotsRoot) + + expect(() => + createChromiumCookieSnapshot(join(root, 'missing', 'Cookies'), { + tempRoot: snapshotsRoot + }) + ).toThrow('does not exist') + expect(readdirSync(snapshotsRoot)).toEqual([]) + }) +}) diff --git a/src/main/browser/chromium-cookie-snapshot.ts b/src/main/browser/chromium-cookie-snapshot.ts new file mode 100644 index 00000000000..4b54e98be8d --- /dev/null +++ b/src/main/browser/chromium-cookie-snapshot.ts @@ -0,0 +1,140 @@ +import { copyFileSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const SNAPSHOT_ATTEMPTS = 5 + +type FileState = { + device: bigint + inode: bigint + size: bigint + modifiedAt: bigint + changedAt: bigint +} + +export type ChromiumCookieSnapshot = { + databasePath: string + cleanup: () => void +} + +type ChromiumCookieSnapshotOptions = { + tempRoot?: string +} + +function removeSnapshotDirectory(path: string): void { + // Why: Windows can briefly retain SQLite handles after close; bounded retries + // keep cleanup reliable without ever touching the live browser directory. + rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) +} + +function isMissingFileError(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + +function readFileState(path: string): FileState | null { + try { + const stats = statSync(path, { bigint: true }) + return { + device: stats.dev, + inode: stats.ino, + size: stats.size, + modifiedAt: stats.mtimeNs, + changedAt: stats.ctimeNs + } + } catch (error) { + if (isMissingFileError(error)) { + return null + } + throw error + } +} + +function sameFileState(left: FileState | null, right: FileState | null): boolean { + if (!left || !right) { + return left === right + } + return ( + left.device === right.device && + left.inode === right.inode && + left.size === right.size && + left.modifiedAt === right.modifiedAt && + left.changedAt === right.changedAt + ) +} + +function removeAttemptFiles(databasePath: string): void { + for (const suffix of ['', '-wal', '-shm'] as const) { + try { + unlinkSync(databasePath + suffix) + } catch { + /* best-effort between snapshot attempts */ + } + } +} + +function copyStableAttempt(sourcePath: string, databasePath: string): boolean { + const sourceWalPath = `${sourcePath}-wal` + const databaseBefore = readFileState(sourcePath) + const walBefore = readFileState(sourceWalPath) + if (!databaseBefore) { + throw new Error('Chromium cookies database does not exist') + } + + removeAttemptFiles(databasePath) + copyFileSync(sourcePath, databasePath) + + if (walBefore) { + try { + // Why: SQLite only discovers a WAL whose basename exactly matches the DB. + copyFileSync(sourceWalPath, `${databasePath}-wal`) + } catch (error) { + if (isMissingFileError(error)) { + return false + } + throw error + } + } + // Why: SHM is a transient mmap WAL index that may be locked or mid-update. + // SQLite safely rebuilds a matching Cookies-shm beside the private WAL copy. + + const databaseAfter = readFileState(sourcePath) + const walAfter = readFileState(sourceWalPath) + if (!sameFileState(databaseBefore, databaseAfter) || !sameFileState(walBefore, walAfter)) { + return false + } + + const copiedDatabase = readFileState(databasePath) + const copiedWal = readFileState(`${databasePath}-wal`) + return ( + copiedDatabase?.size === databaseBefore.size && + (walBefore ? copiedWal?.size === walBefore.size : copiedWal === null) + ) +} + +export function createChromiumCookieSnapshot( + sourcePath: string, + options: ChromiumCookieSnapshotOptions = {} +): ChromiumCookieSnapshot { + const snapshotDir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'orca-cookie-import-')) + const databasePath = join(snapshotDir, 'Cookies') + let keepSnapshot = false + + try { + for (let attempt = 0; attempt < SNAPSHOT_ATTEMPTS; attempt += 1) { + if (copyStableAttempt(sourcePath, databasePath)) { + keepSnapshot = true + return { + databasePath, + cleanup: () => removeSnapshotDirectory(snapshotDir) + } + } + } + throw new Error('Chromium cookies database changed while creating a snapshot') + } finally { + if (!keepSnapshot) { + removeSnapshotDirectory(snapshotDir) + } + } +}