mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(browser): align cookie import safeguards (#12607)
* fix(browser): align cookie import safeguards * fix(browser): preserve sessions on failed cookie imports * fix(browser): bound single-label cookie replacement * fix(browser): preserve host-only parent cookies * fix(build): bundle cookie scope parser
This commit is contained in:
@@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => {
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
@@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => {
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
expect(external('psl', undefined, false)).toBe(false)
|
||||
expect(external('zod', undefined, false)).toBe(false)
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import packageJson from './package.json' with { type: 'json' }
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
'psl',
|
||||
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
|
||||
// not race the later resources/node_modules copy.
|
||||
'zod'
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"posthog-node": "^5.33.3",
|
||||
"psl": "1.15.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"serve-sim": "^0.1.40",
|
||||
|
||||
Generated
+16
@@ -68,6 +68,9 @@ importers:
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
psl:
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -5793,9 +5796,16 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
psl@1.15.0:
|
||||
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
@@ -12448,11 +12458,17 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
psl@1.15.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pvtsutils@1.3.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
normalizeCookieDomain,
|
||||
replaceCookiesForImportedDomains
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
function cookie(domain: string, name: string, path = '/', secure = true): Cookie {
|
||||
return {
|
||||
domain,
|
||||
name,
|
||||
path,
|
||||
secure,
|
||||
sameSite: 'unspecified',
|
||||
value: 'secret'
|
||||
}
|
||||
}
|
||||
|
||||
describe('isGoogleSourceBoundCookie', () => {
|
||||
it('matches the allowlisted names only on google.com and its subdomains', () => {
|
||||
expect(isGoogleSourceBoundCookie('SIDCC', '.google.com')).toBe(true)
|
||||
expect(isGoogleSourceBoundCookie('AEC', 'accounts.google.com')).toBe(true)
|
||||
expect(isGoogleSourceBoundCookie('__Secure-STRP', '.accounts.google.com')).toBe(true)
|
||||
expect(isGoogleSourceBoundCookie('SIDCC', '.notgoogle.com')).toBe(false)
|
||||
expect(isGoogleSourceBoundCookie('SIDCC', '.google.com.evil.example')).toBe(false)
|
||||
expect(isGoogleSourceBoundCookie('SID', '.google.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes leading dots, case, and international domains consistently', () => {
|
||||
expect(normalizeCookieDomain('..Accounts.Google.Com')).toBe('accounts.google.com')
|
||||
expect(normalizeCookieDomain('münich.example')).toBe('xn--mnich-kva.example')
|
||||
expect(normalizeCookieDomain('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects URL syntax that could normalize an invalid cookie scope to another domain', () => {
|
||||
expect(normalizeCookieDomain('example.com/path')).toBeNull()
|
||||
expect(normalizeCookieDomain('user@example.com')).toBeNull()
|
||||
expect(normalizeCookieDomain('example.com:443')).toBeNull()
|
||||
expect(normalizeCookieDomain('%65xample.com')).toBeNull()
|
||||
expect(isGoogleSourceBoundCookie('SIDCC', 'user@google.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceCookiesForImportedDomains', () => {
|
||||
it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => {
|
||||
const existing = [
|
||||
cookie('.google.com', 'parent'),
|
||||
{ ...cookie('google.com', 'host-only-parent'), hostOnly: true },
|
||||
cookie('.accounts.google.com', 'exact', '/signin'),
|
||||
cookie('.child.accounts.google.com', 'child', '/nested', false),
|
||||
cookie('.google.com.evil.example', 'suffix-confusion'),
|
||||
cookie('.example.com', 'unrelated')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [
|
||||
'accounts.google.com'
|
||||
])
|
||||
|
||||
expect(removed).toHaveLength(3)
|
||||
expect(get).toHaveBeenCalledWith({})
|
||||
expect(remove.mock.calls).toEqual([
|
||||
['https://google.com/', 'parent'],
|
||||
['https://accounts.google.com/signin', 'exact'],
|
||||
['http://child.accounts.google.com/nested', 'child']
|
||||
])
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replace a private-suffix host cookie for a tenant import', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ ...cookie('github.io', 'host-only-suffix'), hostOnly: true },
|
||||
cookie('.user.github.io', 'tenant')
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io'])
|
||||
|
||||
expect(removed.map(({ name }) => name)).toEqual(['tenant'])
|
||||
expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant')
|
||||
})
|
||||
|
||||
it('does not read or mutate the store when no valid domain scope exists', async () => {
|
||||
const get = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const set = vi.fn()
|
||||
|
||||
await expect(
|
||||
replaceCookiesForImportedDomains({ get, remove, set }, [
|
||||
'',
|
||||
'...',
|
||||
'com',
|
||||
'co.uk',
|
||||
'github.io'
|
||||
])
|
||||
).resolves.toEqual([])
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps single-label intranet scopes from selecting descendant hosts', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local'])
|
||||
|
||||
expect(removed.map(({ name }) => name)).toEqual(['exact'])
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(remove).toHaveBeenCalledWith('https://local/', 'exact')
|
||||
})
|
||||
|
||||
it('restores cookies removed before a later removal fails', async () => {
|
||||
const existing = [
|
||||
cookie('.example.com', 'first', '/one'),
|
||||
cookie('.example.com', 'second', '/two')
|
||||
]
|
||||
const get = vi.fn().mockResolvedValue(existing)
|
||||
const remove = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('cookie store unavailable'))
|
||||
const set = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
replaceCookiesForImportedDomains({ get, remove, set }, ['example.com'])
|
||||
).rejects.toThrow('cookie store unavailable')
|
||||
expect(set).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/one',
|
||||
name: 'first',
|
||||
value: 'secret',
|
||||
domain: '.example.com',
|
||||
path: '/one',
|
||||
secure: true,
|
||||
httpOnly: undefined,
|
||||
sameSite: 'unspecified'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
|
||||
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
'__Secure-1PSIDCC',
|
||||
'__Secure-3PSIDCC',
|
||||
'__Secure-STRP',
|
||||
'AEC'
|
||||
])
|
||||
|
||||
export type CookieImportMode = 'merge' | 'replace-imported-domains'
|
||||
|
||||
export function normalizeCookieDomain(domain: string): string | null {
|
||||
const candidate = domain.trim().replace(/^\.+/, '')
|
||||
const isBracketedIpv6 = candidate.startsWith('[') && candidate.endsWith(']')
|
||||
if (!candidate || /[/\\@?#%]/.test(candidate) || (!isBracketedIpv6 && candidate.includes(':'))) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(`https://${candidate}/`)
|
||||
const normalized = parsed.hostname.toLowerCase()
|
||||
if (
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.port ||
|
||||
parsed.pathname !== '/' ||
|
||||
parsed.search ||
|
||||
parsed.hash ||
|
||||
normalized.endsWith('.') ||
|
||||
normalized.includes('..')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCookieImportDomain(domain: string): string | null {
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseDomain(normalized)
|
||||
if ('error' in parsed) {
|
||||
return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null
|
||||
}
|
||||
if (parsed.domain === null && parsed.listed) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function isGoogleSourceBoundCookie(name: string, domain: string): boolean {
|
||||
if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) {
|
||||
return false
|
||||
}
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
return normalized === 'google.com' || normalized?.endsWith('.google.com') === true
|
||||
}
|
||||
|
||||
function domainSuffixes(domain: string): string[] {
|
||||
const labels = domain.split('.')
|
||||
return labels.map((_, index) => labels.slice(index).join('.'))
|
||||
}
|
||||
|
||||
function importDomainAncestors(domain: string): string[] {
|
||||
const parsed = parseDomain(domain)
|
||||
const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain)
|
||||
const ancestors: string[] = []
|
||||
for (const suffix of domainSuffixes(domain)) {
|
||||
ancestors.push(suffix)
|
||||
if (suffix === boundary) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
function importedDomainScopes(domains: readonly string[]): {
|
||||
exact: Set<string>
|
||||
ancestors: Set<string>
|
||||
descendantRoots: Set<string>
|
||||
} {
|
||||
const exact = new Set<string>()
|
||||
const ancestors = new Set<string>()
|
||||
const descendantRoots = new Set<string>()
|
||||
const seen = new Set<string>()
|
||||
for (const domain of domains) {
|
||||
const candidate = normalizeCookieDomain(domain)
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
continue
|
||||
}
|
||||
seen.add(candidate)
|
||||
const normalized = normalizeCookieImportDomain(candidate)
|
||||
if (!normalized || exact.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
exact.add(normalized)
|
||||
if (normalized.includes('.')) {
|
||||
descendantRoots.add(normalized)
|
||||
}
|
||||
for (const suffix of importDomainAncestors(normalized)) {
|
||||
ancestors.add(suffix)
|
||||
}
|
||||
}
|
||||
return { exact, ancestors, descendantRoots }
|
||||
}
|
||||
|
||||
function overlapsImportedDomain(
|
||||
cookie: Cookie,
|
||||
domain: string,
|
||||
scopes: ReturnType<typeof importedDomainScopes>
|
||||
): boolean {
|
||||
if (scopes.exact.has(domain)) {
|
||||
return true
|
||||
}
|
||||
if (cookie.hostOnly !== true && scopes.ancestors.has(domain)) {
|
||||
return true
|
||||
}
|
||||
return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix))
|
||||
}
|
||||
|
||||
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 : '/'
|
||||
return url.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreImportedDomainCookies(
|
||||
store: Pick<Cookies, 'set'>,
|
||||
cookies: readonly Cookie[]
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
await store.set({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
...(cookie.hostOnly ? {} : { domain: cookie.domain }),
|
||||
...(cookie.path ? { path: cookie.path } : {}),
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
failures.push(err)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Could not restore replaced cookies')
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceCookiesForImportedDomains(
|
||||
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
|
||||
importedDomains: readonly string[]
|
||||
): Promise<Cookie[]> {
|
||||
const scopes = importedDomainScopes(importedDomains)
|
||||
if (scopes.exact.size === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const existingCookies = await store.get({})
|
||||
const removedCookies: Cookie[] = []
|
||||
for (const cookie of existingCookies) {
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) {
|
||||
continue
|
||||
}
|
||||
const url = cookieRemovalUrl(cookie, domain)
|
||||
if (!url) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await store.remove(url, cookie.name)
|
||||
removedCookies.push(cookie)
|
||||
} catch (err) {
|
||||
try {
|
||||
await restoreImportedDomainCookies(store, removedCookies)
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return removedCookies
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
clearPendingCookieImportMock,
|
||||
execFileSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
setPendingCookieImportMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
setPendingCookieImport: setPendingCookieImportMock,
|
||||
clearPendingCookieImport: clearPendingCookieImportMock,
|
||||
persistUserAgent: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('validated cookie replacement', () => {
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-replacement-test-'))
|
||||
cookiesGetMock = vi.fn().mockResolvedValue([])
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue({
|
||||
cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeCookies(cookies: unknown[]): string {
|
||||
const filePath = join(tmpDir, 'cookies.json')
|
||||
writeFileSync(filePath, JSON.stringify(cookies))
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('filters Google source-bound cookies before replacing imported domain scopes', async () => {
|
||||
cookiesGetMock.mockResolvedValue([
|
||||
cookie('.google.com', 'old-google'),
|
||||
cookie('.accounts.google.com', 'old-accounts', '/signin'),
|
||||
cookie('.unrelated.com', 'keep'),
|
||||
cookie('.google.com.evil.example', 'keep-suffix-confusion')
|
||||
])
|
||||
const filePath = writeCookies([
|
||||
{ domain: '.google.com', name: 'SIDCC', value: 'source-bound', secure: true },
|
||||
{ domain: '.google.com', name: 'SAPISID', value: 'google-session', secure: true },
|
||||
{ domain: '.example.com', name: 'SIDCC', value: 'not-google', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
totalCookies: 3,
|
||||
importedCookies: 2,
|
||||
skippedCookies: 1,
|
||||
domains: ['example.com', 'google.com']
|
||||
})
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([
|
||||
['https://google.com/', 'old-google'],
|
||||
['https://accounts.google.com/signin', 'old-accounts']
|
||||
])
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC'])
|
||||
expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan(
|
||||
Math.min(...cookiesSetMock.mock.invocationCallOrder)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not touch the store when every valid entry is source-bound', async () => {
|
||||
const filePath = writeCookies([
|
||||
{ domain: '.google.com', name: 'AEC', value: 'source-bound', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toEqual({
|
||||
totalCookies: 1,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 1,
|
||||
domains: []
|
||||
})
|
||||
expect(cookiesGetMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects URL-shaped domains before replacement can clear their normalized scope', async () => {
|
||||
const filePath = writeCookies([
|
||||
{ domain: 'example.com/path', name: 'session', value: 'new', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'No valid cookies found. 1 entries were skipped due to missing or invalid fields.'
|
||||
})
|
||||
expect(cookiesGetMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the previous snapshot when an incoming cookie is rejected', async () => {
|
||||
cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')])
|
||||
cookiesSetMock
|
||||
.mockResolvedValue(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('cookie rejected'))
|
||||
const filePath = writeCookies([
|
||||
{ domain: '.example.com', name: 'first', value: 'new', secure: true },
|
||||
{ domain: '.example.com', name: 'second', value: 'new', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([
|
||||
['https://example.com/', 'existing'],
|
||||
['https://example.com/', 'first']
|
||||
])
|
||||
expect(cookiesSetMock).toHaveBeenLastCalledWith({
|
||||
url: 'https://example.com/',
|
||||
name: 'existing',
|
||||
value: 'old',
|
||||
domain: '.example.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: undefined,
|
||||
sameSite: 'unspecified'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when existing cookies cannot be replaced', async () => {
|
||||
cookiesGetMock.mockRejectedValue(new Error('cookie store unavailable'))
|
||||
const filePath = writeCookies([
|
||||
{ domain: '.example.com', name: 'session', value: 'new', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native Chromium integrity-cookie accounting', () => {
|
||||
let clearStorageDataMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-accounting-test-'))
|
||||
appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData'))
|
||||
execFileSyncMock.mockReset().mockImplementation(() => {
|
||||
throw new Error('OS browser version lookup unavailable')
|
||||
})
|
||||
clearPendingCookieImportMock.mockClear()
|
||||
setPendingCookieImportMock.mockClear()
|
||||
clearStorageDataMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue({
|
||||
cookies: {
|
||||
flushStore: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: cookiesSetMock
|
||||
},
|
||||
clearStorageData: clearStorageDataMock
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('includes domain-scoped integrity cookies in skippedCookies', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{ domain: '.google.com', name: 'AEC', value: 'source-bound' },
|
||||
{ domain: '.google.com', name: 'SAPISID', value: 'google-session' },
|
||||
{ domain: '.example.com', name: 'AEC', value: 'not-google' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
try {
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
totalCookies: 3,
|
||||
importedCookies: 2,
|
||||
skippedCookies: 1,
|
||||
domains: ['example.com', 'google.com']
|
||||
})
|
||||
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC'])
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the destination when every source cookie is filtered or invalid', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{ domain: '.google.com', name: 'AEC', value: 'source-bound' },
|
||||
{ domain: '.accounts.google.com', name: 'SIDCC', value: 'source-bound' },
|
||||
{ domain: 'example.com/path', name: 'invalid-domain', value: 'invalid' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, [
|
||||
{ domain: '.example.com', name: 'existing', value: 'keep' }
|
||||
]).close()
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
try {
|
||||
const result = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(result.ok && result.summary).toEqual({
|
||||
totalCookies: 3,
|
||||
importedCookies: 0,
|
||||
skippedCookies: 3,
|
||||
domains: []
|
||||
})
|
||||
expect(clearStorageDataMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
expect(clearPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([])
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function cookie(domain: string, name: string, path = '/') {
|
||||
return { domain, name, path, secure: true, sameSite: 'unspecified', value: 'old' }
|
||||
}
|
||||
|
||||
function chromeBrowser(cookiesPath: string) {
|
||||
return {
|
||||
family: 'chrome' as const,
|
||||
label: 'Google Chrome',
|
||||
cookiesPath,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ChromiumCookieTestRow = {
|
||||
domain?: string
|
||||
name: string
|
||||
value: string
|
||||
encryptedValue?: Buffer
|
||||
@@ -61,7 +62,7 @@ export function createChromiumCookieTestDatabase(
|
||||
rows.forEach((row, index) => {
|
||||
insert.run(
|
||||
133_000_000_000_000 + index,
|
||||
'.example.com',
|
||||
row.domain ?? '.example.com',
|
||||
row.name,
|
||||
row.value,
|
||||
row.encryptedValue ?? Buffer.alloc(0),
|
||||
|
||||
@@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer {
|
||||
|
||||
describe('importCookiesFromFile', () => {
|
||||
let tmpDir: string
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-'))
|
||||
cookiesGetMock = vi.fn().mockResolvedValue([])
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: { set: cookiesSetMock }
|
||||
cookies: {
|
||||
get: cookiesGetMock,
|
||||
remove: cookiesRemoveMock,
|
||||
set: cookiesSetMock
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,7 +370,7 @@ describe('importCookiesFromFile', () => {
|
||||
expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
||||
})
|
||||
|
||||
it('counts cookies that fail to set', async () => {
|
||||
it('rolls back replacement when a cookie fails to set', async () => {
|
||||
cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
||||
|
||||
const filePath = writeCookieFile([
|
||||
@@ -371,12 +379,8 @@ describe('importCookiesFromFile', () => {
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.summary.importedCookies).toBe(1)
|
||||
expect(result.summary.skippedCookies).toBe(1)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(cookiesRemoveMock).toHaveBeenCalledWith('http://a.com/', 'ok')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */
|
||||
import { app, type BrowserWindow, dialog, session } from 'electron'
|
||||
import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
@@ -75,6 +75,14 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
normalizeCookieDomain,
|
||||
normalizeCookieImportDomain,
|
||||
replaceCookiesForImportedDomains,
|
||||
restoreImportedDomainCookies,
|
||||
type CookieImportMode
|
||||
} from './browser-cookie-import-policy'
|
||||
import {
|
||||
createChromiumCookieSnapshot,
|
||||
type ChromiumCookieSnapshot
|
||||
@@ -500,13 +508,13 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la
|
||||
|
||||
// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag.
|
||||
function deriveUrl(domain: string, secure: boolean): string | null {
|
||||
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
if (!cleanDomain || cleanDomain.includes(' ')) {
|
||||
const normalizedDomain = normalizeCookieDomain(domain)
|
||||
if (!normalizedDomain) {
|
||||
return null
|
||||
}
|
||||
const protocol = secure ? 'https' : 'http'
|
||||
try {
|
||||
const url = new URL(`${protocol}://${cleanDomain}/`)
|
||||
const url = new URL(`${protocol}://${normalizedDomain}/`)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return null
|
||||
@@ -552,40 +560,79 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null {
|
||||
async function importValidatedCookies(
|
||||
cookies: ValidatedCookie[],
|
||||
totalInput: number,
|
||||
targetPartition: string
|
||||
targetPartition: string,
|
||||
mode: CookieImportMode
|
||||
): Promise<BrowserCookieImportResult> {
|
||||
const importDomainCache = new Map<string, boolean>()
|
||||
const validDomainCookies = cookies.filter((cookie) => {
|
||||
let valid = importDomainCache.get(cookie.domain)
|
||||
if (valid === undefined) {
|
||||
valid = normalizeCookieImportDomain(cookie.domain) !== null
|
||||
importDomainCache.set(cookie.domain, valid)
|
||||
}
|
||||
return valid
|
||||
})
|
||||
const importableCookies = validDomainCookies.filter(
|
||||
(cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain)
|
||||
)
|
||||
const integritySkipped = validDomainCookies.length - importableCookies.length
|
||||
const invalidDomainSkipped = cookies.length - validDomainCookies.length
|
||||
diag(
|
||||
`importValidatedCookies: ${cookies.length} validated of ${totalInput} total, partition="${targetPartition}"`
|
||||
`importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"`
|
||||
)
|
||||
const targetSession = session.fromPartition(targetPartition)
|
||||
let importedCount = 0
|
||||
let skipped = totalInput - cookies.length
|
||||
let skipped = totalInput - importableCookies.length
|
||||
const domainSet = new Set<string>()
|
||||
let replacedCookies: Cookie[] | null = null
|
||||
|
||||
if (mode === 'replace-imported-domains' && importableCookies.length > 0) {
|
||||
try {
|
||||
replacedCookies = await replaceCookiesForImportedDomains(
|
||||
targetSession.cookies,
|
||||
importableCookies.map((cookie) => cookie.domain)
|
||||
)
|
||||
diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`)
|
||||
} catch (err) {
|
||||
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
|
||||
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
|
||||
const importedCookieKeys: { url: string; name: string }[] = []
|
||||
let setFailure: unknown = null
|
||||
|
||||
for (const cookie of cookies) {
|
||||
for (const cookie of importableCookies) {
|
||||
try {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
const path = isHostPrefixed ? '/' : cookie.path
|
||||
await targetSession.cookies.set({
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: stripNonPrintable(cookie.value),
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path: isHostPrefixed ? '/' : cookie.path,
|
||||
path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
const removalUrl = new URL(cookie.url)
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name })
|
||||
importedCount++
|
||||
// Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
|
||||
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
|
||||
domainSet.add(cleanDomain)
|
||||
} catch (err) {
|
||||
skipped++
|
||||
setFailure = err
|
||||
if (skipped <= 5) {
|
||||
// Find the exact offending character position and code
|
||||
const val = cookie.value
|
||||
@@ -601,6 +648,32 @@ async function importValidatedCookies(
|
||||
` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (replacedCookies) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (setFailure && replacedCookies) {
|
||||
const rollbackFailures: unknown[] = []
|
||||
for (const cookie of importedCookieKeys.toReversed()) {
|
||||
try {
|
||||
await targetSession.cookies.remove(cookie.url, cookie.name)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await restoreImportedDomainCookies(targetSession.cookies, replacedCookies)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,7 +763,12 @@ export async function importCookiesFromFile(
|
||||
}
|
||||
}
|
||||
|
||||
return importValidatedCookies(validated, parsed.length, targetPartition)
|
||||
return importValidatedCookies(
|
||||
validated,
|
||||
parsed.length,
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1338,7 +1416,12 @@ async function importCookiesFromFirefox(
|
||||
return { ok: false, reason: 'No valid cookies found in Firefox.' }
|
||||
}
|
||||
|
||||
return importValidatedCookies(validated, rows.length, targetPartition)
|
||||
return importValidatedCookies(
|
||||
validated,
|
||||
rows.length,
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
} catch (err) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
diag(` Firefox import failed: ${String(err)}`)
|
||||
@@ -1392,7 +1475,12 @@ async function importCookiesFromSafari(
|
||||
return { ok: false, reason: 'All Safari cookies are expired.' }
|
||||
}
|
||||
|
||||
return importValidatedCookies(valid, cookies.length, targetPartition)
|
||||
return importValidatedCookies(
|
||||
valid,
|
||||
cookies.length,
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
} catch (err) {
|
||||
diag(` Safari import failed: ${String(err)}`)
|
||||
return { ok: false, reason: 'Could not import cookies from Safari.' }
|
||||
@@ -1572,22 +1660,6 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue.
|
||||
const INTEGRITY_COOKIE_NAMES = new Set([
|
||||
'SIDCC',
|
||||
'__Secure-1PSIDCC',
|
||||
'__Secure-3PSIDCC',
|
||||
'__Secure-STRP',
|
||||
'AEC'
|
||||
])
|
||||
function isIntegrityCookie(name: string, domain: string): boolean {
|
||||
if (!INTEGRITY_COOKIE_NAMES.has(name)) {
|
||||
return false
|
||||
}
|
||||
const d = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
return d === 'google.com' || d.endsWith('.google.com')
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let integritySkipped = 0
|
||||
@@ -1608,6 +1680,7 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
|
||||
const decryptedCookies: DecryptedCookie[] = []
|
||||
const sourceDomainValidity = new Map<string, boolean>()
|
||||
|
||||
// Why: staging only backs the cold-restart replay, so any failure writing it disables that
|
||||
// fallback instead of aborting an import whose in-memory half still works.
|
||||
@@ -1658,11 +1731,21 @@ export async function importCookiesFromBrowser(
|
||||
const domain = sourceRow.host_key as string
|
||||
const name = sourceRow.name as string
|
||||
|
||||
if (isIntegrityCookie(name, domain)) {
|
||||
if (isGoogleSourceBoundCookie(name, domain)) {
|
||||
integritySkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
let validDomain = sourceDomainValidity.get(domain)
|
||||
if (validDomain === undefined) {
|
||||
validDomain = normalizeCookieImportDomain(domain) !== null
|
||||
sourceDomainValidity.set(domain, validDomain)
|
||||
}
|
||||
if (!validDomain) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
domainSet.add(cleanDomain)
|
||||
|
||||
@@ -1704,6 +1787,21 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`)
|
||||
|
||||
if (decryptedCookies.length === 0) {
|
||||
closeStagingDb()
|
||||
discardStagingFile()
|
||||
return {
|
||||
ok: true,
|
||||
profileId: '',
|
||||
summary: {
|
||||
totalCookies: sourceRows.length,
|
||||
importedCookies: 0,
|
||||
skippedCookies: skipped + integritySkipped,
|
||||
domains: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stagingDb) {
|
||||
try {
|
||||
stagingDb.exec('COMMIT')
|
||||
@@ -1786,7 +1884,7 @@ export async function importCookiesFromBrowser(
|
||||
const summary: BrowserCookieImportSummary = {
|
||||
totalCookies: sourceRows.length,
|
||||
importedCookies: imported,
|
||||
skippedCookies: skipped,
|
||||
skippedCookies: skipped + integritySkipped,
|
||||
domains: [...domainSet].sort(),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user