#2945·stagehand

Domain policy (setDomainPolicy) is bypassed by service-worker fetches and WebSocket connections

Author: AUTHENSORCreated Sep 16, 2026Updated Sep 16, 2026

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:

  1. Worker and service-worker targets. isNonWebTarget returns true for every target type except "page" and "iframe", so onAttachedToTarget only sends Runtime.runIfWaitingForDebugger and returns; the Fetch.enable install a few lines below is unreachable for those targets.

    packages/extension/understudy/context.ts:60-66

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

  2. 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:19

    const HTTP_SCHEMES = ["http", "https"] as const;

    packages/extension/understudy/domainPolicy.ts:185-193

    function 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):

  1. Create a BrowserContext over the browser websocket, then await ctx.setDomainPolicy({ blockedDomains: ["127.0.0.2"] }).
  2. Navigate the page to the allowed origin. The page then:
    • fetches http://127.0.0.2:8123/direct (control),
    • registers /sw.js and asks it to fetch http://127.0.0.2:8123/sw-exfil,
    • opens new WebSocket("ws://127.0.0.2:8123/socket").
  3. 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 fetch and the wire shows Fetch.failRequest on 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 no Fetch.enable.
  • Bypass B: the WebSocket connection to the blocked origin establishes (client onopen fires; the blocked origin logs the upgrade). All 16 emitted Fetch patterns 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 Fetch handler on service_worker and shared_worker sessions before resume, exactly as done for page and iframe sessions, keeping the existing fail-closed behavior if Fetch.enable fails 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 that Fetch pauses 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.