mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(browser): fail-closed cookie clear after a partial import wipe (STA-4090) (#14191)
* fix(browser): fail-closed cookie clear after a partial import wipe (STA-4090) A rejected bulk clear plus a later per-cookie removal could permanently delete cookies that were already gone. Snapshot partition-complete identities before mutating, keep the bulk clear, and restore through CDP so a failed import never silently drops existing cookies. * fix(browser): preserve exact cookie clear identities * fix(browser): abort unrepresentable cookie clears * fix(browser): skip excluded-only cookie clears
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Session } from 'electron'
|
||||
|
||||
const electron = vi.hoisted(() => {
|
||||
const windows: BrowserWindow[] = []
|
||||
const getAllWebContents = vi.fn(() => [])
|
||||
|
||||
class BrowserWindow {
|
||||
destroy = vi.fn()
|
||||
resolveLoad!: () => void
|
||||
webContents = {
|
||||
debugger: { sendCommand: vi.fn(async (_method: string) => ({ cookies: [] })) },
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
windows.push(this)
|
||||
}
|
||||
|
||||
loadURL() {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.resolveLoad = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { BrowserWindow, getAllWebContents, windows }
|
||||
})
|
||||
|
||||
const lease = vi.hoisted(() => ({ release: vi.fn() }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: electron.BrowserWindow,
|
||||
webContents: { getAllWebContents: electron.getAllWebContents }
|
||||
}))
|
||||
vi.mock('./electron-debugger-lease', () => ({
|
||||
acquireElectronDebugger: vi.fn(() => lease)
|
||||
}))
|
||||
|
||||
import { openCookieClearStore } from './browser-cookie-clear-store'
|
||||
|
||||
function targetSession(): Session {
|
||||
return { cookies: { get: vi.fn(), remove: vi.fn() } } as unknown as Session
|
||||
}
|
||||
|
||||
describe('cookie clear debugger lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
electron.windows.length = 0
|
||||
electron.getAllWebContents.mockReturnValue([])
|
||||
lease.release.mockClear()
|
||||
})
|
||||
|
||||
it('memoizes a pending hidden-window attachment across concurrent callers', async () => {
|
||||
const store = openCookieClearStore(targetSession())
|
||||
const snapshot = store.snapshotClearIdentities([])
|
||||
const restore = store.restoreClearIdentities([])
|
||||
|
||||
expect(electron.windows).toHaveLength(1)
|
||||
electron.windows[0].resolveLoad()
|
||||
await Promise.all([snapshot, restore])
|
||||
expect(
|
||||
electron.windows[0].webContents.debugger.sendCommand.mock.calls.map(([method]) => method)
|
||||
).toEqual(['Network.getAllCookies'])
|
||||
store.dispose()
|
||||
expect(lease.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases an attachment that resolves after disposal', async () => {
|
||||
const store = openCookieClearStore(targetSession())
|
||||
const snapshot = store.snapshotClearIdentities([])
|
||||
|
||||
store.dispose()
|
||||
electron.windows[0].resolveLoad()
|
||||
await expect(snapshot).rejects.toThrow(/disposed during debugger attachment/)
|
||||
expect(lease.release).toHaveBeenCalledOnce()
|
||||
expect(electron.windows[0].destroy).toHaveBeenCalledOnce()
|
||||
await expect(store.restoreClearIdentities([])).rejects.toThrow(/store was disposed/)
|
||||
expect(electron.windows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
cdpRestoreParamsFromIdentity,
|
||||
cookieClearIdentitiesFromCdp
|
||||
} from './browser-cookie-clear-store'
|
||||
|
||||
const chipsCookie: Cookie = {
|
||||
domain: 'app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'no_restriction',
|
||||
value: 'keep-me'
|
||||
}
|
||||
|
||||
describe('cookie clear CDP identities', () => {
|
||||
it('captures a CHIPS partition key for restore and never invents one', () => {
|
||||
const identities = cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(identities).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'chips-auth',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
])
|
||||
expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'chips-auth',
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when CDP cannot identify a removable cookie', () => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[]
|
||||
)
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it('keeps host-only and domain cookies with the same coordinates distinct', () => {
|
||||
const cookies: Cookie[] = [
|
||||
{ ...chipsCookie, domain: 'example.com', hostOnly: true, name: 'twin', value: 'host' },
|
||||
{ ...chipsCookie, domain: '.example.com', hostOnly: false, name: 'twin', value: 'domain' }
|
||||
]
|
||||
const identities = cookieClearIdentitiesFromCdp(
|
||||
cookies.map((cookie) => ({ cookie, url: 'https://example.com/' })),
|
||||
[
|
||||
{ name: 'twin', value: 'host', domain: 'example.com', path: '/' },
|
||||
{ name: 'twin', value: 'domain', domain: '.example.com', path: '/' }
|
||||
]
|
||||
)
|
||||
|
||||
expect(identities).toEqual([
|
||||
expect.objectContaining({ value: 'host', domain: 'example.com', hostOnly: true }),
|
||||
expect.objectContaining({ value: 'domain', domain: '.example.com', hostOnly: false })
|
||||
])
|
||||
expect(cdpRestoreParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
|
||||
expect(cdpRestoreParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
|
||||
})
|
||||
|
||||
it('indexes CDP cookies once instead of rescanning the jar for every cookie', () => {
|
||||
let domainReads = 0
|
||||
const count = 200
|
||||
const cookies = Array.from({ length: count }, (_, index) => ({
|
||||
cookie: { ...chipsCookie, domain: `host-${index}.example`, name: `cookie-${index}` },
|
||||
url: `https://host-${index}.example/`
|
||||
}))
|
||||
const cdpCookies = cookies.map(({ cookie }) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
get domain() {
|
||||
domainReads += 1
|
||||
return cookie.domain
|
||||
},
|
||||
path: '/'
|
||||
}))
|
||||
|
||||
expect(cookieClearIdentitiesFromCdp(cookies, cdpCookies)).toHaveLength(count)
|
||||
expect(domainReads).toBeLessThan(count * 6)
|
||||
})
|
||||
|
||||
it('does not turn an unspecified SameSite policy into explicit Lax', () => {
|
||||
expect(
|
||||
cdpRestoreParamsFromIdentity({
|
||||
url: 'https://example.com/',
|
||||
name: 'unspecified',
|
||||
value: 'value',
|
||||
sameSite: 'unspecified'
|
||||
})
|
||||
).not.toHaveProperty('sameSite')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { BrowserWindow, webContents, type Cookie, type Session } from 'electron'
|
||||
import { acquireElectronDebugger } from './electron-debugger-lease'
|
||||
import { normalizeCookieDomain } from './browser-cookie-import-policy'
|
||||
import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieClearStore
|
||||
} from './browser-cookie-import-clear'
|
||||
|
||||
type CdpCookiePartitionKey = {
|
||||
topLevelSite?: string
|
||||
hasCrossSiteAncestor?: boolean
|
||||
}
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
session?: boolean
|
||||
expires?: number
|
||||
sameSite?: string
|
||||
partitionKey?: CdpCookiePartitionKey
|
||||
}
|
||||
|
||||
type CookieClearDebugger = {
|
||||
sendCommand: (method: string, params?: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
|
||||
type CookieClearSession = {
|
||||
debugger: CookieClearDebugger
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function findPartitionWebContents(targetSession: Session) {
|
||||
return webContents
|
||||
.getAllWebContents()
|
||||
.find((contents) => !contents.isDestroyed() && contents.session === targetSession)
|
||||
}
|
||||
|
||||
function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined {
|
||||
if (sameSite === 'strict') {
|
||||
return 'Strict'
|
||||
}
|
||||
if (sameSite === 'no_restriction') {
|
||||
return 'None'
|
||||
}
|
||||
return sameSite === 'lax' ? 'Lax' : undefined
|
||||
}
|
||||
|
||||
function electronSameSite(sameSite: string | undefined): Cookie['sameSite'] {
|
||||
if (sameSite === 'Strict') {
|
||||
return 'strict'
|
||||
}
|
||||
if (sameSite === 'None') {
|
||||
return 'no_restriction'
|
||||
}
|
||||
return sameSite === 'Lax' ? 'lax' : 'unspecified'
|
||||
}
|
||||
|
||||
function partitionKeyFromCdp(
|
||||
partitionKey: CdpCookiePartitionKey | undefined
|
||||
): CookieClearPartitionKey | undefined {
|
||||
const topLevelSite = partitionKey?.topLevelSite
|
||||
if (!topLevelSite) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
topLevelSite,
|
||||
hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor === true
|
||||
}
|
||||
}
|
||||
|
||||
function cookieScopeKey(
|
||||
name: string,
|
||||
domain: string | undefined,
|
||||
path: string | undefined,
|
||||
hostOnly: boolean
|
||||
): string | null {
|
||||
const normalizedDomain = domain ? normalizeCookieDomain(domain) : null
|
||||
return normalizedDomain ? JSON.stringify([name, normalizedDomain, path || '/', hostOnly]) : null
|
||||
}
|
||||
|
||||
function cdpCookieScopeKey(cookie: CdpCookie): string | null {
|
||||
return cookieScopeKey(cookie.name, cookie.domain, cookie.path, !cookie.domain?.startsWith('.'))
|
||||
}
|
||||
|
||||
function indexCdpCookies(cookies: readonly CdpCookie[]): Map<string, CdpCookie[]> {
|
||||
const index = new Map<string, CdpCookie[]>()
|
||||
for (const cookie of cookies) {
|
||||
const key = cdpCookieScopeKey(cookie)
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
const matches = index.get(key) ?? []
|
||||
matches.push(cookie)
|
||||
index.set(key, matches)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie.partitionKey)
|
||||
return {
|
||||
url,
|
||||
name: cdpCookie.name,
|
||||
value: cdpCookie.value,
|
||||
domain: cdpCookie.domain,
|
||||
hostOnly: !cdpCookie.domain?.startsWith('.'),
|
||||
path: cdpCookie.path,
|
||||
secure: cdpCookie.secure,
|
||||
httpOnly: cdpCookie.httpOnly,
|
||||
sameSite: electronSameSite(cdpCookie.sameSite),
|
||||
...(cdpCookie.session === true || cdpCookie.expires == null
|
||||
? {}
|
||||
: { expirationDate: cdpCookie.expires }),
|
||||
...(partitionKey ? { partitionKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
|
||||
const existing = findPartitionWebContents(targetSession)
|
||||
const window = existing
|
||||
? null
|
||||
: new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
session: targetSession,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
try {
|
||||
if (window) {
|
||||
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
|
||||
}
|
||||
const contents = existing ?? window?.webContents
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
throw new Error('Could not attach to the cookie session for an atomic clear')
|
||||
}
|
||||
const lease = acquireElectronDebugger(contents)
|
||||
return {
|
||||
debugger: contents.debugger,
|
||||
dispose: () => {
|
||||
lease.release()
|
||||
window?.destroy()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
window?.destroy()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieClearIdentitiesFromCdp(
|
||||
cookies: readonly { cookie: Cookie; url: string }[],
|
||||
cdpCookies: readonly CdpCookie[]
|
||||
): CookieClearIdentity[] {
|
||||
const identities: CookieClearIdentity[] = []
|
||||
const seen = new Set<string>()
|
||||
const cdpCookieIndex = indexCdpCookies(cdpCookies)
|
||||
for (const item of cookies) {
|
||||
const key = cookieScopeKey(
|
||||
item.cookie.name,
|
||||
item.cookie.domain,
|
||||
item.cookie.path,
|
||||
item.cookie.hostOnly ?? !item.cookie.domain?.startsWith('.')
|
||||
)
|
||||
const matches = key ? (cdpCookieIndex.get(key) ?? []) : []
|
||||
if (matches.length === 0) {
|
||||
throw new Error('Could not snapshot cookie identity for an atomic clear')
|
||||
}
|
||||
for (const match of matches) {
|
||||
const key = JSON.stringify([
|
||||
item.url,
|
||||
match.name,
|
||||
match.domain,
|
||||
match.path,
|
||||
partitionKeyFromCdp(match.partitionKey) ?? null
|
||||
])
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
identities.push(identityFromCdpCookie(item.url, match))
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
export function cdpRestoreParamsFromIdentity(
|
||||
identity: CookieClearIdentity
|
||||
): Record<string, unknown> {
|
||||
const sameSite = cdpSameSite(identity.sameSite)
|
||||
return {
|
||||
url: identity.url,
|
||||
name: identity.name,
|
||||
value: identity.value,
|
||||
...(identity.hostOnly ? {} : { domain: identity.domain }),
|
||||
...(identity.path ? { path: identity.path } : {}),
|
||||
secure: identity.secure,
|
||||
httpOnly: identity.httpOnly,
|
||||
...(sameSite ? { sameSite } : {}),
|
||||
...(identity.expirationDate ? { expires: identity.expirationDate } : {}),
|
||||
...(identity.partitionKey ? { partitionKey: identity.partitionKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function cdpCookiesFromCommand(value: unknown): CdpCookie[] {
|
||||
if (typeof value !== 'object' || value === null || !('cookies' in value)) {
|
||||
return []
|
||||
}
|
||||
const cookies = value.cookies
|
||||
return Array.isArray(cookies) ? cookies : []
|
||||
}
|
||||
|
||||
function cdpSetCookieSucceeded(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null || !('success' in value)) {
|
||||
return true
|
||||
}
|
||||
return value.success !== false
|
||||
}
|
||||
|
||||
async function snapshotClearIdentitiesFromCdp(
|
||||
cookieDebugger: CookieClearDebugger,
|
||||
cookies: readonly { cookie: Cookie; url: string }[]
|
||||
): Promise<CookieClearIdentity[]> {
|
||||
const result = await cookieDebugger.sendCommand('Network.getAllCookies')
|
||||
return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result))
|
||||
}
|
||||
|
||||
async function restoreClearIdentitiesWithCdp(
|
||||
cookieDebugger: CookieClearDebugger,
|
||||
identities: readonly CookieClearIdentity[]
|
||||
): Promise<void> {
|
||||
for (const identity of identities) {
|
||||
const result = await cookieDebugger.sendCommand(
|
||||
'Network.setCookie',
|
||||
cdpRestoreParamsFromIdentity(identity)
|
||||
)
|
||||
if (!cdpSetCookieSucceeded(result)) {
|
||||
throw new Error(`Could not restore cookie ${identity.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openCookieClearStore(
|
||||
targetSession: Session
|
||||
): CookieClearStore & { dispose: () => void } {
|
||||
let attached: CookieClearSession | null = null
|
||||
let pendingAttach: Promise<CookieClearSession> | null = null
|
||||
let disposed = false
|
||||
const attach = async () => {
|
||||
if (disposed) {
|
||||
throw new Error('Cookie clear store was disposed')
|
||||
}
|
||||
if (attached) {
|
||||
return attached
|
||||
}
|
||||
if (pendingAttach) {
|
||||
return pendingAttach
|
||||
}
|
||||
const pending = attachCookieClearSession(targetSession).then((session) => {
|
||||
if (disposed) {
|
||||
session.dispose()
|
||||
throw new Error('Cookie clear store was disposed during debugger attachment')
|
||||
}
|
||||
attached = session
|
||||
return session
|
||||
})
|
||||
pendingAttach = pending
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
if (pendingAttach === pending) {
|
||||
pendingAttach = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
get: (filter) => targetSession.cookies.get(filter),
|
||||
remove: (url, name) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (cookies) =>
|
||||
snapshotClearIdentitiesFromCdp((await attach()).debugger, cookies),
|
||||
restoreClearIdentities: async (identities) =>
|
||||
restoreClearIdentitiesWithCdp((await attach()).debugger, identities),
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
pendingAttach = null
|
||||
attached?.dispose()
|
||||
attached = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
identitiesFromClearCookies,
|
||||
removeTransplantableCookies,
|
||||
type CookieClearIdentity,
|
||||
type CookieClearSession
|
||||
} from './browser-cookie-import-clear'
|
||||
|
||||
function cookie(domain: string, name: string, path = '/', secure = true): Cookie {
|
||||
return {
|
||||
domain,
|
||||
name,
|
||||
path,
|
||||
secure,
|
||||
sameSite: 'unspecified',
|
||||
value: `${name}-value`
|
||||
}
|
||||
}
|
||||
|
||||
function createJarSession(
|
||||
initial: Cookie[],
|
||||
options: {
|
||||
failOn?: string
|
||||
restoreError?: Error
|
||||
snapshot?: CookieClearSession['snapshotClearIdentities']
|
||||
} = {}
|
||||
) {
|
||||
let jar = [...initial]
|
||||
const removedNames: string[] = []
|
||||
const session: CookieClearSession & {
|
||||
names: () => string[]
|
||||
removedNames: () => string[]
|
||||
} = {
|
||||
cookies: {
|
||||
get: async () => [...jar],
|
||||
remove: async (_url, name) => {
|
||||
if (name === (options.failOn ?? 'stale')) {
|
||||
throw new Error('cookie store unavailable')
|
||||
}
|
||||
removedNames.push(name)
|
||||
jar = jar.filter((entry) => entry.name !== name)
|
||||
}
|
||||
},
|
||||
clearData: async () => {
|
||||
throw new Error('storage busy')
|
||||
},
|
||||
snapshotClearIdentities:
|
||||
options.snapshot ?? (async (items) => identitiesFromClearCookies(items)),
|
||||
restoreClearIdentities: async (identities) => {
|
||||
if (options.restoreError) {
|
||||
throw options.restoreError
|
||||
}
|
||||
for (const identity of identities) {
|
||||
if (jar.some((entry) => entry.name === identity.name)) {
|
||||
continue
|
||||
}
|
||||
jar.push(cookie(identity.domain ?? '', identity.name, identity.path, identity.secure))
|
||||
}
|
||||
},
|
||||
names: () => jar.map((entry) => entry.name).sort(),
|
||||
removedNames: () => [...removedNames]
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
describe('STA-4090 failed full cookie clear', () => {
|
||||
it('does not permanently delete a cookie removed before another removal rejects', async () => {
|
||||
const session = createJarSession([
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'removed-first', '/one'),
|
||||
cookie('.other.test', 'stale', '/two')
|
||||
])
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(
|
||||
/existing cookies were restored/
|
||||
)
|
||||
|
||||
expect(session.removedNames()).toEqual(['removed-first'])
|
||||
expect(session.names()).toEqual(['SID', 'removed-first', 'stale'])
|
||||
})
|
||||
|
||||
it('does not start removing when a cookie identity cannot be snapshotted', async () => {
|
||||
const session = createJarSession(
|
||||
[cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
|
||||
{
|
||||
snapshot: async (items) =>
|
||||
identitiesFromClearCookies(items.filter(({ cookie: entry }) => entry.name !== 'stale'))
|
||||
}
|
||||
)
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(
|
||||
/the session was left unchanged/
|
||||
)
|
||||
expect(session.removedNames()).toEqual([])
|
||||
expect(session.names()).toEqual(['removed-first', 'stale'])
|
||||
})
|
||||
|
||||
it('reports a partial clear only when restore also fails', async () => {
|
||||
const session = createJarSession(
|
||||
[cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
|
||||
{ restoreError: new Error('restore rejected') }
|
||||
)
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(
|
||||
/the session was left partially cleared/
|
||||
)
|
||||
expect(session.names()).toEqual(['stale'])
|
||||
})
|
||||
|
||||
it('restores a partitioned identity through the captured restore channel', async () => {
|
||||
const identities: CookieClearIdentity[] = []
|
||||
const session: CookieClearSession = {
|
||||
cookies: {
|
||||
get: async () => [cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
|
||||
remove: async (_url, name) => {
|
||||
if (name === 'stale') {
|
||||
throw new Error('cookie store unavailable')
|
||||
}
|
||||
}
|
||||
},
|
||||
clearData: async () => {
|
||||
throw new Error('storage busy')
|
||||
},
|
||||
snapshotClearIdentities: async (items) =>
|
||||
identitiesFromClearCookies(items).map((identity) =>
|
||||
identity.name === 'removed-first'
|
||||
? {
|
||||
...identity,
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}
|
||||
: identity
|
||||
),
|
||||
restoreClearIdentities: async (restored) => {
|
||||
identities.push(...restored)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(
|
||||
/existing cookies were restored/
|
||||
)
|
||||
expect(identities).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'removed-first',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}),
|
||||
expect.objectContaining({ name: 'stale' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes concurrent clears on the same session', async () => {
|
||||
const activeClears: number[] = []
|
||||
let inClear = 0
|
||||
const session: CookieClearSession = {
|
||||
cookies: {
|
||||
get: async () => [cookie('.example.com', 'session')],
|
||||
remove: async () => {
|
||||
inClear += 1
|
||||
activeClears.push(inClear)
|
||||
await Promise.resolve()
|
||||
inClear -= 1
|
||||
}
|
||||
},
|
||||
clearData: async () => {
|
||||
throw new Error('storage busy')
|
||||
},
|
||||
snapshotClearIdentities: async (items) => identitiesFromClearCookies(items),
|
||||
restoreClearIdentities: async () => undefined
|
||||
}
|
||||
|
||||
await Promise.all([removeTransplantableCookies(session), removeTransplantableCookies(session)])
|
||||
|
||||
expect(activeClears).toEqual([1, 1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Cookie, Cookies, Session } from 'electron'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import {
|
||||
cookieRemovalUrl,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS,
|
||||
normalizeCookieDomain
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
const COOKIE_CLEAR_CONCURRENCY = 8
|
||||
|
||||
export type CookieClearPartitionKey = {
|
||||
topLevelSite: string
|
||||
hasCrossSiteAncestor: boolean
|
||||
}
|
||||
|
||||
export type CookieClearIdentity = {
|
||||
url: string
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
hostOnly?: boolean
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite: Cookie['sameSite']
|
||||
expirationDate?: number
|
||||
partitionKey?: CookieClearPartitionKey
|
||||
}
|
||||
|
||||
export type CookieClearStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
snapshotClearIdentities(
|
||||
cookies: readonly { cookie: Cookie; url: string }[]
|
||||
): Promise<CookieClearIdentity[]>
|
||||
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
|
||||
}
|
||||
|
||||
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
|
||||
export type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'remove'>
|
||||
clearData: Session['clearData']
|
||||
snapshotClearIdentities: CookieClearStore['snapshotClearIdentities']
|
||||
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
|
||||
}
|
||||
|
||||
const clearLocks = new WeakMap<object, Promise<void>>()
|
||||
|
||||
function cookieClearKey(url: string, name: string): string {
|
||||
return JSON.stringify([url, name])
|
||||
}
|
||||
|
||||
export function identitiesFromClearCookies(
|
||||
cookies: readonly { cookie: Cookie; url: string }[]
|
||||
): CookieClearIdentity[] {
|
||||
return cookies.map(({ cookie, url }) => ({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
hostOnly: cookie.hostOnly,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
}))
|
||||
}
|
||||
|
||||
export async function withCookieClearLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
|
||||
const previous = clearLocks.get(owner) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
clearLocks.set(
|
||||
owner,
|
||||
previous.then(() => current)
|
||||
)
|
||||
await previous
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function removableCookieEntries(
|
||||
cookies: readonly Cookie[],
|
||||
requireAddressable = false
|
||||
): { cookie: Cookie; url: string }[] {
|
||||
const removable: { cookie: Cookie; url: string }[] = []
|
||||
for (const cookie of cookies) {
|
||||
if (isNonTransplantableCookieDomain(cookie.domain ?? '')) {
|
||||
continue
|
||||
}
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
if (requireAddressable) {
|
||||
throw new Error('Could not clear existing cookies; the session was left unchanged')
|
||||
}
|
||||
continue
|
||||
}
|
||||
removable.push({ cookie, url })
|
||||
}
|
||||
return removable
|
||||
}
|
||||
|
||||
function assertClearIdentitiesCoverRemovable(
|
||||
removable: readonly { cookie: Cookie; url: string }[],
|
||||
identities: readonly CookieClearIdentity[]
|
||||
): void {
|
||||
const covered = new Set(identities.map((identity) => cookieClearKey(identity.url, identity.name)))
|
||||
for (const item of removable) {
|
||||
if (!covered.has(cookieClearKey(item.url, item.cookie.name))) {
|
||||
throw new Error('Could not clear existing cookies; the session was left unchanged')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function groupRemovableCookies(
|
||||
removable: readonly { cookie: Cookie; url: string }[]
|
||||
): Map<string, { cookie: Cookie; url: string }[]> {
|
||||
const groups = new Map<string, { cookie: Cookie; url: string }[]>()
|
||||
for (const item of removable) {
|
||||
const key = cookieClearKey(item.url, item.cookie.name)
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(item)
|
||||
groups.set(key, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
async function restoreClearedCookies(
|
||||
targetSession: CookieClearSession,
|
||||
identities: readonly CookieClearIdentity[],
|
||||
failures: unknown[]
|
||||
): Promise<never> {
|
||||
try {
|
||||
await targetSession.restoreClearIdentities(identities.toReversed())
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[...failures, restoreError],
|
||||
'Could not clear existing cookies; the session was left partially cleared'
|
||||
)
|
||||
}
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
'Could not clear existing cookies; existing cookies were restored'
|
||||
)
|
||||
}
|
||||
|
||||
export async function removeTransplantableCookies(
|
||||
targetSession: CookieClearSession
|
||||
): Promise<void> {
|
||||
return withCookieClearLock(targetSession, async () => {
|
||||
const store = targetSession.cookies
|
||||
const initialCookies = await store.get({})
|
||||
if (initialCookies.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialRemovable = removableCookieEntries(initialCookies, true)
|
||||
if (initialRemovable.length === 0) {
|
||||
return
|
||||
}
|
||||
const identities = await targetSession.snapshotClearIdentities(initialRemovable)
|
||||
assertClearIdentitiesCoverRemovable(initialRemovable, identities)
|
||||
|
||||
try {
|
||||
// Why (STA-4065): excludeOrigins keeps the google.com family, including partitioned
|
||||
// cookies, so one call replaces a remove() per cookie on the ordinary import path.
|
||||
await targetSession.clearData({
|
||||
dataTypes: ['cookies'],
|
||||
excludeOrigins: NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
// Why: a rejected bulk clear can still have emptied part of the jar.
|
||||
}
|
||||
|
||||
const existingCookies = await store.get({})
|
||||
const removableGroups = groupRemovableCookies(removableCookieEntries(existingCookies))
|
||||
const results = await mapSettledWithConcurrency(
|
||||
[...removableGroups.values()],
|
||||
COOKIE_CLEAR_CONCURRENCY,
|
||||
async (group) => {
|
||||
// Why: identical removal coordinates must stay ordered instead of racing.
|
||||
for (const { cookie, url } of group) {
|
||||
await store.remove(url, cookie.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
await restoreClearedCookies(targetSession, identities, failures)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,6 +30,78 @@ vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: {
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<unknown>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
set?: (details: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (
|
||||
items: {
|
||||
cookie: {
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite: string
|
||||
expirationDate?: number
|
||||
hostOnly?: boolean
|
||||
}
|
||||
url: string
|
||||
}[]
|
||||
) =>
|
||||
items.map(({ cookie, url }) => ({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
hostOnly: cookie.hostOnly,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})),
|
||||
restoreClearIdentities: async (
|
||||
identities: {
|
||||
url: string
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite: string
|
||||
expirationDate?: number
|
||||
hostOnly?: boolean
|
||||
}[]
|
||||
) => {
|
||||
if (!targetSession.cookies.set) {
|
||||
return
|
||||
}
|
||||
for (const identity of identities.toReversed()) {
|
||||
await targetSession.cookies.set({
|
||||
url: identity.url,
|
||||
name: identity.name,
|
||||
value: identity.value,
|
||||
...(identity.hostOnly ? {} : { domain: identity.domain }),
|
||||
...(identity.path ? { path: identity.path } : {}),
|
||||
secure: identity.secure,
|
||||
httpOnly: identity.httpOnly,
|
||||
sameSite: identity.sameSite,
|
||||
...(identity.expirationDate ? { expirationDate: identity.expirationDate } : {})
|
||||
})
|
||||
}
|
||||
},
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
@@ -268,22 +340,29 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why (STA-4061): reconstruction drops partition keys, so a rejected removal must not rebuild
|
||||
// anything — not the excluded Google rows, and not the non-Google rows that were already gone.
|
||||
it('fails the import without reconstructing any cookie when selective removal rejects', async () => {
|
||||
// Why (STA-4090): a rejected removal must restore cookies already deleted in the same clear
|
||||
// without writing imported rows or touching the live Google session.
|
||||
it('fails the import without permanently deleting a cookie removed before a later rejection', async () => {
|
||||
const sourceCookiesPath = seedSource([
|
||||
{ domain: '.example.com', name: 'session', value: 'new' }
|
||||
])
|
||||
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
|
||||
cookiesGetMock.mockResolvedValue([
|
||||
let jar = [
|
||||
existingCookie('.google.com', 'SID'),
|
||||
existingCookie('.example.com', 'removed-first'),
|
||||
existingCookie('.other.test', 'stale')
|
||||
])
|
||||
]
|
||||
cookiesGetMock.mockImplementation(async () => [...jar])
|
||||
cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => {
|
||||
if (name === 'stale') {
|
||||
throw new Error('cookie store unavailable')
|
||||
}
|
||||
jar = jar.filter((entry) => entry.name !== name)
|
||||
})
|
||||
cookiesSetMock.mockImplementation(async (details: { domain?: string; name: string }) => {
|
||||
if (!jar.some((entry) => entry.name === details.name)) {
|
||||
jar.push(existingCookie(details.domain ?? '.example.com', details.name))
|
||||
}
|
||||
})
|
||||
clearDataMock.mockRejectedValue(new Error('storage busy'))
|
||||
|
||||
@@ -293,7 +372,10 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
|
||||
expect(clearDataMock).toHaveBeenCalledOnce()
|
||||
expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale'])
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(
|
||||
expect.arrayContaining(['removed-first'])
|
||||
)
|
||||
expect(jar.map((entry) => entry.name).sort()).toEqual(['SID', 'removed-first', 'stale'])
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,11 +31,11 @@ const EXPECTED_PARTITION_KEY = {
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
function buildFixtureMain(policyPath: string, resultPath: string): string {
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { removeTransplantableCookies } = require(${JSON.stringify(policyPath)})
|
||||
const { openCookieClearStore, removeTransplantableCookies } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
@@ -59,8 +59,6 @@ async function run() {
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
await debug.sendCommand('Network.enable')
|
||||
mark('network enabled')
|
||||
|
||||
// Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey.
|
||||
await debug.sendCommand('Network.setCookie', {
|
||||
@@ -83,25 +81,24 @@ async function run() {
|
||||
await targetSession.cookies.set({ url: 'https://accounts.google.com/', name: 'SID', value: 'live', secure: true })
|
||||
mark('removable cookies set')
|
||||
|
||||
const store = {
|
||||
get: (filter) => targetSession.cookies.get(filter),
|
||||
set: (details) => targetSession.cookies.set(details),
|
||||
// Why: one rejecting removal is what drove the old rollback; the rest still succeed.
|
||||
remove: async (url, name) => {
|
||||
if (name === 'victim') throw new Error('forced victim removal failure')
|
||||
return targetSession.cookies.remove(url, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the bulk clear is the ordinary path now, so rejecting it is what routes this fixture onto
|
||||
// the per-cookie fallback where a partial failure — and the old rollback — could happen at all.
|
||||
// Why: the bulk clear is the ordinary path now, so rejecting it routes this fixture onto
|
||||
// the per-cookie fallback where a later removal can fail after earlier ones succeeded.
|
||||
let bulkClearCalls = 0
|
||||
const cookieClearStore = openCookieClearStore(targetSession)
|
||||
const clearSession = {
|
||||
cookies: store,
|
||||
cookies: {
|
||||
get: (filter) => targetSession.cookies.get(filter),
|
||||
remove: async (url, name) => {
|
||||
if (name === 'victim') throw new Error('forced victim removal failure')
|
||||
return targetSession.cookies.remove(url, name)
|
||||
}
|
||||
},
|
||||
clearData: async () => {
|
||||
bulkClearCalls++
|
||||
throw new Error('forced bulk clear failure')
|
||||
}
|
||||
},
|
||||
snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies),
|
||||
restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities)
|
||||
}
|
||||
|
||||
let clearError = null
|
||||
@@ -109,6 +106,8 @@ async function run() {
|
||||
await removeTransplantableCookies(clearSession)
|
||||
} catch (error) {
|
||||
clearError = String(error?.message || error)
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
mark('clear finished')
|
||||
|
||||
@@ -140,25 +139,33 @@ run().catch((error) => {
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-partition-rollback-'))
|
||||
fixtureRoots.push(root)
|
||||
const policyPath = join(root, 'browser-cookie-import-policy.cjs')
|
||||
const bundlePath = join(root, 'cookie-clear-rollback.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-clear-rollback.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
[
|
||||
`export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`,
|
||||
`export { removeTransplantableCookies } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-clear.ts'))}`
|
||||
].join('\n')
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'),
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'browser-cookie-import-policy.cjs'
|
||||
fileName: () => 'cookie-clear-rollback.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(policyPath, resultPath))
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath))
|
||||
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
|
||||
@@ -178,20 +185,18 @@ async function runFixture(): Promise<FixtureResult> {
|
||||
}
|
||||
|
||||
describe('non-Google partitioned cookie under a failed Electron cookie clear', () => {
|
||||
it('never resurrects a removed partitioned cookie', async () => {
|
||||
// Why (STA-4090): a later fallback rejection must not permanently drop cookies already
|
||||
// removed in the same clear — including CHIPS cookies cookies.set() cannot round-trip.
|
||||
it('keeps already-removed CHIPS and ordinary cookies after a later removal rejects', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
// Why (STA-4065): the fallback is only reachable once the bulk clear has been tried and failed;
|
||||
// if it ever succeeded here the partial-failure assertions below would be vacuous.
|
||||
expect(result.bulkClearCalls).toBe(1)
|
||||
expect(result.remainingExcluded.map(({ name }) => name)).toEqual(['SID'])
|
||||
expect(result.clearError).toContain('Could not clear existing cookies')
|
||||
// STA-4061: rollback rebuilt this cookie through cookies.set, which drops partitionKey.
|
||||
expect(
|
||||
result.remainingChips,
|
||||
`chips-auth survived: ${JSON.stringify(result.remainingChips)}`
|
||||
).toEqual([])
|
||||
expect(result.remainingPlain).toEqual([])
|
||||
expect(result.remainingChips).toEqual([
|
||||
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
|
||||
])
|
||||
expect(result.remainingPlain).toEqual([{ name: 'plain', value: 'stale' }])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
identitiesFromClearCookies,
|
||||
removeTransplantableCookies
|
||||
} from './browser-cookie-import-clear'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
removeTransplantableCookies,
|
||||
replaceCookiesForImportedDomains
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
@@ -225,12 +228,24 @@ describe('removeTransplantableCookies', () => {
|
||||
...overrides
|
||||
}
|
||||
const clearData = overrides.clearData ?? vi.fn().mockResolvedValue(undefined)
|
||||
const restoreClearIdentities = vi.fn().mockResolvedValue(undefined)
|
||||
const snapshotClearIdentities = vi.fn(
|
||||
async (items: Parameters<typeof identitiesFromClearCookies>[0]) =>
|
||||
identitiesFromClearCookies(items)
|
||||
)
|
||||
return {
|
||||
session: { cookies: store, clearData },
|
||||
session: {
|
||||
cookies: store,
|
||||
clearData,
|
||||
snapshotClearIdentities,
|
||||
restoreClearIdentities
|
||||
},
|
||||
get: store.get,
|
||||
remove: store.remove,
|
||||
set: store.set,
|
||||
clearData
|
||||
clearData,
|
||||
snapshotClearIdentities,
|
||||
restoreClearIdentities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,14 +293,29 @@ describe('removeTransplantableCookies', () => {
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bulk clears cookies the per-cookie path could never address', async () => {
|
||||
const { session, clearData } = clearSession([
|
||||
{ ...cookie('.example.com', 'session'), domain: '' }
|
||||
it('does not attach or clear when the jar contains only excluded cookies', async () => {
|
||||
const { session, snapshotClearIdentities, clearData, remove } = clearSession([
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('accounts.google.com', 'ACCOUNT')
|
||||
])
|
||||
|
||||
await removeTransplantableCookies(session)
|
||||
|
||||
expect(clearData).toHaveBeenCalledOnce()
|
||||
expect(snapshotClearIdentities).not.toHaveBeenCalled()
|
||||
expect(clearData).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mutate when a transplantable cookie cannot be represented for rollback', async () => {
|
||||
const { session, clearData, remove, restoreClearIdentities } = clearSession([
|
||||
{ ...cookie('.example.com', 'session'), domain: '' }
|
||||
])
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(/session was left unchanged/)
|
||||
|
||||
expect(clearData).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(restoreClearIdentities).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to per-cookie removal when the bulk clear rejects', async () => {
|
||||
@@ -343,10 +373,9 @@ describe('removeTransplantableCookies', () => {
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why (STA-4061): reconstructing a removed cookie loses its partition key, and the snapshot
|
||||
// cannot say which cookies had one, so a failed clear must stay failed.
|
||||
it('never reconstructs removed cookies when another removal fails', async () => {
|
||||
const { session, remove, set } = clearSession(
|
||||
// Why (STA-4090): a failed fallback must restore through captured identities, never cookies.set.
|
||||
it('restores removed cookies through captured identities when another removal fails', async () => {
|
||||
const { session, remove, set, restoreClearIdentities } = clearSession(
|
||||
[
|
||||
cookie('.google.com', 'SID'),
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
@@ -364,10 +393,14 @@ describe('removeTransplantableCookies', () => {
|
||||
)
|
||||
|
||||
await expect(removeTransplantableCookies(session)).rejects.toThrow(
|
||||
'the session was left partially cleared'
|
||||
'existing cookies were restored'
|
||||
)
|
||||
expect(remove).toHaveBeenCalledTimes(3)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
expect(restoreClearIdentities).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
restoreClearIdentities.mock.calls[0][0].map((identity: { name: string }) => identity.name)
|
||||
).toEqual(expect.arrayContaining(['first', 'second', 'third']))
|
||||
})
|
||||
|
||||
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Cookie, Cookies, Session } from 'electron'
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
|
||||
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
@@ -66,10 +65,9 @@ export function normalizeCookieImportDomain(domain: string): string | null {
|
||||
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
|
||||
// users actually asked for.
|
||||
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
|
||||
const NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS = NON_TRANSPLANTABLE_DOMAINS.map(
|
||||
export const NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS = NON_TRANSPLANTABLE_DOMAINS.map(
|
||||
(root) => `https://${root}`
|
||||
)
|
||||
const COOKIE_CLEAR_CONCURRENCY = 8
|
||||
|
||||
export function isNonTransplantableCookieDomain(domain: string): boolean {
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
@@ -159,7 +157,7 @@ function overlapsImportedDomain(
|
||||
return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix))
|
||||
}
|
||||
|
||||
function cookieRemovalUrl(cookie: Cookie, domain: string): string | null {
|
||||
export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null {
|
||||
try {
|
||||
const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`)
|
||||
url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/'
|
||||
@@ -201,83 +199,6 @@ export async function restoreImportedDomainCookies(
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
|
||||
export type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'remove'>
|
||||
clearData: Session['clearData']
|
||||
}
|
||||
|
||||
// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed.
|
||||
// Why (STA-4061): the same gap forbids rolling a partial clear back. cookies.get() omits
|
||||
// partitionKey and cookies.set() silently drops it, so every reconstruction is a coin flip that
|
||||
// can downgrade a partitioned (CHIPS) cookie into an unpartitioned one — and nothing in the
|
||||
// snapshot says which cookies are at risk. A partially cleared jar is a retryable import failure;
|
||||
// a downgraded cookie is unrecoverable auth-state corruption that survives restart.
|
||||
// Why (STA-4065): the exclusion is module state rather than a parameter so the predicate and the
|
||||
// origins the bulk clear preserves cannot drift apart — a caller-supplied predicate that disagreed
|
||||
// with NON_TRANSPLANTABLE_DOMAINS would silently delete a cookie the bulk call is meant to keep.
|
||||
export async function removeTransplantableCookies(
|
||||
targetSession: CookieClearSession
|
||||
): Promise<void> {
|
||||
const store = targetSession.cookies
|
||||
const initialCookies = await store.get({})
|
||||
if (initialCookies.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why (STA-4065): measured on Electron 43, excludeOrigins preserves the whole registrable
|
||||
// family — host, leading-dot, subdomain, and partitioned Google cookies — so one call replaces a
|
||||
// remove() per cookie even when the jar holds cookies to keep. That is the ordinary case here:
|
||||
// this import exists for Google, so a Google cookie is usually present.
|
||||
try {
|
||||
await targetSession.clearData({
|
||||
dataTypes: ['cookies'],
|
||||
excludeOrigins: NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
// Why: a rejected bulk clear can still have changed the jar, so the fallback must act on the
|
||||
// survivors rather than stale removal coordinates from before the attempt.
|
||||
}
|
||||
|
||||
const existingCookies = await store.get({})
|
||||
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
|
||||
for (const cookie of existingCookies) {
|
||||
if (isNonTransplantableCookieDomain(cookie.domain ?? '')) {
|
||||
continue
|
||||
}
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
const key = JSON.stringify([url, cookie.name])
|
||||
const group = removableGroups.get(key) ?? []
|
||||
group.push({ cookie, url })
|
||||
removableGroups.set(key, group)
|
||||
}
|
||||
|
||||
const results = await mapSettledWithConcurrency(
|
||||
[...removableGroups.values()],
|
||||
COOKIE_CLEAR_CONCURRENCY,
|
||||
async (group) => {
|
||||
// Why: identical removal coordinates must stay ordered instead of racing.
|
||||
for (const { cookie, url } of group) {
|
||||
await store.remove(url, cookie.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : []
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
'Could not clear existing cookies; the session was left partially cleared'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceCookiesForImportedDomains(
|
||||
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
|
||||
importedDomains: readonly string[]
|
||||
|
||||
@@ -26,6 +26,21 @@ vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: {
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<unknown>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
}
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
|
||||
@@ -44,6 +44,21 @@ vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: dialogShowOpenDialogMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: {
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<unknown>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
}
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
buildChromiumCookieInsertParams,
|
||||
|
||||
@@ -80,11 +80,12 @@ import {
|
||||
NON_TRANSPLANTABLE_HOST_KEY_SQL,
|
||||
normalizeCookieDomain,
|
||||
normalizeCookieImportDomain,
|
||||
removeTransplantableCookies,
|
||||
replaceCookiesForImportedDomains,
|
||||
restoreImportedDomainCookies,
|
||||
type CookieImportMode
|
||||
} from './browser-cookie-import-policy'
|
||||
import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear'
|
||||
import { openCookieClearStore } from './browser-cookie-clear-store'
|
||||
import {
|
||||
createChromiumCookieSnapshot,
|
||||
type ChromiumCookieSnapshot
|
||||
@@ -1780,7 +1781,20 @@ export async function importCookiesFromBrowser(
|
||||
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
|
||||
// session. Non-transplantable families are exempt — nothing was imported for them, and their
|
||||
// live session is the only one that works.
|
||||
await removeTransplantableCookies(targetSession)
|
||||
const cookieClearStore = openCookieClearStore(targetSession)
|
||||
try {
|
||||
await withCookieClearLock(targetSession, () =>
|
||||
removeTransplantableCookies({
|
||||
cookies: cookieClearStore,
|
||||
clearData: (options) => targetSession.clearData(options),
|
||||
snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies),
|
||||
restoreClearIdentities: (identities) =>
|
||||
cookieClearStore.restoreClearIdentities(identities)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user