fix(browser): snapshot Chromium cookie WAL safely (#8195)

* fix(browser): snapshot Chromium cookie WAL safely

* fix(browser): clean partial cookie staging copies
This commit is contained in:
Jinjing 2026-07-10 18:12:27 -07:00 committed by GitHub
parent f1d09275b7
commit efa6b7b945
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 378 additions and 8 deletions

View File

@ -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', () => {

View File

@ -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<typeof DatabaseSync> | null = null
let stagingDb: InstanceType<typeof DatabaseSync> | 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}`)
}
}
}

View File

@ -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<typeof NodeFs>()
return {
...actual,
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
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<string, Buffer> {
const files = new Map<string, Buffer>()
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<string, Buffer>): 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([])
})
})

View File

@ -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)
}
}
}