Files
orca/src/main/browser/browser-cookie-validation.ts
T
Brennan Benson ca46cb1f5a 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)
2026-09-11 16:21:44 -07:00

123 lines
3.8 KiB
TypeScript

import { normalizeCookieDomain } from './browser-cookie-import-policy'
import {
readJsonCookiePartition,
type SourcePartitionRead
} from './browser-cookie-source-partition'
import type { ImportedCookieFields } from './browser-cookie-import-write'
export type RawCookieEntry = {
domain?: unknown
name?: unknown
value?: unknown
path?: unknown
secure?: unknown
httpOnly?: unknown
sameSite?: unknown
expirationDate?: unknown
partitionKey?: unknown
partitionKeyOpaque?: unknown
}
// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to
// state what it read. An optional field would let a new source silently default to unpartitioned.
export type ValidatedCookie = ImportedCookieFields & {
sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
partition: SourcePartitionRead
}
// 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'
case 1:
return 'lax'
case 2:
return 'strict'
default:
return 'unspecified'
}
}
export function normalizeSameSite(
raw: unknown
): 'unspecified' | 'no_restriction' | 'lax' | 'strict' {
if (typeof raw === 'number') {
return databaseSameSite(raw)
}
if (typeof raw !== 'string') {
return 'unspecified'
}
const lower = raw.toLowerCase()
if (lower === 'lax') {
return 'lax'
}
if (lower === 'strict') {
return 'strict'
}
if (lower === 'none' || lower === 'no_restriction') {
return 'no_restriction'
}
return 'unspecified'
}
// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag.
export function deriveUrl(domain: string, secure: boolean): string | null {
const normalizedDomain = normalizeCookieDomain(domain)
if (!normalizedDomain) {
return null
}
const protocol = secure ? 'https' : 'http'
try {
const url = new URL(`${protocol}://${normalizedDomain}/`)
return url.toString()
} catch {
return null
}
}
export function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null {
if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) {
return null
}
if (typeof raw.name !== 'string' || raw.name.trim().length === 0) {
return null
}
if (typeof raw.value !== 'string') {
return null
}
const domain = raw.domain.trim()
const secure = raw.secure === true || raw.secure === 1
const url = deriveUrl(domain, secure)
if (!url) {
return null
}
const expirationDate =
typeof raw.expirationDate === 'number' && raw.expirationDate > 0
? raw.expirationDate
: undefined
return {
url,
name: raw.name.trim(),
value: raw.value,
domain,
path: typeof raw.path === 'string' ? raw.path : '/',
secure,
httpOnly: raw.httpOnly === true || raw.httpOnly === 1,
sameSite: normalizeSameSite(raw.sameSite),
expirationDate,
partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque)
}
}