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:
parent
f1d09275b7
commit
efa6b7b945
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue