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>
This commit is contained in:
Brennan Benson
2026-09-11 15:59:56 -07:00
committed by GitHub
co-authored by Merge Sim
parent 9c918b93a6
commit 6252f8149b
6 changed files with 317 additions and 23 deletions
@@ -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 0x000xFF 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')
})
})
+11 -16
View File
@@ -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'