Skip to content

Add domain-firewall skill — CDP navigation security for browser agents#63

Open
aq17 wants to merge 25 commits intomainfrom
add-domain-firewall-skill
Open

Add domain-firewall skill — CDP navigation security for browser agents#63
aq17 wants to merge 25 commits intomainfrom
add-domain-firewall-skill

Conversation

@aq17
Copy link
Copy Markdown
Contributor

@aq17 aq17 commented Mar 30, 2026

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:

  • CLI script (agent-first) — node domain-firewall.mjs --session-id <id> --allowlist "example.com" attaches to a live session and enforces policies transparently. Works with the browse CLI, Stagehand, or any CDP client.
  • TypeScript API (code integration) — installDomainFirewall(page, { policies, defaultVerdict }) for embedding directly in Stagehand projects with composable policies.

What it does

  • Intercepts all Document navigations via Fetch.requestPaused CDP event
  • Evaluates requests against a policy chain: denylist → allowlist → default verdict
  • Blocks unauthorized navigations with Fetch.failRequest("BlockedByClient") — Chrome shows ERR_BLOCKED_BY_CLIENT
  • Logs all decisions (allowed/blocked + which policy decided) to stdout or audit trail

Built-in policies (TypeScript API)

Policy Purpose
allowlist(domains) Static domain allowlist
denylist(domains) Static domain denylist
pattern(globs, verdict) Glob matching on domains
tld(rules) TLD-based rules (.org, .edu, .ru)
interactive(handler) Human-in-the-loop approval with session memory

CLI script flags

--session-id <id>      Browserbase session ID
--cdp-url <ws://...>   Direct CDP WebSocket URL (local Chrome)
--allowlist <domains>   Comma-separated allowed domains
--denylist <domains>    Comma-separated denied domains
--default <verdict>     allow or deny (default: deny)
--json                  Output events as JSON lines
--quiet                 Suppress per-request logging

Security demo results

Tested against a honeypot page containing hidden prompt injection that directs agents to an attacker URL:

Scenario Result
Without firewall Agent navigated to attacker URL, session token exfiltrated
With firewall (--allowlist localhost) Navigation blocked at CDP level, attacker received zero data

Files

skills/domain-firewall/
├── SKILL.md                          # Skill docs (threat model, policies, usage)
├── REFERENCE.md                      # API reference, CDP protocol details
├── EXAMPLES.md                       # 6 usage examples
├── LICENSE.txt                       # MIT
├── package.json                      # ws dependency for CDP WebSocket
└── scripts/
    └── domain-firewall.mjs           # CLI-native firewall script (agent-first)

Test plan

  • CLI script connects to Browserbase sessions via bb sessions debug
  • CLI script connects to local Chrome via --cdp-url
  • Allowlisted domains pass through (Fetch.continueRequest)
  • Non-allowlisted domains blocked (Fetch.failRequest)
  • Denylist takes priority over allowlist
  • Browser-level CDP auto-attaches to page target (coexists with browse CLI)
  • Fail-closed on policy errors (no hung requests)
  • Graceful shutdown on SIGINT
  • JSON output mode (--json)
  • Honeypot attack demo: unprotected agent gets pwned, protected agent blocks exfiltration

🤖 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-firewall skill that can attach to Browserbase or local Chrome via CDP and block/allow navigations by domain using Fetch.requestPaused interception, with CLI flags for --allowlist, --denylist, --default, --json, and --quiet.

Registers the skill in .claude-plugin/marketplace.json and ships supporting docs (SKILL.md, REFERENCE.md, EXAMPLES.md), plus a small Node package (ws dependency) and the domain-firewall.mjs script that resolves Browserbase CDP URLs via bb 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.

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>
@aq17 aq17 requested review from Kylejeong2, shrey150 and shubh24 March 30, 2026 21:23
@aq17
Copy link
Copy Markdown
Contributor Author

aq17 commented Mar 30, 2026

Inspired by @shubh24 's sample script for Notion who raised this as a top-of-mind guardrail for security

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 installDomainFirewall and added await to all page.sendCDP calls in the Fetch.requestPaused handler to prevent unhandled rejections and stuck paused requests.

Create PR

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.

aq17 and others added 2 commits March 30, 2026 14:30
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>
@aq17 aq17 closed this Apr 3, 2026
@aq17 aq17 reopened this Apr 3, 2026
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>
@aq17 aq17 changed the title Add domain-firewall skill (CDP navigation security via Stagehand) Add domain-firewall skill — CDP navigation security for browser agents Apr 3, 2026
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

aq17 and others added 2 commits April 3, 2026 14:59
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>
aq17 and others added 3 commits April 3, 2026 15:19
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>
aq17 and others added 3 commits April 3, 2026 15:32
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>
aq17 and others added 2 commits April 3, 2026 17:00
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>
aq17 and others added 3 commits April 6, 2026 15:57
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>
@aq17 aq17 requested a review from shubh24 April 6, 2026 23:21
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>
@aq17
Copy link
Copy Markdown
Contributor Author

aq17 commented Apr 7, 2026

Potential messaging:

Your browser agent just got prompt injected. One hidden link on a page, and your session
   token is on its way to an attacker.

  We built a fix: domain-firewall. One CLI command, zero code changes. It intercepts every
   navigation at the Chrome DevTools Protocol level — before the request ever leaves the
  browser.

  node domain-firewall.mjs --session-id <id> --allowlist "stripe.com" --default deny

  Not on the allowlist? Killed instantly. No bytes reach the attacker.

  We tested it: same page, same malicious link. Without the firewall — data exfiltrated.
  With it — ERR_BLOCKED_BY_CLIENT. Done.

  Open source, ships as a Browserbase skill. Go protect your agents.

How it works

  1. Agent creates a Browserbase session via bb sessions create
  2. Agent runs node domain-firewall.mjs --session-id <id> --allowlist "example.com"
  --default deny
  3. The script connects to the session's CDP WebSocket (resolved via bb sessions debug)
  4. It sends Fetch.enable to intercept all network requests at the protocol level
  5. On every Fetch.requestPaused event, it checks the domain against the denylist →
  allowlist → default verdict
  6. Allowed: Fetch.continueRequest. Blocked: Fetch.failRequest("BlockedByClient")
  7. Logs every decision to stdout. Runs until Ctrl+C or session ends.

aq17 and others added 3 commits April 7, 2026 11:25
--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>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

aq17 and others added 2 commits April 7, 2026 13:27
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants