Skip to content

fix(security): enforce a public-only egress policy on every browser connection - #19

Merged
abnegate merged 4 commits into
mainfrom
cursor/browser-egress-guard-6824
Oct 15, 2026
Merged

abnegate merged 4 commits into
mainfrom
cursor/browser-egress-guard-6824

Conversation

@abnegate

@abnegate abnegate commented Oct 1, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Closes the remaining SSRF in self-hosted screenshots, where a redirect or a script sends the browser to a private destination.

appwrite/appwrite#13995 validates the first-hop URL and moves the browser onto its own Docker network. appwrite/appwrite#13998 leaves screenshots open on purpose: "the browser service resolves and follows redirects on its own. Closing that means giving the browser a proxy or network policy." This PR adds that proxy.

The gap that was still open on main (0.3.5)

The guard from #18 used page.route and did its own dns.lookup in Node. That misses two things:

  1. Redirect hops. Playwright never calls a route handler for a redirect hop. I checked this against a real Chromium. A public open redirect therefore still reached anything the browser container can reach. Through the unauthenticated avatars endpoint on 0.3.5, a redirect to http://127.0.0.1:9222/json/version renders Chromium's own DevTools metadata, webSocketDebuggerUrl included. Redirects to 127.0.0.1:3000, link-local metadata and other private addresses work the same way. The browser network in #13995 does not help, because these are IP literals and loopback.
  2. DNS rebinding. The route handler resolved the hostname in Node, then Chromium resolved it again before connecting. A host that answers public first and private second got past the check, so script navigations were exposed too.

Disabling redirects alone would not fix this: a script navigation is equivalent to a redirect.

The fix: an egress guard in the browser service

src/utils/egress.ts is an HTTP/CONNECT forward proxy that runs inside the service.

  • Every connection goes through it. Each screenshot or report context gets its own guard, passed in through Playwright's per-context proxy option. A shared guard is also set at Chromium launch, as the fallback for the default context (Lighthouse, relaunches). Loopback is proxied as well (<-loopback>). Plain HTTP is forwarded; HTTPS and WebSockets arrive as CONNECT tunnels.
  • It checks the address it actually dials. The proxy resolves the host itself and refuses the connection unless every address is publicly routable. It then dials one of those vetted addresses. Chromium never resolves the destination, so there is no second lookup to rebind. Each capture pins a host to its first answer.
  • Same ranges as before, with gaps fixed. It reuses the existing ssrf.ts ranges: private, loopback, CGNAT, link-local including 169.254.169.254, reserved, documentation, multicast, ULA, and IPv4-mapped in both dotted and hex form. This PR also adds IPv4-compatible ::/96, local-use NAT64 64:ff9b:1::/48 and 192.88.99.0/24, all of which were previously treated as public.
  • WebRTC. UDP to page-chosen STUN/TURN servers bypasses any proxy, so it is limited to proxied TCP. headless-shell reads --force-webrtc-ip-handling-policy=disable_non_proxied_udp and Chrome reads --webrtc-ip-handling-policy=…; both are passed, and a test covers this on each binary.
  • It fails closed with a clear error. watchNavigations records every main-frame navigation, redirect hops and script navigations included. If the guard refused one, the request returns 403 with a body like {"error":"Navigation to 'http://169.254.169.254/latest/meta-data/' was blocked: the destination is not publicly routable."}, and no image or report is returned. Appwrite already passes any status of 400 or above through as AVATAR_REMOTE_URL_FAILED with that message. A private first-hop URL is still rejected up front with 400.
  • A refused iframe or subresource is left out of the page and the capture continues, so a stray localhost script tag doesn't break a legitimate site. The internal server never receives a request either way.

page.route now only applies the caller's headers to the target origin. Because the proxy pins connections, forwarded headers, Host included, can no longer steer a connection to an internal address.

One Bun quirk: Bun's node:http server stalls on the second absolute-form request of a kept-alive connection. The guard therefore sends Connection: close on plain-HTTP responses. HTTPS tunnels are unaffected.

Test Plan

  • tests/integration/egress.test.ts (new, added to CI as bun test:integration using the runner's Chrome) drives a real Chromium through the guard. A test resolver maps attacker.test to a local "public" server, and every test asserts the internal server got zero requests.
    • Redirects: a public open redirect to 127.0.0.1, localhost, https://127.0.0.1, [::1], [::ffff:127.0.0.1], 169.254.169.254 and 10.0.0.1, plus a redirect chain. Each returns BlockedNavigationError.
    • JS navigation: location.href during load, location.replace after load to metadata, and a script navigation that goes through a public redirect. Each is blocked.
    • Everything else: iframes, <img>/<script>/fetch, WebSockets and popups never reach the private target. WebRTC STUN sends no UDP.
    • DNS rebinding: a host that answers public, then private, is resolved once and dialled at the checked address.
    • Public sites: https://example.com and plain-HTTP http://example.com still load.
    • Passes on Chrome 148 and on headless-shell 143.0.7445.3, the binary in the Docker image. I extracted it from chromedp/headless-shell. As a negative control, removing the guard makes the redirect and JS tests load the internal page.
  • tests/e2e/ssrf.test.ts (new) runs against the service, as in CI.
    • Covers httpbin's open redirect to loopback and to metadata, an httpbin page that navigates by script, and script plus redirect. Each returns 403.
    • https://example.com still returns a PNG, and a private first hop still returns 400.
    • Against main, the redirect and JS cases return 200 PNGs.
    • If httpbin is unreachable, these four checks are skipped with a warning; the integration suite does not depend on it.
  • The existing unit and e2e suites (screenshots, Lighthouse reports, headers) pass. Recovery also works: after Chromium is killed, the relaunched browser is still behind the guard.
  • Latency is unchanged for normal use: https://appwrite.io screenshots take about 0.4–0.7 s.

Follow-up

  • Release this as 0.3.6 and bump appwrite/browser:0.3.4 → 0.3.6 in appwrite/appwrite's docker-compose.yml, plus the installer and compose templates. Appwrite needs no code change; blocked navigations already surface as AVATAR_REMOTE_URL_FAILED.

Related PRs and Issues

Open in Web Open in Cursor 

cursoragent and others added 3 commits October 1, 2026 20:40
Callers that connect need the vetted addresses so they can dial one of
them instead of resolving again. Also refuse IPv4-compatible IPv6
(::/96), local-use NAT64 (64:ff9b:1::/48) and the 6to4 relay anycast
range, which were treated as public.

Co-authored-by: Jake Barnby <[email protected]>
Request interception only checked the URL a route handler saw: Playwright
never calls the handler for redirect hops, and Chromium resolved hosts
again on its own after the check. A public open redirect therefore still
reached loopback, link-local metadata and other private addresses, and a
rebound DNS answer could do the same for script navigations.

Run a forward proxy inside the service and give every capture context its
own instance, with a shared one set at launch as the fallback for the
default context. The proxy resolves each host, refuses any destination
that is not publicly routable, and dials exactly the address it checked.
Loopback is proxied too, and WebRTC is limited to proxied TCP.

Captures watch their main-frame navigations, including redirect hops and
script-started ones, and fail with 403 when the guard refused one rather
than returning whatever Chromium rendered.

Co-authored-by: Jake Barnby <[email protected]>
…locked

Integration tests drive a real Chromium through the guard against local
servers: open redirects and redirect chains to loopback, IPv6 and
link-local targets, script navigations during and after load, iframes,
subresources, WebSockets, popups, WebRTC STUN, DNS rebinding, and public
sites over HTTPS and plain HTTP. They assert the internal server never
receives a request.

E2E tests replay the screenshot request through a public open redirect and
a public page that navigates by script, against the running service.

Co-authored-by: Jake Barnby <[email protected]>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Docker Image Stats

Metric Value
Image Size 494MB
Memory Usage 152.5MiB
Cold Start Time 1.02s
Screenshot Time .63s

Screenshot benchmark: Average of 3 runs on https://appwrite.io

Main added ALLOWED_HOSTS (#21) on top of the per-request route check
this branch replaces with the egress proxy. Keep the proxy and make its
default resolver honour ALLOWED_HOSTS via resolveAllowedHost, so listed
internal hosts stay reachable while every other private address is
still refused on redirects and script navigations. Drop main's README
note that redirect hops are unchecked, since the proxy now checks them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@abnegate
abnegate marked this pull request as ready for review October 15, 2026 03:53
@hansi-codes

hansi-codes Bot commented Oct 15, 2026

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

Adds a per-capture egress proxy that checks and pins destination addresses for browser traffic, and reports blocked main-frame navigations as 403 responses. It also extends SSRF range checks, documents the network policy, and adds unit, integration, and end-to-end coverage.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 11
File Change
.github/workflows/test.yml Adds the egress integration suite to CI.
README.md Documents the proxy-enforced network access policy and its response behavior.
package.json Adds the integration-test script.
src/config/browser.ts Adds a shared fallback egress proxy and WebRTC restrictions to Chromium launch.
src/routes/reports.ts Routes report contexts through per-request guards and maps blocked navigations to 403.
src/routes/screenshots.ts Routes screenshot contexts through per-request guards and maps blocked navigations to 403.
src/utils/egress.ts Implements the address-checking forward proxy and main-frame navigation tracking.
src/utils/ssrf.ts Returns checked host resolutions and expands blocked address ranges.
tests/e2e/ssrf.test.ts Adds service-level tests for blocked navigations and valid public captures.
tests/integration/egress.test.ts Exercises the proxy against real browser redirects, subresources, WebRTC, and rebinding cases.
tests/unit/ssrf.test.ts Adds unit coverage for expanded IP ranges and resolved allowed hosts.
🔇 Filtered out · 1

Findings Hansi considered but did not post.

Finding Why
Hex-form IPv4-mapped IPv6 addresses still pass the egress check Verifier: The IPv4 rules in node:net BlockList also match IPv4-mapped IPv6 addresses, including hex form. The existing 127.0.0.0/8 entry therefore blocks ::ffff:7f00:1 at ssrf.ts:65 without needing the dotted-form branch or a separate IPv6 subnet. Verified using Bun 1.3.2 (the Dockerfile's runtime): bun test tests/unit/ssrf.test.ts -t isPublicIp passes all three tests, including the assertion at line 30. Direct calls return isPublicIp('::ffff:7f00:1') === false and resolveHost('[::ffff:7f00:1]') === {status:'private'}. Node 22's BlockList also matches this address.

Reviewed 8a1ba2c · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Tier S · Looks good to merge. Summary

@abnegate
abnegate merged commit 3856d53 into main Oct 15, 2026
4 checks passed
@abnegate
abnegate deleted the cursor/browser-egress-guard-6824 branch October 15, 2026 04:08
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