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:
Chris Tate
2026-02-25 15:33:20 -06:00
committed by GitHub
parent c0e2b80f8c
commit bc1e917e87
28 changed files with 3444 additions and 476 deletions
+81 -2
View File
@@ -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)