Own your browser.
One stealth headless Chromium in a container, exposing Chrome DevTools Protocol on port 9222 with the two things raw CDP never had: authentication and a trust boundary you can read. Connect from Playwright, Puppeteer, an MCP client, or any agent that wants a real browser without bundling one.
What sets it apart · Who can do what · Guarantees · Architecture · Connect · Proxies · Sessions · MCP · Configuration · Releases
docker run --rm -p 127.0.0.1:9222:9222 --shm-size=512m ghcr.io/askalf/browser-bridge:latestimport { chromium } from 'playwright';
const browser = await chromium.connectOverCDP('http://localhost:9222');That is the whole integration. Everything below is about what you are trusting when you do it.
Most "headless Chrome in Docker" images are a Dockerfile around a browser. browser-bridge is what you get when the CDP port is treated as production infrastructure:
- The stealth score is measured, not asserted. On every relevant push and PR, CI builds the image, drives it as an ordinary CDP client, and evaluates the bot-detection vectors that sannysoft and CreepJS probe, in-page, with no network (
stealth-score.mjs). The badge above is that live number, and the build fails if it drops below the floor. Currently 13/13. - CDP gets an auth story. Set
BRIDGE_TOKENand every request and WebSocket upgrade must present it. The compare is constant-time over a SHA-256 digest, the token is stripped before anything reaches Chromium, and failures are counted in/metrics. Off by default, and this README says so in three places rather than hiding it. - The trust boundary is fuzzed. ClusterFuzzLite runs two Jazzer.js targets weekly against the proxy's pure request guards and the user-agent picker (
fuzz/): the DNS-rebinding gate never passes a hostname,?token=never survives into the forwarded path, auth headers never leak upstream. OpenSSF Scorecard Fuzzing, Pinned-Dependencies, and Token-Permissions all score 10. - Authenticated proxies just work. Chromium discards the
user:passin--proxy-serverand expects a human to answer the407. browser-bridge stands up a loopback relay that addsProxy-Authorizationon the browser's behalf, withoutpage.authenticate(), so your CDP client keeps theFetchdomain to itself. The password never reaches the logs. - Failover is a deliberate choice, off by default.
PROXY_FALLBACK=directretries an unreachable proxy straight out of the container. It never fails over on a407or any other answer the proxy sends, because turning a wrong password into a silent change of exit address is worse than an outage. - The container has to boot, not just build. CI runs the image and waits for the post-launch marker. This exists because v0.3.0 shipped an image that built clean and crashed on start; the guard has been there since.
- The browser's own chatter stays home. GCM, component update, domain-reliability beacons, and Sync are disabled at launch, so a metered or residential proxy carries only the traffic your client asked for.
- Ninety-six unit tests, no Docker required.
npm testruns the proxy, relay, broker, MCP server, profile lock, and UA suites against fakes and stubs, in seconds.
A remote browser is a remote shell with a rendering engine. This is who sits on the path and what each party can do, in the default deployment (a private Docker network, no token). "Stores" means retained after the request.
| Party | Can do | Stores |
|---|---|---|
Anyone who can reach :9222 |
Everything CDP allows: navigate anywhere, read every page, run script, read cookies the browser holds, take screenshots. With BRIDGE_TOKEN set, nothing without the token (401); with a hostname Host and no token, nothing (403). |
Nothing on the bridge. |
The CDP proxy (cdp-proxy.mjs) |
Sees every request and WebSocket frame as bytes. Checks the token, gates the Host, rewrites Host to loopback, strips the token, and pipes. No per-message CDP parsing on the hot path. |
Counters only (authFailures, hostBlocked, connections). |
Chromium (browser user, --no-sandbox) |
Whatever a page can do inside a Chromium process. Escapes land as the unprivileged browser user inside the container; the container is the sandbox. |
The profile at BRIDGE_USER_DATA_DIR in shared mode (cookies, storage), which persists only if you mount a volume there. Isolated sessions use a fresh directory that is deleted on close. |
The auth relay (proxy-auth-relay.mjs, only with a credentialed proxy URL) |
Sees the plaintext of every HTTP request and the CONNECT targets of every HTTPS one. It is an open proxy on 127.0.0.1 inside the container. |
Nothing. Logs print http://user:***@host:port. |
| Your upstream proxy or VPN | Sees the exit traffic and, for plain HTTP, its content. | Per its own policy. |
| Sites you visit | See a Chromium that passes the 13 scored vectors, the configured proxy's exit address, and whatever your client chooses to send. | Whatever they retain. Stealth is a regression gate against a fixed battery, not a guarantee against detection. |
Health and metrics (:9224) |
Reachable only from inside the container. | Counters and uptime. |
What this does not protect against: a client you have handed the token to, a host where 127.0.0.1 is shared with untrusted processes under network_mode: host, or a site that fingerprints something outside the battery. If you need egress governance rather than a browser, that is fieldpass; browser-bridge is the substrate under it.
Every row is enforced by code in this repo, and every row has a check you can run against a running container without trusting this file. <c> is your container name.
| Guarantee | Enforced by | Verify it |
|---|---|---|
| Runs as an unprivileged user | USER browser in the Dockerfile; the process never has root inside the container |
docker exec <c> id → a system uid named browser, never uid=0 |
A hostname Host is refused without a token |
hostIsIpOrLocalhost() in cdp-proxy.mjs preserves Chromium's own anti-DNS-rebinding posture; fuzzed in fuzz/cdp_guards.fuzz.js |
curl -s -o /dev/null -w '%{http_code}' -H 'Host: browser' http://localhost:9222/json/version → 403 |
| With a token set, nothing without it | presentedToken() plus a timingSafeEqual over SHA-256 digests; token stripped before forwarding |
curl -s -o /dev/null -w '%{http_code}' http://localhost:9222/json/version → 401; add ?token=… → 200 |
| CDP WebSockets accept loopback origins only | --remote-allow-origins defaults to loopback, not * (launch.mjs) |
docker exec <c> sh -c 'cat /proc/[0-9]*/cmdline 2>/dev/null | tr "\0" " " | grep -o -- "--remote-allow-origins=[^ ]*" | head -1' |
| The browser makes no background calls of its own | --disable-background-networking --disable-component-update --disable-domain-reliability --disable-sync in COMMON_ARGS |
Same command as above, grep for --disable-background-networking |
| A proxy password never reaches stdout | Redaction in proxy-auth-relay.mjs, asserted by a test |
docker logs <c> 2>&1 | grep -c "$PROXY_PASS" → 0 |
| Failover never fires on a proxy's answer | Error-code match on unreachability only; a 407 is relayed verbatim (test/proxy-auth-relay.test.mjs asserts the 407 and oversized-head cases) |
npm test |
| Health reflects the browser, not a TCP port | /healthz checks the CDP connection and a cached deep page-load; the Docker HEALTHCHECK hits it |
docker exec <c> curl -s http://127.0.0.1:9224/healthz → {"ok":true,"connected":true,"pageCheck":"ok",…} |
| Health and metrics are not reachable from outside | Bound to 127.0.0.1 inside the container; the image EXPOSEs 9222 and 9225 only |
docker port <c> lists no 9224 |
| One profile directory, the one you configured | buildLaunchOptions() throws if --user-data-dir appears in args (launch-opts.mjs); test/launch-opts.test.mjs asserts a single profile source |
Startup log line [browser-bridge] profile: /home/browser/data; docker exec <c> ls /home/browser/data/Default |
| A killed container does not wedge its volume | clearStaleSingletonLock() removes Chromium's three singleton entries before launch (profile-lock.mjs) |
docker kill <c>, then recreate with the same volume; it starts |
| Isolated sessions cannot exhaust the host | BRIDGE_MAX_SESSIONS (default 20); acquisitions past it get 503 (session-broker.mjs) |
Open 21 connections in isolated mode; the 21st is refused |
| The image you pull is the image CI built | Keyless Sigstore provenance attested in release.yml; the bundle is attached to every release |
gh attestation verify oci://ghcr.io/askalf/browser-bridge:v0.5.1 --owner askalf |
| The image builds from the committed lockfile on a pinned base | npm ci --omit=dev; FROM node:26-slim@sha256:…; Dependabot refreshes the digest |
Read the first 30 lines of the Dockerfile |
| Stealth does not regress silently | stealth.yml fails below the floor and publishes the score to the badges branch |
Run it yourself: node stealth-score.mjs --cdp http://localhost:9222 from a clone after npm ci |
| The container boots, not just builds | Boot smoke in build.yml waits for stealth Chromium running |
build runs |
Four honest caveats. --no-sandbox is on: Chromium's setuid sandbox cannot run in an unprivileged container, so the sandbox is the container's user namespace plus the non-root user, not Chromium's own. CDP is open by default: without BRIDGE_TOKEN, anyone who can reach the port owns the browser; bind it to a private network, and never to the public internet, token or not. The relay is an open proxy on loopback: fine when the container is the trust boundary, which is the normal case, and wrong under host networking on a shared machine. --disable-component-update also stops CRLSet: a container left running for a very long time stops receiving certificate-revocation data; restart it periodically if that matters to you.
flowchart LR
C["CDP client<br/>Playwright · Puppeteer · raw WS"] -->|":9222"| P["cdp-proxy.mjs<br/>token · Host gate · origin lock"]
M["MCP client"] -->|":9225 /mcp"| S["mcp-server.mjs<br/>6 tools, CDP client of the bridge"]
S --> P
P -->|"shared"| B["Chromium<br/>127.0.0.1 · non-root · stealth"]
P -->|"isolated ?session="| K["session-broker.mjs<br/>one Chromium per connection, cap + idle reap"]
K --> B2["Chromium ×N"]
B --> R["proxy-auth-relay.mjs<br/>127.0.0.1, only with user:pass"]
R -->|"Proxy-Authorization"| U["upstream proxy / VPN"]
U --> W["the web"]
B -.->|"/healthz /metrics"| H[":9224 container-internal"]
- Launcher (
launch.mjs) starts Chromium through puppeteer-extra with the full stealth evasion set and a realistic argument set: 1920×1080 window,en-US,en, WebGL and accelerated canvas on, font hinting set,--enable-automationgone. The user-agent pool is derived at startup fromchromium --version(ua.mjs) so a UA can never claim a version the engine is not. - Proxy (
cdp-proxy.mjs) fronts Chromium's loopback debugger on0.0.0.0:9222. It is a byte pipe with three checks at the door: token,Host, and the WebSocket URL. Chromium rejects DNS names inHost, which is why remote CDP usually means digging up a container IP; the proxy presents loopback upstream soconnectOverCDP('http://browser:9222')works by service name once auth is on. - Broker (
session-broker.mjs) is opt-in. Inisolatedmode each connection gets its own Chromium process, with a concurrency cap and an idle reaper; named sessions survive reconnects. - Reaper closes idle blank tabs, pages idle past a TTL, and pages beyond a hard count, measured from last navigation so an actively reused page is never touched.
- Health (
/healthz) returns503only when the CDP connection is gone. A wedged-but-connected browser reports"pageCheck":"degraded"; a proxy in fallback reports"egress":"direct", still200, so an autoheal never turns a degraded egress into a restart loop.
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP('http://localhost:9222');
const ctx = browser.contexts()[0] ?? await browser.newContext();
const page = await ctx.newPage();
await page.goto('https://example.com');
console.log(await page.title());import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:9222' });// The bridge resolves the root path to the browser target server-side,
// so there is no /devtools/browser/<uuid> discovery round-trip.
const browser = await chromium.connectOverCDP('ws://browser:9222/?token=' + process.env.BRIDGE_TOKEN);curl -s "http://localhost:9222/json/version?token=$BRIDGE_TOKEN"
curl -s -H "X-Bridge-Token: $BRIDGE_TOKEN" http://localhost:9222/json/listThe token travels as Authorization: Bearer, X-Bridge-Token, or ?token=. With a token set you can also connect by DNS or service name; without one, set BRIDGE_ALLOW_HOSTNAMES=1 to accept hostname Host headers on an open bridge and accept the DNS-rebinding trade-off that comes with it.
curl -s http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl
# ws://localhost:9222/devtools/browser/4b3f...See the Chrome DevTools Protocol reference for the method surface.
hands fetches pages over plain HTTP; point its read_page at BROWSER_BRIDGE_URL and it gets a real Chromium for JS-heavy pages and servers that bounce non-browser user agents. deepdive launches a local Playwright Chromium by default; swap chromium.launch() for chromium.connectOverCDP(process.env.BROWSER_BRIDGE_URL) and many runs share one bridge. The bridge already ships the stealth arguments deepdive lists locally, so drop them when connected. Most MCP browser servers accept a browserURL; point it at the bridge.
services:
vpn:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
environment:
VPN_SERVICE_PROVIDER: protonvpn
OPENVPN_USER: ${VPN_USER}
OPENVPN_PASSWORD: ${VPN_PASS}
browser:
image: ghcr.io/askalf/browser-bridge:latest
network_mode: "service:vpn"
shm_size: '512m'
environment:
HTTPS_PROXY: http://localhost:8888
HTTP_PROXY: http://localhost:8888services:
browser:
image: ghcr.io/askalf/browser-bridge:latest
shm_size: '512m'
environment:
HTTPS_PROXY: http://${PROXY_USER}:${PROXY_PASS}@proxy.example.net:8080Chromium strips credentials out of --proxy-server and waits for a human to answer the 407. When credentials are present, browser-bridge starts a small relay on an ephemeral loopback port, points Chromium at it, and adds Proxy-Authorization to every forwarded request and every CONNECT. A non-200 from upstream is relayed verbatim, so a wrong password surfaces as the proxy's own 407. Per-hop headers are stripped in both directions. Credentials are percent-decoded, so a password containing @ or : survives. Authenticated https:// proxy URLs (TLS to the proxy itself) are rejected at startup rather than at first navigation.
environment:
HTTPS_PROXY: http://${PROXY_USER}:${PROXY_PASS}@proxy.example.net:8080
PROXY_FALLBACK: direct # keep browsing if the proxy dies
PROXY_CONNECT_TIMEOUT_MS: '8000' # how long before a silent tunnel counts as deadDecide this per deployment. Going direct means the same browser, carrying the same logged-in cookies, suddenly appears from a different address and ASN, which is the shape of event that trips an account security challenge. If the proxy is there for its exit address, an outage is better than a silent relocation. If it is the only route out, failing over is obviously right.
- Only unreachability counts. Connection refused, host or network unreachable, DNS failure, reset before the tunnel is up, connect timeout.
- Never an answer. A
407, a refusedCONNECT, any status the proxy sends is the proxy working and saying no. - The timeout is what fires in real life. A tunnel whose far end has vanished swallows packets rather than refusing them;
PROXY_CONNECT_TIMEOUT_MSis armed only until the TCP connect lands, so it can never truncate a long-lived tunnel. - One failure trips the breaker. Subsequent requests go direct immediately; the relay re-probes upstream after 30 s and returns to it as soon as it answers.
Degradation is reported, never gated on: /healthz shows "egress":"direct" and "degraded":true at 200, and /metrics counts proxyFallbacks.
By default every client connects to one Chromium: cheap, and a client that calls browser.close() takes the browser down for everyone. When independent clients share a bridge, set BRIDGE_SESSION_MODE=isolated and each connection gets its own stealth Chromium process:
services:
browser:
image: ghcr.io/askalf/browser-bridge:latest
environment:
BRIDGE_SESSION_MODE: isolated
BRIDGE_MAX_SESSIONS: "20"
expose: ["9222"]
shm_size: '512m'// Ephemeral: a fresh browser for this connection, disposed on disconnect.
await chromium.connectOverCDP('http://browser:9222');
// Named: reused across reconnects until it goes idle, for a long-lived logged-in session.
await puppeteer.connect({ browserWSEndpoint: 'ws://browser:9222/?session=my-login' });No client sees or closes another's targets. Every session launches through the same stealth configuration. The proxy still routes bytes; there is no per-message parsing. Named sessions are reaped after BRIDGE_SESSION_IDLE_MS (default 5 min) without a connection. Each session is a Chromium process, so size BRIDGE_MAX_SESSIONS to your RAM.
mcp-server.mjs is a thin MCP server that is itself a CDP client of the bridge, so any MCP client can drive a browser with no Puppeteer or CDP code of its own. Six tools over Streamable HTTP:
| Tool | Does |
|---|---|
browser_navigate |
Go to a URL, wait for load, report status and title |
browser_screenshot |
PNG of the viewport, or fullPage |
browser_evaluate |
Run a JS expression in the page and return the result |
browser_get_content |
The page as html or visible text |
browser_get_console |
Console and page-error messages captured this session |
browser_pdf |
Render the page to a PDF resource |
services:
browser:
image: ghcr.io/askalf/browser-bridge:latest
expose: ["9222"]
shm_size: '512m'
browser-mcp:
image: ghcr.io/askalf/browser-bridge:latest
command: ["node", "/app/mcp-server.mjs"]
environment:
BRIDGE_CDP_URL: http://browser:9222
# BRIDGE_TOKEN: ${BRIDGE_TOKEN} # required on MCP requests, presented onward to the bridge
ports: ["9225:9225"]Point a client at http://<host>:9225/mcp. Each MCP session opens one bridge connection as ?session=mcp-<id>, so with the bridge in isolated mode every MCP session has its own browser. The browser opens lazily on the first tool call and is disposed when the MCP session ends.
| Env var | Default | Effect |
|---|---|---|
BRIDGE_TOKEN |
unset | Shared secret required on every CDP request and WebSocket when set. Unset = open. |
BRIDGE_ALLOW_HOSTNAMES |
unset | Accept DNS-name Host headers without a token. Not needed with BRIDGE_TOKEN. Opt-in because Chromium's Host check doubles as DNS-rebinding protection. |
CDP_ALLOWED_ORIGIN |
loopback origins | Comma-separated Origin values allowed on CDP WebSockets (--remote-allow-origins). Playwright and Puppeteer send no Origin and need nothing here. |
HTTPS_PROXY / HTTP_PROXY |
unset | Outbound proxy for Chromium. Accepts http://user:pass@host:port. HTTPS_PROXY wins if both are set. |
PROXY_FALLBACK |
off |
direct = retry an unreachable upstream straight out of the container. Only applies to a credentialed proxy URL. Never on a 407. |
PROXY_CONNECT_TIMEOUT_MS |
8000 |
TCP connect timeout to the upstream proxy. Only used with PROXY_FALLBACK=direct. |
BRIDGE_SESSION_MODE |
shared |
shared = one browser for all clients. isolated = a browser per connection. |
BRIDGE_MAX_SESSIONS |
20 |
(isolated) Concurrent-session cap; past it, 503. |
BRIDGE_SESSION_IDLE_MS |
300000 |
(isolated) Reap a session this long after its last connection closes. |
BRIDGE_USER_DATA_DIR |
/home/browser/data |
(shared) Chromium profile directory. Mount a volume there to persist cookies and storage; stale singleton locks from a killed container are cleared at startup. Isolated mode ignores this. |
BRIDGE_HEALTH_PORT |
9224 |
Health and metrics port, bound to 127.0.0.1 inside the container. |
BRIDGE_REAP_INTERVAL_MS |
30000 |
How often the page and session reaper runs. |
BRIDGE_BLANK_TTL_MS |
120000 |
(shared) Reap about:blank tabs idle this long. |
BRIDGE_MAX_IDLE_MS |
900000 |
(shared) Reap any page with no navigation for this long. Raise it if clients hold pages open while working. |
BRIDGE_MAX_PAGES |
25 |
(shared) Hard page-count cap; the most-idle pages beyond it are reaped. |
BRIDGE_MCP_PORT |
9225 |
(mcp-server.mjs) Port the MCP endpoint listens on. |
BRIDGE_MCP_PATH |
/mcp |
(mcp-server.mjs) Request path for the MCP endpoint. |
BRIDGE_CDP_URL |
http://127.0.0.1:9222 |
(mcp-server.mjs) The bridge the MCP server connects to. |
PUPPETEER_EXECUTABLE_PATH |
/usr/bin/chromium |
Chromium binary. Rarely overridden. |
Ports: 9222 CDP (the image EXPOSEs it). 9224 health and metrics, container-internal. 9225 the optional MCP endpoint, only when mcp-server.mjs runs.
--shm-size=512m is not optional: Chromium's default 64 MB /dev/shm is too small for non-trivial pages and the symptom is a crashed tab with no useful error.
docker exec <c> curl -s http://127.0.0.1:9224/healthz
# {"ok":true,"connected":true,"pageCheck":"ok","pagesOpen":2}
docker exec <c> curl -s http://127.0.0.1:9224/metrics
# {"uptimeSec":4211,"pagesOpen":2,"pagesCreated":17,"pagesReaped":3,
# "navCount":42,"healthChecks":280,"lastReapAt":1765500000000,
# "authFailures":0,"hostBlocked":0,"cdpConnectionsTotal":5,
# "cdpConnectionsActive":1,"connected":true}/healthz returns 503 only when the CDP connection is gone. The deep check opens a throwaway context and evaluates 1+1, refreshed at most once a minute. One heartbeat log line per minute carries the same counters; pair with restart: unless-stopped for self-recovery.
- Tags.
:latesttracksmaster.:vX.Y.Zis a release;:vX.Yand:vXfollow the latest matching release. Multi-arch:linux/amd64andlinux/arm64. - Provenance. Every release is attested with keyless Sigstore (
actions/attest-build-provenance), and the bundle is attached to the GitHub release. Verify before you trust:gh attestation verify oci://ghcr.io/askalf/browser-bridge:v0.5.1 --owner askalf. An SBOM and BuildKit provenance are pushed with the image. - Pins. Base image digest-pinned;
npm cifrom the committed lockfile; every GitHub Action SHA-pinned; workflow tokens read-only by default. Dependabot refreshes all of it. - Analysis. CodeQL on every push and PR. ClusterFuzzLite weekly,
npm run fuzzlocally. OpenSSF Scorecard weekly. - Changes.
CHANGELOG.mdrecords the why as well as the what, including the bugs each release found in itself. - Disclosure. See
SECURITY.md. Please do not open a public issue for a vulnerability.
- Not a queue. One container is one browser (or, in isolated mode, one browser per connection up to the cap). For throughput, run several containers behind a queue.
- Not internet-facing. CDP was never designed for that, and a token does not change it. Private network, always.
- Not a Chrome extension host. Headless Chromium does not load extensions reliably.
- Not egress governance. It gives you a browser and tells you honestly what that browser can do. Policy over what an agent may fetch is fieldpass.
fieldpass's source is the policy/ directory of this repository. The npm package is unchanged: npm i @askalf/fieldpass (or npx -y @askalf/fieldpass scan <url>). Same bins, env vars and MCP tool names; only the repo moved. See policy/README.md.
MIT. See LICENSE.
Part of Own Your Stack: open tools for owning your AI infrastructure instead of renting it by the token. One subscription. Your box. Your terms.
- dario — own your routing
- hybrid — own your inference
- deepdive — own your research
- hands — own your computer-use
- browser-bridge — own your browser (you are here)
- redstamp — own your agent security
- truecopy — own your agent skills
- strongroom — own your agent secrets
- cordon — own your prompts
- fieldpass — own your agent browser
- amnesia — own your search
- askalf — own your operation: the AI operation that runs Sprayberry Labs
Built by Thomas Sprayberry.
{ "id": 1, "method": "Page.navigate", "params": { "url": "https://example.com" } }