Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export class BrowserManager {
private dialogAutoAccept: boolean = true;
private dialogPromptText: string | null = null;

// ─── Cookie Origin Tracking ────────────────────────────────
private cookieImportedDomains: Set<string> = new Set();

// ─── Handoff State ─────────────────────────────────────────
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
Expand Down Expand Up @@ -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<string> {
return this.cookieImportedDomains;
}

hasCookieImports(): boolean {
return this.cookieImportedDomains.size > 0;
}

// ─── Viewport ──────────────────────────────────────────────
async setViewport(width: number, height: number) {
await this.getPage().setViewportSize({ width, height });
Expand Down
34 changes: 34 additions & 0 deletions browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ export async function getCleanText(page: Page | Frame): Promise<string> {
});
}

/**
* 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[],
Expand Down Expand Up @@ -166,6 +198,7 @@ export async function handleReadCommand(
case 'js': {
const expr = args[0];
if (!expr) throw new Error('Usage: browse js <expression>');
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 ?? '');
Expand All @@ -174,6 +207,7 @@ export async function handleReadCommand(
case 'eval': {
const filePath = args[0];
if (!filePath) throw new Error('Usage: browse eval <js-file>');
assertJsOriginAllowed(bm, page.url());
validateReadPath(filePath);
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
Expand Down
34 changes: 30 additions & 4 deletions browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <browser> --domain <domain> [--profile <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');

Expand All @@ -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 <domain> to scope cookies to a single domain.`;
}

default:
Expand Down