Files
phishingclub/backend/remotebrowser/ssrfproxy.go
T

357 lines
11 KiB
Go

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)
}