diff --git a/backend/remotebrowser/pdf.go b/backend/remotebrowser/pdf.go index acd743c..c20c036 100644 --- a/backend/remotebrowser/pdf.go +++ b/backend/remotebrowser/pdf.go @@ -13,7 +13,7 @@ import ( "github.com/go-rod/rod/lib/proto" ) -// WipeBrowserCache removes the auto-downloaded Chromium directory. +// WipeBrowserCache removes the automatically downloaded Chromium directory. // The next call to RenderHTMLToPDF will trigger a fresh download. func WipeBrowserCache() error { dir, err := resolveBrowserRootDir() @@ -23,8 +23,32 @@ func WipeBrowserCache() error { return os.RemoveAll(dir) } +// applyGuardFlags points the launcher at the egress guard and removes the +// browser's own ways to leave the box around it: it routes all requests through +// the proxy, removes the implicit loopback and link local proxy bypass (without +// which 127.0.0.1 and 169.254.169.254 are reached directly), blocks the WebRTC +// and QUIC UDP paths, and disables the browser's background network calls. It is +// the single definition used by both the renderer and its tests so the guard +// configuration cannot silently drift out of the real render path. +func applyGuardFlags(l *launcher.Launcher, proxyAddr string) *launcher.Launcher { + return l. + Set("proxy-server", proxyAddr). + Set("proxy-bypass-list", "<-loopback>"). + Set("disable-background-networking"). + Set("disable-component-update"). + Set("disable-domain-reliability"). + Set("disable-sync"). + Set("disable-client-side-phishing-detection"). + Set("safebrowsing-disable-auto-update"). + Set("no-pings"). + Set("no-first-run"). + Set("no-default-browser-check"). + Set("force-webrtc-ip-handling-policy", "disable_non_proxied_udp"). + Set("disable-quic") +} + // RenderHTMLToPDF renders an HTML string to PDF bytes using a headless Chromium instance. -// If execPath is empty the browser binary is auto-resolved using the same path as the runner. +// If execPath is empty the browser binary is resolved automatically using the same path as the runner. func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) ([]byte, error) { rootDir, err := resolveBrowserRootDir() if err != nil { @@ -36,6 +60,14 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) ( _ = os.MkdirAll(filepath.Join(rootDir, "config"), 0755) _ = os.MkdirAll(filepath.Join(rootDir, "cache"), 0755) + // force browser egress through the loopback guard so an embedded report + // resource cannot be used for request forgery (see guardedProxy). + proxy, err := startGuardedProxy() + if err != nil { + return nil, fmt.Errorf("reportpdf: egress guard: %w", err) + } + defer proxy.close() + l := launcher.New(). Headless(true). Set("disable-crash-reporter"). @@ -44,6 +76,7 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) ( "XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"), "XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"), )...) + applyGuardFlags(l, proxy.addr()) if execPath != "" { l = l.Bin(execPath) @@ -61,6 +94,10 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) ( u, err := l.Launch() if err != nil { + // Kill does nothing when no process started; Cleanup is skipped here because + // on an early launch failure it blocks forever waiting on the exit channel. + // Kill still removes a process that did start. + l.Kill() return nil, fmt.Errorf("reportpdf: browser launch failed: %w", err) } defer func() { l.Kill(); l.Cleanup() }() @@ -92,7 +129,7 @@ func RenderHTMLToPDF(ctx context.Context, htmlContent string, execPath string) ( return nil, fmt.Errorf("reportpdf: set content failed: %w", err) } - // non-fatal: lets inline resources settle before printing + // not fatal: lets inline resources settle before printing _ = page.WaitIdle(3 * time.Second) a4Width := 8.27 diff --git a/backend/remotebrowser/pdf_flags_test.go b/backend/remotebrowser/pdf_flags_test.go new file mode 100644 index 0000000..4f0dd0f --- /dev/null +++ b/backend/remotebrowser/pdf_flags_test.go @@ -0,0 +1,177 @@ +package remotebrowser + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" +) + +// TestRenderHTMLToPDFRoutesThroughGuard drives the real production entrypoint and +// asserts that a report embedding a loopback resource cannot reach it. This is the +// only test that exercises RenderHTMLToPDF itself, so it catches removal of the +// guard wiring from the real render path (not just flag drift). Skips without a +// browser. The produced PDF is the positive control that the render actually ran. +func TestRenderHTMLToPDFRoutesThroughGuard(t *testing.T) { + bin := "" + for _, cand := range []string{"/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"} { + if _, err := os.Stat(cand); err == nil { + bin = cand + break + } + } + if bin == "" { + t.Skip("no chrome/chromium binary available") + } + + var hits int + var mu sync.Mutex + victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + hits++ + mu.Unlock() + })) + defer victim.Close() + + html := `
Report
`)
+
+ deadline := time.Now().Add(8 * time.Second)
+ for func() int { mu.Lock(); defer mu.Unlock(); return hits }() == 0 && time.Now().Before(deadline) {
+ time.Sleep(100 * time.Millisecond)
+ }
+ pg.Close()
+
+ mu.Lock()
+ defer mu.Unlock()
+ if hits == 0 {
+ t.Fatalf("normal http resource did not load with the production flags (disable-quic may have broken fetching)")
+ }
+}
diff --git a/backend/remotebrowser/ssrfproxy.go b/backend/remotebrowser/ssrfproxy.go
new file mode 100644
index 0000000..bc46f84
--- /dev/null
+++ b/backend/remotebrowser/ssrfproxy.go
@@ -0,0 +1,356 @@
+package remotebrowser
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "sync/atomic"
+ "syscall"
+ "time"
+)
+
+// The report PDF renderer runs a headless browser over operator authored report
+// templates that may embed arbitrary external resources (images, scripts, styles).
+// Those resources are fetched by the browser, so without a guard an injected or
+// operator supplied URL pointing at an internal address turns the render into a
+// server side request forgery against loopback, link local metadata, or private
+// networks.
+//
+// guardedProxy is a small forward proxy the report browser is pointed at. It
+// permits arbitrary public destinations but refuses any request whose target
+// resolves to an internal address. It resolves the host and connects to that
+// exact resolved address in one step, so a DNS record that flips to an internal
+// address between resolution and connect (rebinding) cannot move the connection
+// onto an internal host. It fails closed: any resolution or dial problem blocks
+// the request rather than letting it through.
+//
+// The browser must also be started with proxy bypass for loopback removed, or it
+// connects to loopback and link local metadata directly, skipping this guard.
+type guardedProxy struct {
+ ln net.Listener
+ srv *http.Server
+ transport *http.Transport
+ blocked atomic.Int64 // requests refused by policy (observability and tests)
+}
+
+// blockedCIDRs are ranges that must never be reached from the report browser.
+// This is an explicit deny list layered on top of the IsGlobalUnicast allowlist
+// in isInternalIP, covering reserved and special use ranges that are still
+// classified as global unicast (RFC1918 and the reserved v4/v6 blocks below).
+var blockedCIDRs = parseCIDRs(
+ // IPv4
+ "0.0.0.0/8", // this host on this network
+ "10.0.0.0/8", // private
+ "100.64.0.0/10", // carrier grade NAT (some metadata services)
+ "127.0.0.0/8", // loopback
+ "169.254.0.0/16", // link local incl 169.254.169.254 metadata
+ "172.16.0.0/12", // private
+ "192.0.0.0/24", // IETF protocol assignments incl 192.0.0.170 NAT64 discovery
+ "192.168.0.0/16", // private
+ "198.18.0.0/15", // benchmarking
+ "240.0.0.0/4", // reserved
+ "255.255.255.255/32", // limited broadcast
+ "192.88.99.0/24", // 6to4 relay anycast
+ // IPv6
+ "::1/128", // loopback
+ "::/128", // unspecified
+ "64:ff9b::/96", // NAT64
+ "2001::/32", // Teredo (a v4 in v6 transition range, unused for content)
+ "fc00::/7", // unique local
+ "fe80::/10", // link local
+ "fec0::/10", // deprecated site local
+)
+
+func parseCIDRs(cidrs ...string) []*net.IPNet {
+ out := make([]*net.IPNet, 0, len(cidrs))
+ for _, c := range cidrs {
+ _, n, err := net.ParseCIDR(c)
+ if err == nil {
+ out = append(out, n)
+ }
+ }
+ return out
+}
+
+// isInternalIP reports whether an address must never be reached from the report
+// browser. It is deny by default: only global unicast public addresses are
+// allowed. Unknown or unparsable addresses are treated as internal so the caller
+// fails closed. IPv6 forms that embed an IPv4 address (mapped, 6to4, NAT64, and
+// the deprecated v4 compatible form) are unwrapped so an internal v4 target
+// cannot be smuggled inside a v6 address.
+func isInternalIP(ip net.IP) bool {
+ if ip == nil {
+ return true
+ }
+ if v4 := ip.To4(); v4 != nil {
+ ip = v4
+ }
+ if len(ip) == net.IPv6len {
+ if embedded := embeddedV4(ip); embedded != nil && isInternalIP(embedded) {
+ return true
+ }
+ }
+ // deny anything that is not a global unicast address: loopback, link local,
+ // unspecified, multicast, and broadcast all fall here.
+ if !ip.IsGlobalUnicast() {
+ return true
+ }
+ for _, n := range blockedCIDRs {
+ if n.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// embeddedV4 returns the IPv4 address wrapped inside a v6 6to4, NAT64, or v4
+// compatible address, or nil if none is embedded. IPv4 mapped addresses are
+// already handled by To4 in the caller.
+func embeddedV4(ip net.IP) net.IP {
+ if len(ip) != net.IPv6len {
+ return nil
+ }
+ // 6to4 2002:AABB:CCDD::/16
+ if ip[0] == 0x20 && ip[1] == 0x02 {
+ return net.IPv4(ip[2], ip[3], ip[4], ip[5])
+ }
+ // NAT64 64:ff9b::/96
+ if ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b {
+ return net.IPv4(ip[12], ip[13], ip[14], ip[15])
+ }
+ // deprecated IPv4 compatible ::a.b.c.d (first 12 bytes zero, not the
+ // unspecified or loopback addresses)
+ for i := 0; i < 12; i++ {
+ if ip[i] != 0 {
+ return nil
+ }
+ }
+ if ip[12] == 0 && ip[13] == 0 && ip[14] == 0 && (ip[15] == 0 || ip[15] == 1) {
+ return nil
+ }
+ return net.IPv4(ip[12], ip[13], ip[14], ip[15])
+}
+
+// dialGuarded resolves the host in addr, selects a public address, and connects
+// to that exact address. If no resolved address is public it returns an error and
+// no connection is made.
+func dialGuarded(ctx context.Context, network, addr string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ // malformed target, not a policy decision: fail closed but do not label it a block.
+ return nil, &net.OpError{Op: "dial", Err: errUnresolvable}
+ }
+ var candidates []net.IP
+ if literal := net.ParseIP(host); literal != nil {
+ candidates = []net.IP{literal}
+ } else {
+ resolver := &net.Resolver{}
+ ips, resErr := resolver.LookupIP(ctx, "ip", host)
+ if resErr != nil {
+ // could not resolve, not a policy decision: fail closed, do not count as a block.
+ return nil, &net.OpError{Op: "dial", Err: errUnresolvable}
+ }
+ candidates = ips
+ }
+ for _, ip := range candidates {
+ if isInternalIP(ip) {
+ continue
+ }
+ d := net.Dialer{
+ Timeout: 8 * time.Second,
+ // recheck the concrete address at connect time as a second layer.
+ Control: func(_, address string, _ syscall.RawConn) error {
+ h, _, _ := net.SplitHostPort(address)
+ if isInternalIP(net.ParseIP(h)) {
+ return errBlocked
+ }
+ return nil
+ },
+ }
+ return d.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
+ }
+ return nil, &net.OpError{Op: "dial", Err: errBlocked}
+}
+
+// errBlocked marks a destination refused by policy (its address is internal).
+// errUnresolvable marks a target that could not be parsed or resolved: also fails
+// closed, but is reported as an upstream error rather than a policy block so the
+// blocked counter and the 403 response mean only genuine internal refusals.
+var (
+ errBlocked = blockedError("destination blocked by report render policy")
+ errUnresolvable = blockedError("destination could not be resolved")
+)
+
+type blockedError string
+
+func (e blockedError) Error() string { return string(e) }
+
+// startGuardedProxy starts the proxy on an ephemeral loopback port.
+func startGuardedProxy() (*guardedProxy, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ p := &guardedProxy{
+ ln: ln,
+ transport: &http.Transport{
+ DialContext: dialGuarded,
+ ForceAttemptHTTP2: false,
+ MaxIdleConns: 32,
+ IdleConnTimeout: 20 * time.Second,
+ TLSHandshakeTimeout: 8 * time.Second,
+ ResponseHeaderTimeout: 15 * time.Second,
+ },
+ }
+ p.srv = &http.Server{
+ Handler: p,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ go func() { _ = p.srv.Serve(ln) }()
+ return p, nil
+}
+
+// addr is the host:port the browser must be pointed at.
+func (p *guardedProxy) addr() string { return p.ln.Addr().String() }
+
+func (p *guardedProxy) close() {
+ _ = p.srv.Close()
+ p.transport.CloseIdleConnections()
+}
+
+// blockedRequests is the number of requests refused by policy so far.
+func (p *guardedProxy) blockedRequests() int64 { return p.blocked.Load() }
+
+// hopByHopHeaders are removed before forwarding in either direction per RFC 7230.
+var hopByHopHeaders = []string{
+ "Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
+ "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
+}
+
+func removeHopByHop(h http.Header) {
+ for _, k := range hopByHopHeaders {
+ h.Del(k)
+ }
+}
+
+func (p *guardedProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodConnect {
+ p.handleConnect(w, r)
+ return
+ }
+ if !r.URL.IsAbs() {
+ http.Error(w, "proxy requires absolute URI", http.StatusBadRequest)
+ return
+ }
+ out := r.Clone(r.Context())
+ out.RequestURI = ""
+ removeHopByHop(out.Header)
+ resp, err := p.transport.RoundTrip(out)
+ if err != nil {
+ if errors.Is(err, errBlocked) {
+ p.blocked.Add(1)
+ http.Error(w, "blocked by policy", http.StatusForbidden)
+ } else {
+ http.Error(w, "upstream error", http.StatusBadGateway)
+ }
+ return
+ }
+ defer resp.Body.Close()
+ removeHopByHop(resp.Header)
+ for k, vs := range resp.Header {
+ for _, v := range vs {
+ w.Header().Add(k, v)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, resp.Body) // stream, never buffer whole bodies
+}
+
+// tunnelIdleTimeout bounds how long a CONNECT tunnel may sit with no data before
+// it is torn down, so a slow or idle peer cannot hold a connection and goroutine
+// indefinitely.
+const tunnelIdleTimeout = 30 * time.Second
+
+// handleConnect gates and tunnels a CONNECT (used for https). The target host is
+// resolved and pinned by dialGuarded; the tunnel then streams opaque bytes.
+func (p *guardedProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
+ upstream, err := dialGuarded(r.Context(), "tcp", r.Host)
+ if err != nil {
+ if errors.Is(err, errBlocked) {
+ p.blocked.Add(1)
+ http.Error(w, "blocked by policy", http.StatusForbidden)
+ } else {
+ http.Error(w, "upstream error", http.StatusBadGateway)
+ }
+ return
+ }
+ hij, ok := w.(http.Hijacker)
+ if !ok {
+ _ = upstream.Close()
+ http.Error(w, "no hijack", http.StatusInternalServerError)
+ return
+ }
+ client, _, err := hij.Hijack()
+ if err != nil {
+ _ = upstream.Close()
+ return
+ }
+ _, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
+ tunnelPipe(client, upstream)
+}
+
+// tunnelPipe streams bytes both ways between a CONNECT client and its upstream.
+// It tears the tunnel down only after the whole tunnel has been idle (no data in
+// either direction) for tunnelIdleTimeout, so a legitimate one directional
+// transfer such as a large download is never killed while it is still
+// progressing, while an idle tunnel cannot hold a connection open forever.
+func tunnelPipe(client, upstream net.Conn) {
+ var lastActivity atomic.Int64
+ lastActivity.Store(time.Now().UnixNano())
+ stop := make(chan struct{})
+
+ go func() {
+ ticker := time.NewTicker(tunnelIdleTimeout / 3)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-ticker.C:
+ if time.Since(time.Unix(0, lastActivity.Load())) >= tunnelIdleTimeout {
+ _ = client.Close()
+ _ = upstream.Close()
+ return
+ }
+ }
+ }
+ }()
+
+ pipe := func(dst, src net.Conn) {
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := src.Read(buf)
+ if n > 0 {
+ lastActivity.Store(time.Now().UnixNano())
+ if _, werr := dst.Write(buf[:n]); werr != nil {
+ return
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+ }
+
+ go func() {
+ pipe(upstream, client)
+ _ = client.Close()
+ _ = upstream.Close()
+ }()
+ pipe(client, upstream)
+ _ = client.Close()
+ _ = upstream.Close()
+ close(stop)
+}
diff --git a/backend/remotebrowser/ssrfproxy_test.go b/backend/remotebrowser/ssrfproxy_test.go
new file mode 100644
index 0000000..69dc871
--- /dev/null
+++ b/backend/remotebrowser/ssrfproxy_test.go
@@ -0,0 +1,276 @@
+package remotebrowser
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/go-rod/rod"
+ "github.com/go-rod/rod/lib/launcher"
+)
+
+func TestIsInternalIP(t *testing.T) {
+ cases := map[string]bool{
+ "127.0.0.1": true, "127.5.5.5": true, "::1": true,
+ "10.0.0.5": true, "172.16.9.9": true, "172.31.1.1": true, "192.168.1.1": true,
+ "169.254.169.254": true, "169.254.1.1": true,
+ "100.100.100.200": true, // carrier grade NAT (some metadata services)
+ "0.0.0.0": true, "0.1.2.3": true, // this network
+ "192.0.0.170": true, // NAT64 discovery
+ "198.18.0.1": true, // benchmarking
+ "240.0.0.1": true, // reserved
+ "255.255.255.255": true, // broadcast
+ "::": true,
+ "fc00::1": true, "fd12:3456::1": true, "fe80::1": true,
+ "::ffff:10.0.0.1": true, "::ffff:127.0.0.1": true, // v4 mapped
+ "64:ff9b::7f00:1": true, // NAT64 wrapping 127.0.0.1
+ "2002:7f00:1::": true, // 6to4 wrapping 127.0.0.1
+ "::a00:1": true, // v4 compatible wrapping 10.0.0.1
+ "2001::1": true, // Teredo range
+ "224.0.0.1": true, // multicast
+ "ff02::1": true, // v6 multicast
+ "8.8.8.8": false, "1.1.1.1": false, "203.0.113.7": false, "2606:4700::1111": false,
+ }
+ for ipStr, want := range cases {
+ if got := isInternalIP(net.ParseIP(ipStr)); got != want {
+ t.Errorf("isInternalIP(%s)=%v want %v", ipStr, got, want)
+ }
+ }
+ if !isInternalIP(nil) {
+ t.Errorf("nil IP must be treated as internal (fail closed)")
+ }
+}
+
+// The proxy must refuse a request whose target is internal (here loopback).
+func TestGuardedProxyBlocksInternal(t *testing.T) {
+ var hits int
+ var mu sync.Mutex
+ victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ hits++
+ mu.Unlock()
+ w.Write([]byte("REACHED"))
+ }))
+ defer victim.Close()
+
+ proxy, err := startGuardedProxy()
+ if err != nil {
+ t.Fatalf("start proxy: %v", err)
+ }
+ defer proxy.close()
+
+ proxyURL, _ := url.Parse("http://" + proxy.addr())
+ client := &http.Client{
+ Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
+ Timeout: 5 * time.Second,
+ }
+ resp, err := client.Get(victim.URL + "/ssrf")
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode < 400 {
+ t.Errorf("expected the proxy to block loopback, got status %d", resp.StatusCode)
+ }
+ }
+ if proxy.blockedRequests() == 0 {
+ t.Errorf("proxy did not record a blocked request")
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if hits != 0 {
+ t.Errorf("internal victim was reached %d times; the guard failed", hits)
+ }
+}
+
+// TestGuardFlagsConfigured locks the critical guard launcher flags so a change
+// that removes the proxy or adds back a loopback bypass fails in CI without a
+// browser. The renderer uses this same applyGuardFlags, so the two cannot drift.
+func TestGuardFlagsConfigured(t *testing.T) {
+ l := applyGuardFlags(launcher.New(), "127.0.0.1:12345")
+ if got := l.Get("proxy-server"); got != "127.0.0.1:12345" {
+ t.Errorf("proxy-server = %q, want the guard proxy address", got)
+ }
+ if got := l.Get("proxy-bypass-list"); got != "<-loopback>" {
+ t.Errorf("proxy-bypass-list = %q, want <-loopback> so loopback routes through the guard", got)
+ }
+ if !l.Has("disable-quic") {
+ t.Errorf("disable-quic flag missing (UDP egress path could bypass the guard)")
+ }
+ if got := l.Get("force-webrtc-ip-handling-policy"); got != "disable_non_proxied_udp" {
+ t.Errorf("force-webrtc-ip-handling-policy = %q, want disable_non_proxied_udp", got)
+ }
+ if !l.Has("disable-background-networking") {
+ t.Errorf("disable-background-networking flag missing")
+ }
+}
+
+// proxyClient returns an http client that sends all requests through the proxy.
+func proxyClient(p *guardedProxy) *http.Client {
+ proxyURL, _ := url.Parse("http://" + p.addr())
+ return &http.Client{
+ Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
+ Timeout: 5 * time.Second,
+ }
+}
+
+// The proxy must block the cloud metadata endpoint (plain HTTP path).
+func TestGuardedProxyBlocksMetadataLiteral(t *testing.T) {
+ proxy, err := startGuardedProxy()
+ if err != nil {
+ t.Fatalf("start proxy: %v", err)
+ }
+ defer proxy.close()
+ resp, err := proxyClient(proxy).Get("http://169.254.169.254/latest/meta-data/")
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode < 400 {
+ t.Errorf("metadata endpoint not blocked, status %d", resp.StatusCode)
+ }
+ }
+ if proxy.blockedRequests() == 0 {
+ t.Errorf("metadata request was not recorded as blocked")
+ }
+}
+
+// A hostname that resolves to an internal address must be blocked (the DNS
+// rebinding chokepoint: resolution happens inside the guard).
+func TestGuardedProxyBlocksHostnameResolvingInternal(t *testing.T) {
+ // only meaningful if localhost actually resolves to an internal address; skip
+ // otherwise so a resolution quirk cannot make this pass for the wrong reason.
+ ips, lerr := net.LookupIP("localhost")
+ if lerr != nil || len(ips) == 0 || !isInternalIP(ips[0]) {
+ t.Skip("localhost does not resolve to an internal address here")
+ }
+ proxy, err := startGuardedProxy()
+ if err != nil {
+ t.Fatalf("start proxy: %v", err)
+ }
+ defer proxy.close()
+ // localhost resolves to 127.0.0.1 / ::1, both internal.
+ resp, err := proxyClient(proxy).Get("http://localhost/")
+ if err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode < 400 {
+ t.Errorf("hostname resolving to loopback not blocked, status %d", resp.StatusCode)
+ }
+ }
+ if proxy.blockedRequests() == 0 {
+ t.Errorf("localhost request was not recorded as blocked")
+ }
+}
+
+// The CONNECT (https) path must also block internal targets.
+func TestGuardedProxyBlocksConnectInternal(t *testing.T) {
+ proxy, err := startGuardedProxy()
+ if err != nil {
+ t.Fatalf("start proxy: %v", err)
+ }
+ defer proxy.close()
+ // https forces the client to issue CONNECT to the proxy.
+ _, err = proxyClient(proxy).Get("https://127.0.0.1:1/")
+ if err == nil {
+ t.Errorf("expected the CONNECT to an internal target to fail")
+ }
+ if proxy.blockedRequests() == 0 {
+ t.Errorf("CONNECT to internal target was not recorded as blocked")
+ }
+}
+
+// dialGuarded must NOT classify a public destination as blocked. It may fail to
+// connect (no network), but the failure must not be the policy block.
+func TestDialGuardedAllowsPublicDecision(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ conn, err := dialGuarded(ctx, "tcp", "8.8.8.8:80")
+ if conn != nil {
+ conn.Close()
+ }
+ if err != nil && errors.Is(err, errBlocked) {
+ t.Errorf("public destination 8.8.8.8 was wrongly blocked by policy")
+ }
+}
+
+// End to end: proves the production Chrome flags route egress through the guard so
+// the browser cannot reach loopback (verified directly by the victim counter). A
+// positive control (the proxy's blocked count reaching the two internal targets
+// given) ensures the requests actually fired, so the test cannot pass vacuously.
+// Metadata endpoint blocking specifically is covered without a browser by
+// TestGuardedProxyBlocksMetadataLiteral. Skips when no browser binary is present.
+func TestReportBrowserEgressGuarded(t *testing.T) {
+ bin := ""
+ for _, cand := range []string{"/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"} {
+ if _, err := os.Stat(cand); err == nil {
+ bin = cand
+ break
+ }
+ }
+ if bin == "" {
+ t.Skip("no chrome/chromium binary available")
+ }
+
+ var hits int
+ var mu sync.Mutex
+ victim := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ hits++
+ mu.Unlock()
+ }))
+ defer victim.Close()
+ _, victimPort, _ := net.SplitHostPort(victim.Listener.Addr().String())
+
+ proxy, err := startGuardedProxy()
+ if err != nil {
+ t.Fatalf("start proxy: %v", err)
+ }
+ defer proxy.close()
+
+ l := applyGuardFlags(
+ launcher.New().Bin(bin).Headless(true).
+ Set("no-sandbox").Set("disable-gpu").Set("disable-dev-shm-usage"),
+ proxy.addr(),
+ ).
+ // turn off Chrome's own network access checks so a 0 result is attributable
+ // to this guard, not to a masking browser feature.
+ Set("disable-features", "PrivateNetworkAccessChecks,LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests")
+ u, err := l.Launch()
+ if err != nil {
+ t.Fatalf("launch: %v", err)
+ }
+ defer l.Cleanup()
+ browser := rod.New().ControlURL(u)
+ if err := browser.Connect(); err != nil {
+ t.Fatalf("connect: %v", err)
+ }
+ defer browser.Close()
+
+ pg := browser.MustPage()
+ _ = pg.SetDocumentContent(fmt.Sprintf(`
+