add security hardening features (#543)
* add security hardening features - Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007) - Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011) - Add `--allowed-domains` flag to restrict browser navigation to trusted domains - Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation - Add `--max-output` flag to truncate large page outputs, preventing context flooding - New docs page at /security, updated README, SKILL.md, CLI help text, and templates * fixes * fixes * fixes * fixes * fixes * fixes * fixes * docs
This commit is contained in:
+81
-2
@@ -21,6 +21,7 @@ import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
isEncryptedPayload,
|
||||
@@ -117,6 +118,7 @@ export class BrowserManager {
|
||||
private scopedHeaderRoutes: Map<string, (route: Route) => Promise<void>> = new Map();
|
||||
private colorScheme: 'light' | 'dark' | 'no-preference' | null = null;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -246,6 +248,61 @@ export class BrowserManager {
|
||||
return parseRef(selector) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the domain filter on a context if an allowlist is configured.
|
||||
* Should be called before any pages navigate on the context.
|
||||
*/
|
||||
private async ensureDomainFilter(context: BrowserContext): Promise<void> {
|
||||
if (this.allowedDomains.length > 0) {
|
||||
await installDomainFilter(context, this.allowedDomains);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After installing the domain filter, verify existing pages are on allowed
|
||||
* domains. Pages that pre-date the filter (e.g. CDP/cloud connect) may have
|
||||
* already navigated to disallowed domains. Navigate them to about:blank.
|
||||
*/
|
||||
private async sanitizeExistingPages(pages: Page[]): Promise<void> {
|
||||
if (this.allowedDomains.length === 0) return;
|
||||
for (const page of pages) {
|
||||
const url = page.url();
|
||||
if (!url || url === 'about:blank') continue;
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||
await page.goto('about:blank');
|
||||
}
|
||||
} catch {
|
||||
await page.goto('about:blank').catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is allowed by the domain allowlist.
|
||||
* Throws if the URL's domain is blocked. No-op if no allowlist is set.
|
||||
* Blocks non-http(s) schemes and unparseable URLs by default.
|
||||
*/
|
||||
checkDomainAllowed(url: string): void {
|
||||
if (this.allowedDomains.length === 0) return;
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
throw new Error(`Navigation blocked: non-http(s) scheme in URL "${url}"`);
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
throw new Error(`Navigation blocked: unable to parse URL "${url}"`);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(hostname, this.allowedDomains)) {
|
||||
throw new Error(`Navigation blocked: ${hostname} is not in the allowed domains list`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator - supports both refs and regular selectors
|
||||
*/
|
||||
@@ -286,6 +343,7 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -899,6 +957,8 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(10000);
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
@@ -1039,10 +1099,12 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
this.setupContextTracking(context);
|
||||
} catch (error) {
|
||||
await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => {
|
||||
console.error('Failed to close Kernel session during cleanup:', sessionError);
|
||||
@@ -1112,10 +1174,12 @@ export class BrowserManager {
|
||||
this.browser = browser;
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
await this.sanitizeExistingPages([page]);
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
this.setupPageTracking(page);
|
||||
this.setupContextTracking(context);
|
||||
} catch (error) {
|
||||
await this.closeBrowserUseSession(session.id, browserUseApiKey).catch((sessionError) => {
|
||||
console.error('Failed to close Browser Use session during cleanup:', sessionError);
|
||||
@@ -1178,6 +1242,15 @@ export class BrowserManager {
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
|
||||
if (options.allowedDomains && options.allowedDomains.length > 0) {
|
||||
this.allowedDomains = options.allowedDomains.map((d: string) => d.toLowerCase());
|
||||
} else {
|
||||
const envDomains = process.env.AGENT_BROWSER_ALLOWED_DOMAINS;
|
||||
if (envDomains) {
|
||||
this.allowedDomains = parseDomainList(envDomains);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.downloadPath && (cdpEndpoint || options.autoConnect)) {
|
||||
const warning =
|
||||
"--download-path is ignored when connecting via CDP or auto-connect (downloads use the remote browser's configuration)";
|
||||
@@ -1405,8 +1478,10 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
await this.sanitizeExistingPages([page]);
|
||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||
if (!this.pages.includes(page)) {
|
||||
this.pages.push(page);
|
||||
@@ -1480,8 +1555,11 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(10000);
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
}
|
||||
|
||||
await this.sanitizeExistingPages(allPages);
|
||||
|
||||
for (const page of allPages) {
|
||||
this.pages.push(page);
|
||||
this.setupPageTracking(page);
|
||||
@@ -1737,6 +1815,7 @@ export class BrowserManager {
|
||||
context.setDefaultTimeout(getDefaultTimeout());
|
||||
this.contexts.push(context);
|
||||
this.setupContextTracking(context);
|
||||
await this.ensureDomainFilter(context);
|
||||
|
||||
const page = await context.newPage();
|
||||
// Only add if not already tracked (setupContextTracking may have already added it via 'page' event)
|
||||
|
||||
Reference in New Issue
Block a user