From ec06cd84c15e1e3bd797afff526a8d8feb2b5b07 Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Thu, 3 Sep 2026 09:33:01 +0200 Subject: [PATCH] restrict report generation egress, disable browser telemetry and escape recipient data Signed-off-by: RonniSkansing --- backend/remotebrowser/pdf.go | 43 ++- backend/remotebrowser/pdf_flags_test.go | 177 ++++++++++++ backend/remotebrowser/ssrfproxy.go | 356 ++++++++++++++++++++++++ backend/remotebrowser/ssrfproxy_test.go | 276 ++++++++++++++++++ backend/service/campaign.go | 33 ++- 5 files changed, 880 insertions(+), 5 deletions(-) create mode 100644 backend/remotebrowser/pdf_flags_test.go create mode 100644 backend/remotebrowser/ssrfproxy.go create mode 100644 backend/remotebrowser/ssrfproxy_test.go 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` + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pdf, err := RenderHTMLToPDF(ctx, html, bin) + if err != nil { + t.Fatalf("render: %v", err) + } + if len(pdf) == 0 { + t.Fatalf("no PDF produced; render did not run so the guard was not exercised") + } + mu.Lock() + defer mu.Unlock() + if hits != 0 { + t.Fatalf("loopback resource reached %d times through RenderHTMLToPDF; guard wiring missing", hits) + } +} + +// startAllowAllProxy is a permissive forward proxy used only to isolate the flag +// behavior from the guard policy: it forwards everything (including loopback). +func startAllowAllProxy(t *testing.T) (string, func()) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tr := &http.Transport{} + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + upstream, err := net.DialTimeout("tcp", r.Host, 5*time.Second) + if err != nil { + http.Error(w, "", http.StatusBadGateway) + return + } + client, _, err := w.(http.Hijacker).Hijack() + if err != nil { + _ = upstream.Close() + return + } + _, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + go func() { _, _ = io.Copy(upstream, client); _ = upstream.Close() }() + _, _ = io.Copy(client, upstream) + _ = client.Close() + return + } + out := r.Clone(r.Context()) + out.RequestURI = "" + resp, err := tr.RoundTrip(out) + if err != nil { + http.Error(w, "", http.StatusBadGateway) + return + } + defer resp.Body.Close() + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + }) + srv := &http.Server{Handler: h} + go func() { _ = srv.Serve(ln) }() + return ln.Addr().String(), func() { _ = srv.Close() } +} + +// TestNormalResourcesLoadWithProductionFlags is a smoke test: it confirms Chrome +// launches with the full production flag set (including --disable-quic and the +// WebRTC policy) and can still fetch a normal proxied resource, and that +// proxy-bypass-list=<-loopback> correctly routes even loopback through the proxy. +// It exercises the plain HTTP forward path, not the https/QUIC negotiation path, +// so it does not by itself prove QUIC fallback; that rests on the reasoning that +// QUIC is only ever reached after a TCP connection and always has a TCP fallback. +// Skips when no browser is present. +func TestNormalResourcesLoadWithProductionFlags(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 + res := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + hits++ + mu.Unlock() + w.Header().Set("Content-Type", "image/gif") + // 1x1 gif so the browser treats it as a real successful image load + w.Write([]byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b}) + })) + defer res.Close() + + proxyAddr, closeProxy := startAllowAllProxy(t) + defer closeProxy() + + // the real production flag set, via the shared helper, plus the PNA check + // disables so the fetch is attributable to the flags rather than a masking + // browser feature. + l := applyGuardFlags( + launcher.New().Bin(bin).Headless(true). + Set("no-sandbox").Set("disable-gpu").Set("disable-dev-shm-usage"), + proxyAddr, + ). + 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(``) + + 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(` + + + `, victimPort)) + + // positive control: wait until the guard has refused both internal targets. If + // the requests never fire, this stays below 2 and the test fails loudly rather + // than passing without exercising the guard. + deadline := time.Now().Add(10 * time.Second) + for proxy.blockedRequests() < 2 && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + pg.Close() + + if got := proxy.blockedRequests(); got < 2 { + t.Fatalf("guard refused %d requests; expected it to refuse both loopback and metadata (the requests may not have fired)", got) + } + mu.Lock() + defer mu.Unlock() + if hits != 0 { + t.Fatalf("loopback victim reached %d times through the report browser; SSRF guard failed", hits) + } +} diff --git a/backend/service/campaign.go b/backend/service/campaign.go index de198c0..f9650b0 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -5993,7 +5993,9 @@ func (c *Campaign) buildReportHTMLWithData( c.Logger.Errorw("failed to parse report template", "error", err) return "", nil, "", errs.Wrap(err) } - if err := tmpl.Execute(&buf, rd); err != nil { + // the report HTML is rendered by a browser, so escape untrusted strings; the + // raw rd is returned for plain text consumers such as the email subject. + if err := tmpl.Execute(&buf, htmlEscapeReportData(rd)); err != nil { c.Logger.Errorw("failed to execute report template", "error", err) return "", nil, "", errs.Wrap(err) } @@ -6312,8 +6314,10 @@ func (c *Campaign) SendCampaignReport( bodyTmpl = v } } + // the subject is a plain text mail header, so it uses the raw data; the body is + // text/html, so it uses the escaped data like the report itself. subject := renderReportEmailField(c, subjectTmpl, defaultReportEmailSubject, reportData) - body := renderReportEmailField(c, bodyTmpl, defaultReportEmailBody, reportData) + body := renderReportEmailField(c, bodyTmpl, defaultReportEmailBody, htmlEscapeReportData(reportData)) m.Subject(subject) m.SetBodyString("text/html", body) if err := m.AttachReader(filename, bytes.NewReader(pdfBytes)); err != nil { @@ -6428,3 +6432,28 @@ func buildReportData( Recipients: recipients, } } + +// htmlEscapeReportData returns a copy of the report data with the externally +// supplied strings HTML escaped, for the HTML contexts (the rendered report and +// the html email body). text/template does not escape values, so this stops +// untrusted recipient fields (CSV, manual, or SCIM) and operator entered names +// from being interpreted as markup by the browser. The original is returned +// unescaped for plain text contexts such as the email subject header. +func htmlEscapeReportData(rd *model.ReportData) *model.ReportData { + if rd == nil { + return nil + } + cp := *rd + cp.CampaignName = template.HTMLEscapeString(rd.CampaignName) + cp.CompanyName = template.HTMLEscapeString(rd.CompanyName) + cp.Recipients = make([]model.ReportRecipient, len(rd.Recipients)) + for i, r := range rd.Recipients { + r.FirstName = template.HTMLEscapeString(r.FirstName) + r.LastName = template.HTMLEscapeString(r.LastName) + r.Email = template.HTMLEscapeString(r.Email) + r.Department = template.HTMLEscapeString(r.Department) + r.Position = template.HTMLEscapeString(r.Position) + cp.Recipients[i] = r + } + return &cp +}