diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts index e3bdc6a6409..eb057712a11 100644 --- a/src/main/browser/browser-cookie-chromium-scan.ts +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -7,7 +7,7 @@ import { } from './browser-cookie-import-policy' import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' -import { chromiumSameSite } from './browser-cookie-validation' +import { databaseSameSite } from './browser-cookie-validation' import { buildUndecryptableWarning, cookieEncryptionVersion, @@ -97,7 +97,8 @@ export function scanChromiumCookieRows( const path = sourceRow.path as string const secure = sourceRow.is_secure === 1n const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + // Why: pre-samesite schemas and NULL rows follow Chromium's own unspecified fallback. + const sameSite = databaseSameSite(Number(sourceRow.samesite ?? -1)) const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) const partition = partitionBySourceRow.get(sourceRow)! // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts index 60482b30e31..d7ada7e76de 100644 --- a/src/main/browser/browser-cookie-firefox-import.ts +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -9,7 +9,7 @@ import { cookieImportTarget, type CookieImportOptions } from './browser-cookie-import-pipeline' -import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import { databaseSameSite, deriveUrl, type ValidatedCookie } from './browser-cookie-validation' import type { DetectedBrowser } from './browser-cookie-detection-types' import { diag } from './browser-cookie-import-diagnostics' @@ -108,7 +108,7 @@ export async function importCookiesFromFirefox( path: row.path || '/', secure, httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), + sameSite: databaseSameSite(row.sameSite), expirationDate: row.expiry > 0 ? row.expiry : undefined, partition: readFirefoxRowPartition(row, firefoxColumns) }) diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index 31cdfc1f157..d3617928fb2 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -13,7 +13,7 @@ type ChromiumCookieTestRow = { hasCrossSiteAncestor?: 0 | 1 isSecure?: 0 | 1 isHttpOnly?: 0 | 1 - sameSite?: 0 | 1 | 2 | 3 + sameSite?: -1 | 0 | 1 | 2 | 3 | null } export function createChromiumCookieTestDatabase( @@ -38,7 +38,7 @@ export function createChromiumCookieTestDatabase( expires_utc INTEGER NOT NULL, is_secure INTEGER NOT NULL, is_httponly INTEGER NOT NULL, - samesite INTEGER NOT NULL, + samesite INTEGER, source_scheme INTEGER NOT NULL DEFAULT 0, source_port INTEGER NOT NULL DEFAULT -1, last_update_utc INTEGER NOT NULL DEFAULT 0, @@ -75,7 +75,7 @@ export function createChromiumCookieTestDatabase( row.encryptedValue ?? Buffer.alloc(0), row.isSecure ?? 0, row.isHttpOnly ?? 0, - row.sameSite ?? 0, + row.sameSite === undefined ? -1 : row.sameSite, 0, row.hasCrossSiteAncestor ?? 0 ) diff --git a/src/main/browser/browser-cookie-samesite.electron.test.ts b/src/main/browser/browser-cookie-samesite.electron.test.ts new file mode 100644 index 00000000000..4f04f93c72e --- /dev/null +++ b/src/main/browser/browser-cookie-samesite.electron.test.ts @@ -0,0 +1,268 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +type CookieSameSite = 'unspecified' | 'no_restriction' | 'lax' | 'strict' + +type ExpectedCookie = { + name: string + rawSameSite: -1 | 0 | 1 | 2 + secure: boolean + sameSite: CookieSameSite +} + +type JarCookie = Pick + +type ImportResult = { + ok: boolean + reason?: string + summary?: { importedCookies: number; skippedCookies: number } +} + +type FixtureResult = { + step: string + error?: string + beforeCookieCount: number + importResult: ImportResult + afterCookies: JarCookie[] +} + +type SourceShape = { + name: string + samesite: number | null + is_secure: number +} + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +const VALID_COMBINATIONS: readonly ExpectedCookie[] = [ + { + name: 'raw-minus-1-secure-0', + rawSameSite: -1, + secure: false, + sameSite: 'unspecified' + }, + // Ablation C: neither the old decoder nor the null-default regression affects this row. + { + name: 'raw-minus-1-secure-1', + rawSameSite: -1, + secure: true, + sameSite: 'unspecified' + }, + { name: 'raw-0-secure-1', rawSameSite: 0, secure: true, sameSite: 'no_restriction' }, + { name: 'raw-1-secure-0', rawSameSite: 1, secure: false, sameSite: 'lax' }, + { name: 'raw-1-secure-1', rawSameSite: 1, secure: true, sameSite: 'lax' }, + { name: 'raw-2-secure-0', rawSameSite: 2, secure: false, sameSite: 'strict' }, + { name: 'raw-2-secure-1', rawSameSite: 2, secure: true, sameSite: 'strict' } +] + +const REJECTION_CONTROL = { + name: 'raw-0-secure-0', + rawSameSite: 0, + secure: false +} as const + +const NULL_CASE = { + name: 'raw-null-secure-0', + rawSameSite: null, + secure: false, + sameSite: 'unspecified' +} as const + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)}) +const resultPath = ${JSON.stringify(resultPath)} +let currentStep = 'starting' + +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 30000) + await app.whenReady() + mark('ready') + const partition = 'persist:samesite-enum-cookie-test' + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,same-site enum fixture') + mark('window loaded') + const beforeCookieCount = (await targetSession.cookies.get({})).length + + const importResult = await importCookiesFromBrowser( + { + family: 'chrome', + label: 'Google Chrome', + cookiesPath: ${JSON.stringify(sourceDbPath)}, + profiles: [], + selectedProfile: '' + }, + partition + ) + mark('import finished') + + const afterCookies = (await targetSession.cookies.get({})) + .filter((cookie) => cookie.name.startsWith('raw-')) + .map((cookie) => ({ + name: cookie.name, + sameSite: cookie.sameSite, + secure: cookie.secure + })) + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + step: currentStep, + beforeCookieCount, + importResult, + afterCookies + })) + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) + app.exit(1) +}) +` +} + +function readSourceShape(sourceDbPath: string): SourceShape[] { + const db = new DatabaseSync(sourceDbPath, { readOnly: true }) + try { + return db + .prepare('SELECT name, samesite, is_secure FROM cookies ORDER BY rowid') + .all() as SourceShape[] + } finally { + db.close() + } +} + +async function runFixture(): Promise<{ fixture: FixtureResult; sourceShape: SourceShape[] }> { + const root = mkdtempSync(join(tmpdir(), 'orca-samesite-enum-')) + fixtureRoots.push(root) + const bundlePath = join(root, 'cookie-import-samesite.cjs') + const bundleEntryPath = join(root, 'cookie-import-samesite.ts') + const resultPath = join(root, 'result.json') + const fixturePath = join(root, 'main.cjs') + const sourceDbPath = join(root, 'source-cookies.db') + const rows = [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + domain: '.samesite.example', + name, + value: 'synthetic-value', + isSecure: secure ? (1 as const) : (0 as const), + sameSite: rawSameSite + }) + ) + createChromiumCookieTestDatabase(sourceDbPath, rows).close() + const sourceShape = readSourceShape(sourceDbPath) + writeFileSync( + bundleEntryPath, + `export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}` + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + lib: { + entry: bundleEntryPath, + formats: ['cjs'], + fileName: () => 'cookie-import-samesite.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeout: 90_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceShape } +} + +describe('Chromium SameSite storage enum import', () => { + let fixture: FixtureResult + let sourceShape: SourceShape[] + + beforeAll(async () => { + ;({ fixture, sourceShape } = await runFixture()) + }, 120_000) + + it('runs the real Chromium import against the complete synthetic matrix', () => { + expect(fixture.step).toBe('import finished') + expect(fixture.beforeCookieCount).toBe(0) + expect(fixture.importResult.ok).toBe(true) + expect(sourceShape).toEqual( + [REJECTION_CONTROL, ...VALID_COMBINATIONS, NULL_CASE].map( + ({ name, rawSameSite, secure }) => ({ + name, + samesite: rawSameSite, + is_secure: secure ? 1 : 0 + }) + ) + ) + }) + + it.each(VALID_COMBINATIONS)( + 'imports $name with the decoded SameSite and authored Secure flag', + ({ name, sameSite, secure }) => { + expect(fixture.afterCookies.find((cookie) => cookie.name === name)).toEqual({ + name, + sameSite, + secure + }) + } + ) + + it('rejects the synthetic SameSite=None insecure control and continues later writes', () => { + // Chromium refuses this shape, so real profiles cannot contain it. Keeping the synthetic row + // proves the fixture can observe rejection instead of making every presence assertion vacuous. + expect( + fixture.afterCookies.find((cookie) => cookie.name === REJECTION_CONTROL.name) + ).toBeUndefined() + expect(fixture.afterCookies.find((cookie) => cookie.name === 'raw-2-secure-1')).toBeDefined() + }) + + it('imports a null SameSite column as unspecified without changing Secure', () => { + expect(fixture.afterCookies.find((cookie) => cookie.name === NULL_CASE.name)).toEqual({ + name: NULL_CASE.name, + sameSite: NULL_CASE.sameSite, + secure: NULL_CASE.secure + }) + }) +}) diff --git a/src/main/browser/browser-cookie-validation.test.ts b/src/main/browser/browser-cookie-validation.test.ts new file mode 100644 index 00000000000..d7b065beeda --- /dev/null +++ b/src/main/browser/browser-cookie-validation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { databaseSameSite } from './browser-cookie-validation' + +describe('databaseSameSite', () => { + it.each([ + { raw: -1, expected: 'unspecified' }, + { raw: 0, expected: 'no_restriction' }, + { raw: 1, expected: 'lax' }, + { raw: 2, expected: 'strict' }, + { raw: 3, expected: 'unspecified' }, + // Why: 256 is Firefox's nsICookie SAMESITE_UNSET, written for every cookie with no SameSite + // attribute -- the most common shape in a modern Firefox profile. It reaches the default arm, + // so without this case the decoder's busiest Firefox input would be untested. + { raw: 256, expected: 'unspecified' }, + { raw: 99, expected: 'unspecified' }, + { raw: 1.5, expected: 'unspecified' } + ] as const)('decodes $raw as $expected', ({ raw, expected }) => { + expect(databaseSameSite(raw)).toBe(expected) + }) + + // Why: pre-v10 Firefox rows carry NULL, and the Chromium scan feeds `?? -1`. Both arrive here as + // a non-integer rather than a number, and both must be unspecified rather than None (0). + it.each([ + { label: 'null', raw: null }, + { label: 'undefined', raw: undefined }, + { label: 'NaN', raw: Number.NaN } + ])('decodes $label as unspecified', ({ raw }) => { + expect(databaseSameSite(raw as unknown as number)).toBe('unspecified') + }) +}) diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts index 6ae543245ef..7f08f713f2e 100644 --- a/src/main/browser/browser-cookie-validation.ts +++ b/src/main/browser/browser-cookie-validation.ts @@ -25,21 +25,16 @@ export type ValidatedCookie = ImportedCookieFields & { partition: SourcePartitionRead } -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { +// Chromium stores net::CookieSameSite unchanged; see net/cookies/cookie_constants.h and +// net/extras/sqlite/sqlite_persistent_cookie_store.cc (-1 unspecified, 0 None, 1 Lax, 2 Strict; +// 3 is the deprecated EXTENDED value Chromium itself folds to unspecified). +// Firefox's moz_cookies OVERLAPS on 1=Lax and 2=Strict but its domain is wider, so the default arm +// is load-bearing for it, not incidental: 256 (nsICookie SAMESITE_UNSET) is what modern Firefox +// writes for every cookie with no SameSite attribute, NULL appears on pre-v10 rows, and 0 means +// explicit None OR a legacy unset row the schema-15 migration left behind — the two are not +// distinguishable in the column. Every one of those must land on unspecified, so do NOT make this +// switch exhaustive or drop the default without re-checking both browsers' real value domains. +export function databaseSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { switch (raw) { case 0: return 'no_restriction' @@ -56,7 +51,7 @@ export function normalizeSameSite( raw: unknown ): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { if (typeof raw === 'number') { - return chromiumSameSite(raw) + return databaseSameSite(raw) } if (typeof raw !== 'string') { return 'unspecified'