Add domain-firewall skill — CDP navigation security for browser agents#63
Add domain-firewall skill — CDP navigation security for browser agents#63
Conversation
Teaches coding agents how to implement protocol-level domain allowlisting for Browserbase sessions using CDP Fetch.enable. Features a composable policy system with five built-in policies (allowlist, denylist, pattern, tld, interactive) that chain with first-match-wins semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Inspired by @shubh24 's sample script for Notion who raised this as a top-of-mind guardrail for security |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing await on sendCDP calls in event handler
- I confirmed the missing awaits in
installDomainFirewalland addedawaitto allpage.sendCDPcalls in theFetch.requestPausedhandler to prevent unhandled rejections and stuck paused requests.
- I confirmed the missing awaits in
Or push these changes by commenting:
@cursor push 36f6f7fbd8
Preview (36f6f7fbd8)
diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md
--- a/skills/domain-firewall/SKILL.md
+++ b/skills/domain-firewall/SKILL.md
@@ -300,7 +300,7 @@
// Pass through non-document requests (images, CSS, JS, fonts, etc.)
const resourceType = params.resourceType || "";
if (resourceType !== "Document" && resourceType !== "") {
- page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
+ await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
return;
}
@@ -310,7 +310,7 @@
url.startsWith("about:") ||
url.startsWith("data:")
) {
- page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
+ await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
return;
}
@@ -318,7 +318,7 @@
try {
domain = normalizeDomain(new URL(url).hostname);
} catch {
- page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
+ await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
return;
}
@@ -334,13 +334,13 @@
time: ts(), domain, url: url.substring(0, 80),
action: "ALLOWED", decidedBy,
});
- page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
+ await page.sendCDP("Fetch.continueRequest", { requestId: params.requestId });
} else {
auditLog?.push({
time: ts(), domain, url: url.substring(0, 80),
action: "BLOCKED", decidedBy,
});
- page.sendCDP("Fetch.failRequest", {
+ await page.sendCDP("Fetch.failRequest", {
requestId: params.requestId,
errorReason: "BlockedByClient",
});This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
The interactive policy now remembers approved and denied domains for the rest of the session by default, so the operator is only prompted once per domain. Opt out with remember: false. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All page.sendCDP() calls inside the async event handler were fire-and-forget. If any rejected (session disconnected, request already handled), the unhandled promise rejection would crash Node.js and the paused CDP request would never resolve, permanently freezing the browser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wrap evaluatePolicies + CDP response calls in try/catch so that if any policy's evaluate() throws, the paused request is denied rather than left permanently hanging the browser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Runnable script that attaches to a live Browserbase session (or local Chrome) via CDP WebSocket and enforces domain allowlist/denylist policies at the protocol level. Zero Stagehand dependency — connects directly to the CDP target. Usage: node domain-firewall.mjs --session-id <id> --allowlist "example.com" node domain-firewall.mjs --cdp-url ws://... --allowlist "localhost" Supports browser-level and page-level CDP targets, auto-attaches to page when connected at browser level (coexists with browse CLI). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Case-sensitive www prefix strip before lowercasing
- Updated domain normalization to lowercase before stripping the www prefix in both the runtime script and SKILL example code.
Or push these changes by commenting:
@cursor push e179cf303d
Preview (e179cf303d)
diff --git a/skills/domain-firewall/SKILL.md b/skills/domain-firewall/SKILL.md
--- a/skills/domain-firewall/SKILL.md
+++ b/skills/domain-firewall/SKILL.md
@@ -180,7 +180,7 @@
```typescript
function normalizeDomain(hostname: string): string {
- return hostname.replace(/^www\./, "").toLowerCase();
+ return hostname.toLowerCase().replace(/^www\./, "");
}
function ts(): string {
diff --git a/skills/domain-firewall/scripts/domain-firewall.mjs b/skills/domain-firewall/scripts/domain-firewall.mjs
--- a/skills/domain-firewall/scripts/domain-firewall.mjs
+++ b/skills/domain-firewall/scripts/domain-firewall.mjs
@@ -95,7 +95,7 @@
// =============================================================================
function normalizeDomain(hostname) {
- return hostname.replace(/^www\./, "").toLowerCase();
+ return hostname.toLowerCase().replace(/^www\./, "");
}
function ts() {This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
SKILL.md now leads with the CLI script workflow (--session-id, --cdp-url, --allowlist, --default) and the agent workflow with browse CLI. TypeScript API moved to "Advanced: Code Integration" section. EXAMPLES.md reordered with CLI examples first. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Swaps the order so that "WWW.Example.com" or "Www.github.com" correctly normalizes to "example.com" / "github.com" instead of silently retaining the www prefix after lowercasing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace execSync with template string with execFileSync and an args array so that a malicious --session-id value cannot execute arbitrary shell commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
data: URLs can contain attacker-controlled HTML with embedded sub-resource loads that bypass the firewall. Remove data: from the internal URL passthrough so they flow through normal policy evaluation where the empty hostname is blocked by default-deny. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Avoids a race where an event arriving in the same TCP chunk as the Fetch.enable response is emitted synchronously before the await resumes, causing the handler to miss it and leaving the request permanently paused. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unparseable URLs were being passed through via continueRequest, contradicting the fail-closed design. Now blocked with failRequest to match the error handling in policy evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- createCDPClient now rejects promises on CDP error responses instead of silently resolving with the error object - sendCDP wrapper also uses reject/resolve - Target.attachToTarget failure exits with fatal error instead of continuing with undefined sessionId - Missing page target exits with fatal error - "Listening for navigations..." only prints after Fetch.enable succeeds — if it fails, main().catch() prints Fatal and exits Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace generic examples with practical scenarios: banking, CRM migration, competitive intelligence, e-commerce monitoring, procurement automation, agent checkout, staging isolation, and HR onboarding. Each shows the exact CLI command and explains why the firewall matters for that use case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of two commands (bb sessions create + domain-firewall.mjs), agents can now run: node domain-firewall.mjs --create --allowlist "example.com" This creates a Browserbase session with keepAlive, attaches the firewall, prints the session ID to stdout, and stays running. Auto-detects project ID from BROWSERBASE_PROJECT_ID env var or bb projects list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Quick Start, Agent Workflow, CLI Reference, and Examples now lead with --create as the recommended one-command approach. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix 1: Top-level try-catch around the Fetch.requestPaused handler prevents unhandled promise rejections from crashing the process when sendCDP fails (network error, WebSocket closed). On error, attempts fail-closed (deny the request) before logging. Fix 2: --default now validates the value is "allow" or "deny". Previously, invalid values like "maybe" silently behaved as deny. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The --allowlist flag does exact string matching only. Glob/wildcard patterns like *.stripe.com only work in the TypeScript API's pattern() policy. Updated Best Practices and Examples to make this explicit and show how to list subdomains. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the firewall created the session (--create), it now calls bb sessions update --status REQUEST_RELEASE on SIGINT/SIGTERM. Sessions attached via --session-id are left alone since another client owns their lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The --create flag mixed session lifecycle management with firewall policy attachment. Session creation is the BB CLI's job (bb sessions create), and the firewall script's job is attaching policies to a live session. This keeps each tool's responsibility clear for agents. Removed: --create, --project-id, createBBSession(), session release on shutdown. The two-step workflow is now the only path: bb sessions create → domain-firewall.mjs --session-id <id> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Potential messaging: How it works |
--allowlist "*.stripe.com,stripe.com" now matches all Stripe subdomains. Uses simple suffix matching (endsWith) instead of regex — one behavior to reason about, no footguns. *.stripe.com matches docs.stripe.com, api.stripe.com *.stripe.com does NOT match stripe.com (include both if needed) Exact matching still works unchanged for non-wildcard entries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Page-level preference caused 500 errors when other CDP clients (browse CLI, Stagehand) tried to connect to the same page. The auto-attach logic in main() already handles page attachment from the browser target, so the page-level URL extraction was unnecessary and counterproductive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mmands - Add Setup section with npm install step (matches cookie-sync pattern) - Fix all script paths to .claude/skills/domain-firewall/scripts/... (the actual installed location after bb skills install) - Replace unhelpful "projectId":"..." placeholder with auto-detecting command using bb projects list - Add stop guidance (Ctrl+C / kill %1) to Best Practices and Workflow - Show wildcard in Quick Start examples Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A trailing comma in --allowlist "stripe.com," created an empty string entry that matched data:, blob:, and file: URLs (which have empty hostnames), bypassing the firewall policy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5c7e9f4. Configure here.
normalizeDomain("www.evil.com") produced "evil.com", which failed
to match *.evil.com via endsWith(".evil.com") because the string
was shorter than the suffix. This let www. subdomains bypass
wildcard denylist entries.
Fix: remove www. stripping entirely. Lowercasing is sufficient
for normalization. Users who want to match www. subdomains can
use *.example.com which now correctly matches www.example.com.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>


Summary
Adds a domain-firewall skill that protects Browserbase (and local Chrome) sessions from unauthorized navigations at the Chrome DevTools Protocol level. Designed to prevent prompt injection attacks that trick AI agents into navigating to attacker-controlled URLs.
Two interfaces:
node domain-firewall.mjs --session-id <id> --allowlist "example.com"attaches to a live session and enforces policies transparently. Works with thebrowseCLI, Stagehand, or any CDP client.installDomainFirewall(page, { policies, defaultVerdict })for embedding directly in Stagehand projects with composable policies.What it does
Documentnavigations viaFetch.requestPausedCDP eventFetch.failRequest("BlockedByClient")— Chrome showsERR_BLOCKED_BY_CLIENTBuilt-in policies (TypeScript API)
allowlist(domains)denylist(domains)pattern(globs, verdict)tld(rules).org,.edu,.ru)interactive(handler)CLI script flags
Security demo results
Tested against a honeypot page containing hidden prompt injection that directs agents to an attacker URL:
--allowlist localhost)Files
Test plan
bb sessions debug--cdp-urlFetch.continueRequest)Fetch.failRequest)--json)🤖 Generated with Claude Code
Note
Low Risk
Low risk because this PR is additive (new skill + documentation) and only touches plugin registration; it doesn’t modify existing automation logic, though the new CDP-interception script should be reviewed for correct fail-closed behavior and safe CLI parsing.
Overview
Adds a new
domain-firewallskill that can attach to Browserbase or local Chrome via CDP and block/allow navigations by domain usingFetch.requestPausedinterception, with CLI flags for--allowlist,--denylist,--default,--json, and--quiet.Registers the skill in
.claude-plugin/marketplace.jsonand ships supporting docs (SKILL.md,REFERENCE.md,EXAMPLES.md), plus a small Node package (wsdependency) and thedomain-firewall.mjsscript that resolves Browserbase CDP URLs viabb sessions debug, optionally auto-attaches from a browser target to a page target, and gracefully disables interception on shutdown.Reviewed by Cursor Bugbot for commit 93df23b. Bugbot is set up for automated code reviews on this repo. Configure here.