model, tunnel, wg, authtoken, netguard, docker and dotenv, split out of the original single repository so Hammerhead, Blacktip and Bamboo can live in their own repos and import them. README extended; LICENSE unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
3.2 KiB
Go
108 lines
3.2 KiB
Go
// Package netguard decides which network destinations a server-side feature
|
|
// may be pointed at. Anything that makes outbound connections on a user's behalf
|
|
// (webhooks, mail delivery) must refuse internal addresses by default, or it can
|
|
// be used to probe the machine's own network and cloud metadata services.
|
|
package netguard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
var internal = func() []*net.IPNet {
|
|
var out []*net.IPNet
|
|
for _, c := range []string{"0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "64:ff9b::/96", "2001:db8::/32"} {
|
|
_, n, _ := net.ParseCIDR(c)
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}()
|
|
|
|
// Blocked reports whether ip is private, loopback, link-local, multicast,
|
|
// unspecified, or in a range reserved for carrier-grade NAT (which includes
|
|
// the WireGuard tunnel network) and similar.
|
|
func Blocked(ip net.IP) bool {
|
|
if v4 := ip.To4(); v4 != nil {
|
|
ip = v4
|
|
}
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
for _, n := range internal {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ErrBlocked is returned when every address a name resolves to is internal.
|
|
var ErrBlocked = errors.New("not allowed: that address is on an internal network")
|
|
|
|
// DialContext resolves the host itself, checks every address, and connects to
|
|
// the one it checked, so a name that changes between the check and the
|
|
// connection (DNS rebinding) can't slip through.
|
|
func DialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ips []net.IP
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
ips = []net.IP{ip}
|
|
} else {
|
|
res, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range res {
|
|
ips = append(ips, r.IP)
|
|
}
|
|
}
|
|
var last error = errors.New("no addresses")
|
|
blocked := false
|
|
d := net.Dialer{Timeout: 10 * time.Second}
|
|
for _, ip := range ips {
|
|
if !allowPrivate && Blocked(ip) {
|
|
blocked = true
|
|
continue
|
|
}
|
|
c, err := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
|
if err == nil {
|
|
return c, nil
|
|
}
|
|
last = err
|
|
}
|
|
if blocked && !allowPrivate {
|
|
return nil, ErrBlocked
|
|
}
|
|
return nil, last
|
|
}
|
|
}
|
|
|
|
// Client is an HTTP client for user-supplied URLs: guarded dialing, a short
|
|
// timeout, and no following of redirects (a redirect could point somewhere internal).
|
|
func Client(allowPrivate bool) *http.Client {
|
|
return &http.Client{
|
|
Timeout: 12 * time.Second,
|
|
Transport: &http.Transport{DialContext: DialContext(allowPrivate), TLSHandshakeTimeout: 10 * time.Second, DisableKeepAlives: true},
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
}
|
|
|
|
// PortOf is a small helper for URLs without an explicit port.
|
|
func PortOf(scheme, port string) string {
|
|
if port != "" {
|
|
return port
|
|
}
|
|
if scheme == "https" {
|
|
return strconv.Itoa(443)
|
|
}
|
|
return strconv.Itoa(80)
|
|
}
|