Skip to content

Playwright browser tools over shared HTTP

The six playwright_* tools drive a headless Chromium through a managed Microsoft Playwright MCP child. Over stdio one process serves one user on their own machine. Over the shared HTTP transport the same tools serve every authenticated tenant of one gateway, and a browser is not an HTTP request: its subrequests, redirects, WebSockets and downloads never pass the gateway’s egress policy, and each one is a process worth hundreds of megabytes. This page is the deployment shape under which the HTTP opt-in is sound, what the gateway enforces itself, and what it cannot.

Issue #34 phase 2 landed these controls; the two gates it was blocked on run in CI as playwright-containment (network) and playwright_http_tests::opens_past_the_cap_are_refused_before_spawn_with_flat_memory (load).

PLAYWRIGHT_ENABLED=true
PLAYWRIGHT_MCP_CLI=/opt/playwright-mcp/node_modules/@playwright/mcp/cli.js
PLAYWRIGHT_HTTP_ENABLED=true
PLAYWRIGHT_CONTAINMENT=proxy
PLAYWRIGHT_MCP_PROXY_SERVER=http://127.0.0.1:3128

PLAYWRIGHT_HTTP_ENABLED refuses to turn on without PLAYWRIGHT_CONTAINMENT. The startup log says browser tools enabled over HTTP with the containment in force, or browser tools disabled over HTTP: … with the setting to fix; the health banner carries the category (network containment not configured, runtime not installed). proxy is verified to the extent that the proxy URL is well-formed and the flags reach the child. PLAYWRIGHT_CONTAINMENT=network-policy is refused, with the reason below.

With the Helm chart, playwright.enabled=true does all of this and adds the proxy sidecar (deploy/helm/mcp-devtools/README.md).

One accepted shape, proven by the CI stack in deploy/compose/playwright-containment and scripts/ci/playwright-containment-smoke.sh:

Forward proxy (proxy). The browser is started with --proxy-server <url> --proxy-bypass "<-loopback>": every request, including localhost and 127.0.0.1, goes to the proxy, so the browser cannot reach the gateway’s own port, its admin API, or anything else on the host beside it. The proxy holds the allowlist. The shipped squid policy (deploy/compose/playwright-containment/squid.conf, and the chart’s mcp.playwright.squid template) refuses the private, loopback, link-local and carrier ranges by resolved address before it consults the allowlist, so a name that resolves into 10/8 or to 169.254.169.254 is refused whatever it is called, then allows the operator’s dstdomain list, then refuses everything else. Run the proxy on the pod’s loopback (the chart’s sidecar) or on a network only the gateway can reach; the proxy authenticates by source, never by a credential on the browser’s command line (the gateway refuses a proxy URL with userinfo).

--proxy-server carries HTTP(S) and WebSocket traffic, not WebRTC’s UDP. With proxy containment the gateway therefore launches each browser with a @playwright/mcp configuration (launch-config.json in the session’s output directory) that sets Chromium’s WebRTC IP handling policy to disable_non_proxied_udp, so WebRTC can use only what the proxy carries.

Both the loopback bypass and the WebRTC policy are Chromium switches. Firefox and WebKit ignore them: behind the same proxy they would still reach the pod’s loopback and send WebRTC UDP directly. With a proxy set the gateway therefore accepts only a Chromium-based PLAYWRIGHT_BROWSER (chromium, chrome, msedge, chromium-headless-shell); the HTTP opt-in refuses to turn on with another engine, and a launch refuses it too. Keep networkPolicy.enabled=true as a further layer for anything else the pod might send.

Why a network policy alone is not accepted. Kubernetes documents that NetworkPolicy cannot block loopback access or traffic from a pod to itself. The browser runs inside the gateway’s pod, so under a network policy alone a page could still reach every listener on the pod’s loopback: the gateway’s own port and admin routes, and any sidecar. The gateway therefore refuses PLAYWRIGHT_CONTAINMENT=network-policy and the chart refuses playwright.containment=network-policy. A network policy is still worth enabling under the proxy, to bound what the pod as a whole can reach, but it is not the browser’s boundary. A future shape that runs browsers in a separate pod could make it one; that needs its own proof.

Defence in depth: the CI stack also puts the gateway on an internal network whose only other member is the proxy, so a browser that somehow ignored the proxy would reach nothing. Do the same where the platform allows it.

What --allowed-origins is not. @playwright/mcp has an --allowed-origins flag. Upstream documents it as a convenience filter on navigation, not a security boundary: it does not cover subresources, redirects the server issues, WebSockets, or anything the page does after it loaded. The gateway does not pass it and nothing here relies on it.

What the proof asserts, for the record: with the probe page opened through the proxy, the internal stand-in service logged no request (by name, by address, through a redirect from the allowed site, over a WebSocket, as an image subresource), the proxy’s log shows TCP_DENIED for every private, loopback, link-local and metadata target and the WebSocket’s CONNECT, the allowed site’s requests passed, and the page reports every probe attempted.

A browser is a Chromium process; a tenant opening pages in a loop is a denial of service on the host, not a hygiene problem. The gateway enforces:

  • a global cap (PLAYWRIGHT_MAX_SESSIONS) and a per-principal cap (PLAYWRIGHT_MAX_SESSIONS_PER_OWNER), admitted before a process is spawned: a reservation counts against both caps until the launch commits or fails, so concurrent opens cannot overshoot, and a refused open creates nothing. The refusal tells the model to close a session it owns;
  • a short idle limit over HTTP (PLAYWRIGHT_HTTP_IDLE_TTL_SECS, default 60 s) measured on the handle’s own clock, never on the transport session;
  • a per-child memory ceiling (PLAYWRIGHT_CHILD_MEMORY_MAX_BYTES): on Linux the child goes into its own cgroup v2 leaf with memory.max and memory.oom.group, so the kernel kills the browser tree and nothing else. The gateway needs a delegated, writable cgroup subtree for that: the packaged systemd unit sets Delegate=memory; a rootless user session has one; a container needs a private, writable cgroup namespace. Where it cannot write one, it logs once (browser memory ceiling not applied: …) and the container or sidecar limit is the ceiling instead. In that case the kernel’s OOM killer selects the largest process in the container, which is the browser, not the gateway.

Either way a browser the kernel kills surfaces as a dropped handle: the next call on it reports the session closed, the slot is freed, and the gateway keeps serving (playwright_http_tests::a_browser_killed_outside_the_gateway_is_a_dropped_handle, playwright_bridge_tests::an_externally_killed_runtime_is_reported_closed).

Size the container: resources.limits.memory must hold the gateway plus PLAYWRIGHT_MAX_SESSIONS browsers at the ceiling, and /tmp (the in-memory profile and screenshots) and /dev/shm (Chromium) need room; the chart gives the gateway 4 GiB and an 8 GiB /tmp by default when the tools are on. The load test in CI opens from six principals at once past a cap of four and asserts the refusals, four spawns exactly, and a flat resident set for the gateway.

Ownership, revocation and the single replica

Section titled “Ownership, revocation and the single replica”

A handle is bound to the bearer subject the transport validated, on every call, whether or not the call entered a policy scope (a gateway without an audit journal enters none). Another subject’s call gets the refusal an unknown handle gets. When the revocation list changes, the browsers of every revoked subject close with its MCP sessions, and a launch in flight for a revoked subject is refused when it tries to commit.

A browser lives in the process that opened it. The gateway therefore runs as one replica, the same constraint the per-principal rate limiter documents: a second replica would hold handles the first cannot see. The chart enforces one replica per role. Affinity on the handle id or a separate browser-runtime service is future work, not configuration.

playwright_click and playwright_fill act on a third party’s site on the caller’s behalf. Under a policy file they are act_on_page writes on a page resource, playwright_open is open_page scoped to the host being opened, the readers are read_page, and playwright_close is close_page. A require_approval rule holds a click or fill at admission like every other write-class tool, and the browser never sees it until a second person approves:

schema_version: 2
version: 1
default: deny
rules:
- id: browse-intranet-docs
effect: allow
subjects: {groups: [Developers]}
match:
vendor: playwright
tool_name: [playwright_open]
request_risk: write
resource_type: page
resource_id: ["docs.intranet.example"]
- id: read-and-close
effect: allow
subjects: {groups: [Developers]}
match: {vendor: playwright, tool_name: [playwright_snapshot, playwright_screenshot, playwright_close], request_risk: [read, write]}
- id: acting-needs-approval
effect: require_approval
subjects: {groups: [Developers]}
match: {vendor: playwright, tool_name: [playwright_click, playwright_fill], request_risk: write}

The host scope on playwright_open is a policy control on where a model may point a browser; it is not network containment, which is the proxy’s job above.

docker build --target playwright --build-arg EXTRA_FEATURES=playwright builds the second image variant: Node 22, @playwright/mcp@0.0.83 and Chromium with its libraries, the binary built with the playwright feature, non-root, read-only root filesystem with /tmp mounted. It is not distroless. Chromium’s own sandbox needs user namespaces the default seccomp profile does not grant, so the image sets PLAYWRIGHT_NO_SANDBOX=true; the container is the sandbox. To restore it, run with a seccomp profile that allows unprivileged user namespaces and set PLAYWRIGHT_NO_SANDBOX=false. CI proves the browser starts in the container over stdio (scripts/ci/image-playwright-smoke.sh).

Outside a container, scripts/install-playwright-runtime.sh installs the same pinned package and browser for the gateway to spawn. The gateway never downloads anything itself; when PLAYWRIGHT_ENABLED=true and the runtime is not where the settings say, the tools are withdrawn and the startup log names the missing path and the script.