mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(browser): decode Chromium SameSite storage values (135 cookies silently lost per profile) (#20076)
* fix(browser): decode Chromium SameSite storage values
* fix(browser): document Firefox's real SameSite domain and pin its default-arm inputs
Third-reviewer finding: the replacement comment reproduced the failure mode this
PR exists to kill. It said "Firefox's moz_cookies uses the same 0/1/2 values",
which is true of the overlap and silently wrong about the rest of the domain.
Firefox writes 256 (nsICookie SAMESITE_UNSET) for every cookie with no SameSite
attribute -- the most common shape in a modern profile -- NULL on pre-v10 rows,
and 0 for explicit None OR a legacy unset row the schema-15 migration left
behind, which are not distinguishable in the column. All of those must land on
unspecified, so the default arm is load-bearing for Firefox rather than
incidental. A later reader making the switch exhaustive against the old comment
would have regressed Firefox silently.
Also pins the inputs that were riding the default untested: 256, and the
non-integer arrivals (null, undefined, NaN) that the `?? -1` scan fallback and
pre-v10 Firefox rows produce. Decoder behaviour is unchanged; 11/11 pass.
---------
Co-authored-by: Merge Sim <sim@local>
(cherry picked from commit 6252f8149b)
This commit is contained in:
committed by
Merge Sim
parent
5c38ef9a6d
commit
ca46cb1f5a
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<ExpectedCookie, 'name' | 'secure' | 'sameSite'>
|
||||
|
||||
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,<title>same-site enum fixture</title>')
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user