mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-09-16 06:05:26 +02:00
restrict report generation egress, disable browser telemetry and escape recipient data
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 := `<html><body>Report<img src="` + victim.URL + `/ssrf"></body></html>`
|
||||
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(`<html><body><img src="` + res.URL + `/normal.gif"></body></html>`)
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(`<html><body>
|
||||
<img src="http://127.0.0.1:%s/ssrf">
|
||||
<img src="http://169.254.169.254/latest/meta-data/">
|
||||
</body></html>`, 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user