mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-04 02:16:56 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
1 parent
730a1017d1
commit
a84b0b5b6d
111 files changed
+14996
-1057
No files matched your search
+164
-346
@@ -35,12 +35,12 @@
|
||||
* └──────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { openCookieDatabase } from './cookie-database';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface ImportResult {
|
||||
count: number;
|
||||
failed: number;
|
||||
domainCounts: Record<string, number>;
|
||||
failureReasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface PlaywrightCookie {
|
||||
@@ -109,6 +110,7 @@ const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
||||
];
|
||||
@@ -168,6 +170,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
if (!fs.existsSync(browserDir)) continue;
|
||||
|
||||
let profileNames: Record<string, { name?: unknown }> = {};
|
||||
try {
|
||||
profileNames = JSON.parse(fs.readFileSync(path.join(browserDir, 'Local State'), 'utf-8'))?.profile?.info_cache ?? {};
|
||||
} catch {}
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
@@ -209,6 +216,9 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
// Ignore — fall back to directory name
|
||||
}
|
||||
|
||||
const currentName = profileNames?.[entry.name]?.name;
|
||||
if (typeof currentName === 'string' && currentName.trim()) displayName = currentName.trim();
|
||||
|
||||
profiles.push({ name: entry.name, displayName });
|
||||
}
|
||||
|
||||
@@ -216,7 +226,48 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
if (profiles.length > 0) break;
|
||||
}
|
||||
|
||||
return profiles;
|
||||
return profiles.sort((a, b) => a.name === b.name ? 0 : a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
export function normalizeCookieDomain(domain: string): string {
|
||||
if (typeof domain !== 'string' || !domain || domain.length > 254 || /[\s\/@?#\\*]/.test(domain)) {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
let host = domain.replace(/^\./, '').replace(/\.$/, '');
|
||||
if (isIP(host) === 6) host = '[' + host + ']';
|
||||
try {
|
||||
if (host.includes(':') && (!host.startsWith('[') || !host.endsWith(']') || isIP(host.slice(1, -1)) !== 6)) throw new Error();
|
||||
const url = new URL('http://' + host);
|
||||
if (!url.hostname || url.hostname.length > 253 || url.port || url.pathname !== '/' || url.hostname.split('.').some(label => !label)) throw new Error();
|
||||
return url.hostname;
|
||||
} catch {
|
||||
throw new CookieImportError('Invalid cookie domain', 'bad_request');
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieDomainMatches(hostname: string, cookieDomain: string): boolean {
|
||||
const host = normalizeCookieDomain(hostname);
|
||||
const domain = normalizeCookieDomain(cookieDomain);
|
||||
const address = isIP(domain.startsWith('[') ? domain.slice(1, -1) : domain);
|
||||
return host === domain || (!address && cookieDomain.startsWith('.') && host.endsWith('.' + domain));
|
||||
}
|
||||
|
||||
export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
if (attempt < 2 && ['db_locked', 'SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
await new Promise(resolve => setTimeout(resolve, [150, 500][attempt]));
|
||||
continue;
|
||||
}
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) throw new CookieImportError('Cookie database is busy. Close the source browser and retry.', 'db_locked', 'retry');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'SQLITE_READONLY') throw new CookieImportError('Cookie database access was denied', 'db_permission');
|
||||
if (typeof err?.code === 'string' && err.code.startsWith('SQLITE_')) throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,15 +302,18 @@ export async function importCookies(
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const selectedDomains = [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))];
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const derivedKeys = await getDerivedKeys(match);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
// Parameterized query — no SQL injection
|
||||
const placeholders = domains.map(() => '?').join(',');
|
||||
const placeholders = selectedDomains.map(() => '?').join(',');
|
||||
const rows = db.query(
|
||||
`SELECT host_key, name, value, encrypted_value, path, expires_utc,
|
||||
is_secure, is_httponly, has_expires, samesite
|
||||
@@ -267,11 +321,15 @@ export async function importCookies(
|
||||
WHERE host_key IN (${placeholders})
|
||||
AND (has_expires = 0 OR expires_utc > ?)
|
||||
ORDER BY host_key, name`
|
||||
).all(...domains, now) as RawCookie[];
|
||||
).all(...selectedDomains, now) as RawCookie[];
|
||||
|
||||
const needsKey = rows.some(row => !row.value && row.encrypted_value.length > 0 && Buffer.from(row.encrypted_value).subarray(0, 3).toString() !== 'v20');
|
||||
const derivedKeys = needsKey ? await getDerivedKeys(match) : new Map<string, Buffer>();
|
||||
|
||||
const cookies: PlaywrightCookie[] = [];
|
||||
let failed = 0;
|
||||
const domainCounts: Record<string, number> = {};
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
const failureReasons: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
@@ -279,12 +337,14 @@ export async function importCookies(
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
failed++;
|
||||
const reason = err instanceof CookieImportError && err.code === 'v20_encryption' ? 'unsupported_encryption' : 'decryption_failed';
|
||||
failureReasons[reason] = (failureReasons[reason] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed, domainCounts };
|
||||
return { cookies, count: cookies.length, failed, domainCounts, failureReasons };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -384,7 +444,7 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
|
||||
function openDb(dbPath: string, browserName: string): Database {
|
||||
function openDb(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
|
||||
// The readonly open may "succeed" but return empty results because the WAL
|
||||
// (where all actual data lives) can't be replayed. Always use the copy
|
||||
@@ -393,45 +453,59 @@ function openDb(dbPath: string, browserName: string): Database {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true });
|
||||
return openCookieDatabase(dbPath);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) {
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
|
||||
return openDbFromCopy(dbPath, browserName);
|
||||
}
|
||||
if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) {
|
||||
if (err?.code === 'SQLITE_CORRUPT') {
|
||||
throw new CookieImportError(
|
||||
`Cookie database for ${browserName} is corrupt`,
|
||||
'db_corrupt',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
function openDbFromCopy(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
|
||||
// Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
|
||||
const tmpPath = path.join(os.tmpdir(), `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`);
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-cookies-'));
|
||||
const tmpPath = path.join(tmpDir, 'Cookies');
|
||||
try {
|
||||
fs.chmodSync(tmpDir, 0o700);
|
||||
fs.copyFileSync(dbPath, tmpPath);
|
||||
fs.chmodSync(tmpPath, 0o600);
|
||||
// Also copy WAL and SHM if they exist (for consistent reads)
|
||||
const walPath = dbPath + '-wal';
|
||||
const shmPath = dbPath + '-shm';
|
||||
if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
if (fs.existsSync(walPath)) {
|
||||
fs.copyFileSync(walPath, tmpPath + '-wal');
|
||||
fs.chmodSync(tmpPath + '-wal', 0o600);
|
||||
}
|
||||
if (fs.existsSync(shmPath)) {
|
||||
fs.copyFileSync(shmPath, tmpPath + '-shm');
|
||||
fs.chmodSync(tmpPath + '-shm', 0o600);
|
||||
}
|
||||
|
||||
const db = new Database(tmpPath, { readonly: true });
|
||||
const db = openCookieDatabase(tmpPath);
|
||||
// Schedule cleanup after the DB is closed
|
||||
const origClose = db.close.bind(db);
|
||||
db.close = () => {
|
||||
origClose();
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-wal'); } catch {}
|
||||
try { fs.unlinkSync(tmpPath + '-shm'); } catch {}
|
||||
try { origClose(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }
|
||||
};
|
||||
return db;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
// Clean up on failure
|
||||
try { fs.unlinkSync(tmpPath); } catch {}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
|
||||
if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
|
||||
if (err?.code === 'EACCES' || err?.code === 'EPERM') throw new CookieImportError('Cookie database access denied', 'db_permission');
|
||||
if (err?.code === 'ENOENT') throw new CookieImportError('Cookie database no longer exists', 'db_missing');
|
||||
if (!/SQLITE_BUSY|SQLITE_LOCKED|database is locked|EBUSY/.test(String(err?.code) + String(err?.message))) {
|
||||
throw new CookieImportError('Cookie database could not be read', 'db_read_error');
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
|
||||
'db_locked',
|
||||
@@ -494,9 +568,8 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
try {
|
||||
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? `: ${err.message}` : '';
|
||||
throw new CookieImportError(
|
||||
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
|
||||
`Cannot read Local State for ${browser.name}`,
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
@@ -532,33 +605,61 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
proc.stdin.write(encryptedBytes.toString('base64'));
|
||||
proc.stdin.end();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
|
||||
throw new CookieImportError('DPAPI decryption failed', 'keychain_error');
|
||||
}
|
||||
return Buffer.from(stdout.trim(), 'base64');
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`DPAPI decryption failed: ${(err as Error).message}`,
|
||||
'DPAPI decryption failed',
|
||||
'keychain_error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readCredentialProcess(
|
||||
proc: { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; kill(): void },
|
||||
timeoutMs: number,
|
||||
timeoutError: () => Error,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
|
||||
const read = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return Buffer.concat(chunks, bytes).toString('utf8');
|
||||
bytes += value.byteLength;
|
||||
if (bytes > 64 * 1024) throw new Error('Credential process output exceeded the limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
||||
});
|
||||
try {
|
||||
const [exitCode, stdout, stderr] = await Promise.race([
|
||||
Promise.all([proc.exited, read(readers[0]), read(readers[1])]), timeout,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
} catch (error) {
|
||||
try { proc.kill(); } catch {}
|
||||
for (const reader of readers) {
|
||||
try { void reader.cancel().catch(() => {}); } catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer!);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
@@ -567,21 +668,13 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
{ stdout: 'pipe', stderr: 'pipe', windowsHide: true },
|
||||
);
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new CookieImportError(
|
||||
try {
|
||||
const { exitCode, stdout, stderr } = await readCredentialProcess(proc, 10_000, () =>
|
||||
new CookieImportError(
|
||||
`macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
|
||||
'keychain_timeout',
|
||||
'retry',
|
||||
));
|
||||
}, 10_000),
|
||||
);
|
||||
|
||||
try {
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
// Distinguish denied vs not found vs other
|
||||
@@ -600,7 +693,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
);
|
||||
}
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${stderr.trim()}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -610,7 +703,7 @@ async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
} catch (err) {
|
||||
if (err instanceof CookieImportError) throw err;
|
||||
throw new CookieImportError(
|
||||
`Could not read Keychain: ${(err as Error).message}`,
|
||||
'Could not read Keychain',
|
||||
'keychain_error',
|
||||
'retry',
|
||||
);
|
||||
@@ -640,15 +733,7 @@ async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | nu
|
||||
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs),
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const { exitCode, stdout } = await readCredentialProcess(proc, timeoutMs, () => new Error('timeout'));
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const password = stdout.trim();
|
||||
@@ -752,295 +837,28 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
|
||||
}
|
||||
|
||||
|
||||
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
|
||||
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
|
||||
// with remote debugging and extract cookies via the DevTools Protocol.
|
||||
// This only works when Chrome is NOT already running (profile lock).
|
||||
|
||||
const CHROME_PATHS_WIN = [
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
];
|
||||
|
||||
const EDGE_PATHS_WIN = [
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
||||
];
|
||||
|
||||
function findBrowserExe(browserName: string): string | null {
|
||||
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBrowserRunning(browserName: string): Promise<boolean> {
|
||||
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
|
||||
return new Promise((resolve) => {
|
||||
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
|
||||
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
|
||||
});
|
||||
proc.exited.then(async () => {
|
||||
const out = await new Response(proc.stdout).text();
|
||||
resolve(out.toLowerCase().includes(exe));
|
||||
}).catch(() => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
|
||||
* remote debugging on the user's real profile directory. Requires Chrome to be
|
||||
* closed first (profile lock).
|
||||
*
|
||||
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
|
||||
* path, so a temp copy of the profile won't work — Chrome silently discards
|
||||
* cookies it can't decrypt. We must use the real profile.
|
||||
*/
|
||||
export async function importCookiesViaCdp(
|
||||
browserName: string,
|
||||
domains: string[],
|
||||
profile = 'Default',
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
if (process.platform !== 'win32') {
|
||||
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
||||
const browser = resolveBrowser(browserName);
|
||||
const exePath = findBrowserExe(browser.name);
|
||||
if (!exePath) {
|
||||
throw new CookieImportError(
|
||||
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
if (await isBrowserRunning(browser.name)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
|
||||
'browser_running',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
// Must use the real user data dir — v20 ABE keys are path-bound
|
||||
validateProfile(profile);
|
||||
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||
const userDataDir = path.join(getBaseDir('win32'), dataDir);
|
||||
|
||||
// Launch Chrome headless with remote debugging on the real profile.
|
||||
//
|
||||
// Security posture of the debug port:
|
||||
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The
|
||||
// port is NOT exposed to the network. Baseline threat: a local
|
||||
// process running as the same user can connect.
|
||||
// - Port is randomized in [9222, 9321] to avoid collisions with other
|
||||
// Chrome-based tools. Not cryptographic — security relies on
|
||||
// same-user-access baseline, not port secrecy.
|
||||
// - Chrome is always killed in the finally block below (even on crash).
|
||||
//
|
||||
// KNOWN NON-GOAL (tracked as a separate hardening task for the next
|
||||
// security wave):
|
||||
// On Windows 10.15+ with App-Bound Encryption (v20) enabled, a
|
||||
// same-user process that opens the cookie DB directly cannot decrypt
|
||||
// v20 values — the DPAPI context is bound to the browser process.
|
||||
// The CDP port bypasses that: `Network.getAllCookies` runs inside the
|
||||
// browser, so any same-user process that connects to the debug port
|
||||
// before we kill Chrome could exfiltrate decrypted v20 cookies.
|
||||
// Fix direction: switch to `--remote-debugging-pipe` so the CDP
|
||||
// transport is a parent/child stdio pipe, not TCP. Requires
|
||||
// restructuring the extractCookiesViaCdp WebSocket client; deferred
|
||||
// to a follow-up because the transport swap is non-trivial and the
|
||||
// baseline threat is still "attacker already has same-user access."
|
||||
//
|
||||
// Debugging note: if this path starts failing after a Chrome update,
|
||||
// check the Chrome version logged below — Chrome's ABE key format (v20)
|
||||
// or /json/list shape can change between major versions.
|
||||
const debugPort = 9222 + Math.floor(Math.random() * 100);
|
||||
const chromeProc = Bun.spawn([
|
||||
exePath,
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
`--profile-directory=${profile}`,
|
||||
'--headless=new',
|
||||
'--no-first-run',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--no-default-browser-check',
|
||||
], { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
|
||||
|
||||
// Wait for Chrome to start, then find a page target's WebSocket URL.
|
||||
// Network.getAllCookies is only available on page targets, not browser.
|
||||
let wsUrl: string | null = null;
|
||||
const startTime = Date.now();
|
||||
let loggedVersion = false;
|
||||
while (Date.now() - startTime < 15_000) {
|
||||
try {
|
||||
// One-time version log for future diagnostics when Chrome changes v20 format.
|
||||
if (!loggedVersion) {
|
||||
try {
|
||||
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
|
||||
if (versionResp.ok) {
|
||||
const v = await versionResp.json() as { Browser?: string };
|
||||
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
|
||||
loggedVersion = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
||||
if (resp.ok) {
|
||||
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (page?.webSocketDebuggerUrl) {
|
||||
wsUrl = page.webSocketDebuggerUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
if (!wsUrl) {
|
||||
chromeProc.kill();
|
||||
throw new CookieImportError(
|
||||
`${browser.name} headless did not start within 15s`,
|
||||
'cdp_timeout',
|
||||
'retry',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect via CDP WebSocket
|
||||
const cookies = await extractCookiesViaCdp(wsUrl, domains);
|
||||
|
||||
const domainCounts: Record<string, number> = {};
|
||||
for (const c of cookies) {
|
||||
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
|
||||
}
|
||||
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
} finally {
|
||||
chromeProc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let msgId = 1;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
|
||||
}, 10_000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Enable Network domain first, then request all cookies
|
||||
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(String(event.data));
|
||||
|
||||
// After Network.enable succeeds, request all cookies
|
||||
if (data.id === 1 && !data.error) {
|
||||
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.id === msgId && data.result?.cookies) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
|
||||
const domainSet = new Set<string>();
|
||||
for (const d of domains) {
|
||||
domainSet.add(d);
|
||||
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
|
||||
}
|
||||
|
||||
const matched: PlaywrightCookie[] = [];
|
||||
for (const c of data.result.cookies as CdpCookie[]) {
|
||||
if (!domainSet.has(c.domain)) continue;
|
||||
matched.push({
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path || '/',
|
||||
expires: c.expires === -1 ? -1 : c.expires,
|
||||
secure: c.secure,
|
||||
httpOnly: c.httpOnly,
|
||||
sameSite: cdpSameSite(c.sameSite),
|
||||
});
|
||||
}
|
||||
resolve(matched);
|
||||
} else if (data.id === msgId && data.error) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(new CookieImportError(
|
||||
`CDP error: ${data.error.message}`,
|
||||
'cdp_error',
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new CookieImportError(
|
||||
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
|
||||
'cdp_error',
|
||||
));
|
||||
};
|
||||
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
||||
const { importNativeCookies } = await import('./cookie-import-native');
|
||||
const cookies = await importNativeCookies({
|
||||
browserName: browser.name,
|
||||
userDataDir: path.join(getBaseDir('win32'), dataDir),
|
||||
profile,
|
||||
domains: [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
interface CdpCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
size: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
session: boolean;
|
||||
sameSite: string;
|
||||
}
|
||||
|
||||
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
|
||||
switch (value) {
|
||||
case 'Strict': return 'Strict';
|
||||
case 'Lax': return 'Lax';
|
||||
case 'None': return 'None';
|
||||
default: return 'Lax';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
|
||||
* Quick check — reads a small sample, no decryption attempted.
|
||||
*/
|
||||
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
try {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
|
||||
return rows.some(row => {
|
||||
const ev = Buffer.from(row.encrypted_value);
|
||||
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const domainCounts: Record<string, number> = Object.create(null);
|
||||
for (const cookie of cookies) domainCounts[cookie.domain] = (domainCounts[cookie.domain] || 0) + 1;
|
||||
return { cookies, count: cookies.length, failed: 0, domainCounts };
|
||||
}
|
||||
Reference in new issue
Block a user