Domain policy (setDomainPolicy) is bypassed by service-worker fetches and WebSocket connections
Domain policy (setDomainPolicy) is bypassed by service-worker fetches and WebSocket connections
Severity: serious (the documented context-wide network domain policy does not gate requests issued from service-worker targets, and never intercepts WebSocket connections, while page and iframe routes are correctly gated)
Affected: the domain policy shipped in Stagehand 3.7 (allowlist #2283, blocklist #2274) through current main. Audited at commit aaf421d7bcca8871c776a5fda6beff0f93b84749 (workspace version 4.0.0), packages/extension/understudy/context.ts and domainPolicy.ts.
Mechanism
The policy is enforced by attaching a Fetch.requestPaused handler with Fetch.enable on every page and iframe session, plus a target-level popup closer. Two arrival routes skip all of it:
Worker and service-worker targets.
isNonWebTargetreturns true for every target type except "page" and "iframe", soonAttachedToTargetonly sendsRuntime.runIfWaitingForDebuggerand returns; theFetch.enableinstall a few lines below is unreachable for those targets.packages/extension/understudy/context.ts:60-66function isNonWebTarget(info: Protocol.Target.TargetInfo): boolean { // (comment lines omitted) if (info.type === "page") return false; return info.type !== "iframe" || !hasInjectableDOM(info.url); }packages/extension/understudy/context.ts:666-676// Skip non-web targets (workers, chrome extensions, background pages, etc.). // They still need to be resumed so we don't leave them paused by // waitForDebuggerOnStart. Trying to initialize these targets can throw or // corrupt their internal state (e.g. Chrome's PDF viewer). if (isNonWebTarget(info)) { const session = this.conn.getSession(sessionId); if (session) { await session.send("Runtime.runIfWaitingForDebugger").catch(() => {}); } return; }A service worker is its own CDP target with its own network requests, so fetches issued inside it are never paused. Dedicated workers are covered incidentally because Chrome attributes their fetches to the owning page target (verified in testing); the service worker is the gap.
Scheme enumeration. The fetch patterns enumerate only http and https, and the decision function continues on any non-HTTP URL, so a WebSocket connection to a blocked or non-allowed host is never matched.
packages/extension/understudy/domainPolicy.ts:19const HTTP_SCHEMES = ["http", "https"] as const;packages/extension/understudy/domainPolicy.ts:185-193function hostnameFromHttpUrl(url: string): string | null { try { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return null; }
The docs describe the feature as a "context-wide network domain policy" that "applies to HTTP and HTTPS requests across existing and future pages, attached child targets, iframes, and subresource loads" (packages/docs/v3/references/context.mdx), and the v4 reference describes the fields as "Domains that the context may access" and "Domains that the context must reject". Service workers are auto-attached child targets and their fetches are HTTP(S) requests from the context.
Reproduction
Local headless Chrome driving the real BrowserContext from the repo, with loopback stand-ins (page origin 127.0.0.1, policy-blocked origin 127.0.0.2, both inside 127/8 so nothing leaves the machine):
- Create a
BrowserContextover the browser websocket, thenawait ctx.setDomainPolicy({ blockedDomains: ["127.0.0.2"] }). - Navigate the page to the allowed origin. The page then:
- fetches
http://127.0.0.2:8123/direct(control), - registers
/sw.jsand asks it to fetchhttp://127.0.0.2:8123/sw-exfil, - opens
new WebSocket("ws://127.0.0.2:8123/socket").
- fetches
- Observe the blocked origin's server log and the CDP wire.
Results (Chrome 145.0.7632.159, commit above, 20/20 harness assertions):
- Control: the direct fetch fails with
TypeError: Failed to fetchand the wire showsFetch.failRequeston the page session. The page route is gated. - Bypass A: the service worker's fetch returns 200 and the blocked origin's log contains
/sw-exfil. The wire shows the service_worker sessions received exactly one command,Runtime.runIfWaitingForDebugger, and noFetch.enable. - Bypass B: the WebSocket connection to the blocked origin establishes (client onopen fires; the blocked origin logs the upgrade). All 16 emitted
Fetchpatterns are http/https variants; none can match ws/wss. - Repeating with
allowedDomains: ["127.0.0.1"]reproduces both bypasses under the allowlist posture: direct fetch blocked, service-worker fetch to the non-allowed host landed (200), WebSocket to the non-allowed host established. - Dedicated-worker check for completeness: a dedicated Worker fetching the blocked origin stays blocked (page-attributed request), so the gap is specific to service workers plus the websocket scheme.
Expected vs actual
Expected: with blockedDomains containing a host (or allowedDomains not containing it), no HTTP(S) request or WebSocket connection from any target in the context reaches that host, which is the posture the popup-closer and the fail-closed Fetch.enable handling already enforce for page targets.
Actual: any page the agent visits can register a service worker on its own origin and exchange arbitrary data with any policy-forbidden host over HTTP(S), and any page can open a WebSocket to a policy-forbidden host; page and iframe routes remain correctly blocked.
Impact
The policy is the operator's fence for what an agent-driven browser may talk to, including the prompt-injection exfil scenario (a visited page trying to move extracted data or credentials to an attacker host). With this gap, the page side of that scenario needs no model cooperation at all: ordinary page JavaScript crosses the fence via its service worker or a WebSocket, and the operator's blocklist/allowlist reports nothing.
Suggested fix
- Install the domain-policy
Fetchhandler onservice_workerandshared_workersessions before resume, exactly as done for page and iframe sessions, keeping the existing fail-closed behavior ifFetch.enablefails on such a session. - For WebSocket: extend the emitted patterns with ws/wss variants of each blocklist rule and
ws://*/*,wss://*/*for the allowlist posture, and verify against Chrome thatFetchpauses WebSocket handshakes under those patterns; alternatively gate WebSocket handshakes in the decision function so the popup-closer posture can be reused.
The reproduction harness for both routes is small (two loopback servers, a service worker, and the BrowserContext API) and I am happy to share it.
Source: browserbase/stagehand