diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index a6eda991b..4784c5431 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -57,6 +57,9 @@ export class BrowserManager { private dialogAutoAccept: boolean = true; private dialogPromptText: string | null = null; + // ─── Cookie Origin Tracking ──────────────────────────────── + private cookieImportedDomains: Set = new Set(); + // ─── Handoff State ───────────────────────────────────────── private isHeaded: boolean = false; private consecutiveFailures: number = 0; @@ -521,6 +524,19 @@ export class BrowserManager { return this.dialogPromptText; } + // ─── Cookie Origin Tracking ──────────────────────────────── + trackCookieImportDomains(domains: string[]): void { + for (const d of domains) this.cookieImportedDomains.add(d); + } + + getCookieImportedDomains(): ReadonlySet { + return this.cookieImportedDomains; + } + + hasCookieImports(): boolean { + return this.cookieImportedDomains.size > 0; + } + // ─── Viewport ────────────────────────────────────────────── async setViewport(width: number, height: number) { await this.getPage().setViewportSize({ width, height }); diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 5615b60f0..9da3cee1f 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -86,6 +86,38 @@ export async function getCleanText(page: Page | Frame): Promise { }); } +/** + * When cookies have been imported for specific domains, block JS execution + * on pages whose origin doesn't match any imported cookie domain. + * Prevents cross-origin cookie exfiltration via `js document.cookie` or + * similar when the agent navigates to an untrusted page. + */ +function assertJsOriginAllowed(bm: BrowserManager, pageUrl: string): void { + if (!bm.hasCookieImports()) return; + + let hostname: string; + try { + hostname = new URL(pageUrl).hostname; + } catch { + return; // about:blank, data: URIs — allow (no cookies at risk) + } + + const importedDomains = bm.getCookieImportedDomains(); + const allowed = [...importedDomains].some(domain => { + // Exact match or subdomain match (e.g., ".github.com" matches "api.github.com") + const normalized = domain.startsWith('.') ? domain : '.' + domain; + return hostname === domain.replace(/^\./, '') || hostname.endsWith(normalized); + }); + + if (!allowed) { + throw new Error( + `JS execution blocked: current page (${hostname}) does not match any cookie-imported domain. ` + + `Imported cookies for: ${[...importedDomains].join(', ')}. ` + + `This prevents cross-origin cookie exfiltration. Navigate to an imported domain or run without imported cookies.` + ); + } +} + export async function handleReadCommand( command: string, args: string[], @@ -166,6 +198,7 @@ export async function handleReadCommand( case 'js': { const expr = args[0]; if (!expr) throw new Error('Usage: browse js '); + assertJsOriginAllowed(bm, page.url()); const wrapped = wrapForEvaluate(expr); const result = await target.evaluate(wrapped); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); @@ -174,6 +207,7 @@ export async function handleReadCommand( case 'eval': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse eval '); + assertJsOriginAllowed(bm, page.url()); validateReadPath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 02413daf8..3194eb487 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -314,32 +314,58 @@ export async function handleWriteCommand( } await page.context().addCookies(cookies); + const importedDomains = [...new Set(cookies.map((c: any) => c.domain).filter(Boolean))]; + if (importedDomains.length > 0) bm.trackCookieImportDomains(importedDomains); return `Loaded ${cookies.length} cookies from ${filePath}`; } case 'cookie-import-browser': { // Two modes: // 1. Direct CLI import: cookie-import-browser --domain [--profile ] - // 2. Open picker UI: cookie-import-browser [browser] + // Requires --domain (or --all to explicitly import everything). + // 2. Open picker UI: cookie-import-browser [browser] (interactive domain selection) const browserArg = args[0]; const domainIdx = args.indexOf('--domain'); const profileIdx = args.indexOf('--profile'); + const hasAll = args.includes('--all'); const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default'; if (domainIdx !== -1 && domainIdx + 1 < args.length) { - // Direct import mode — no UI + // Direct import mode — scoped to specific domain const domain = args[domainIdx + 1]; const browser = browserArg || 'comet'; const result = await importCookies(browser, [domain], profile); if (result.cookies.length > 0) { await page.context().addCookies(result.cookies); + bm.trackCookieImportDomains([domain]); } const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`]; if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); return msg.join(' '); } - // Picker UI mode — open in user's browser + if (hasAll) { + // Explicit all-cookies import — requires --all flag as a deliberate opt-in. + // Imports every non-expired cookie domain from the browser. + const browser = browserArg || 'comet'; + const { listDomains } = await import('./cookie-import-browser'); + const { domains } = listDomains(browser, profile); + const allDomainNames = domains.map(d => d.domain); + if (allDomainNames.length === 0) { + return `No cookies found in ${browser} (profile: ${profile})`; + } + const result = await importCookies(browser, allDomainNames, profile); + if (result.cookies.length > 0) { + await page.context().addCookies(result.cookies); + bm.trackCookieImportDomains(allDomainNames); + } + const msg = [`Imported ${result.count} cookies across ${Object.keys(result.domainCounts).length} domains from ${browser}`]; + msg.push('(used --all: all browser cookies imported — consider --domain for tighter scoping)'); + if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`); + return msg.join(' '); + } + + // Picker UI mode — open in user's browser for interactive domain selection const port = bm.serverPort; if (!port) throw new Error('Server port not available'); @@ -355,7 +381,7 @@ export async function handleWriteCommand( // open may fail silently — URL is in the message below } - return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`; + return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.\n\nTip: For scripted imports, use --domain to scope cookies to a single domain.`; } default: