Files
trihuy-russian/util/netsafe/netsafe.go
T
MHSanaei 38da210ded fix(security): SSRF-guard node and remote HTTP clients
The Node.Probe and Remote.do paths built outbound URLs by string-
formatting admin-controlled fields (Scheme/Address/Port/BasePath)
straight into requests, then dialed the result with the default
transport. CodeQL flagged this as go/request-forgery — an admin
(or anyone who compromises the admin account) could point a node
at internal infrastructure (cloud metadata, RFC1918 ranges, etc.)
and the panel would dutifully fetch it.

Add util/netsafe with a shared TOCTOU-safe DialContext that
resolves the host, rejects private/internal IPs unless the
per-request context whitelists them (per-node AllowPrivateAddress
flag, plumbed through context.Value), and dials the resolved IP
directly so the IP that passed the check is the IP we connect to.
This closes the DNS-rebinding window where a hostname could
resolve to a public IP at check time and a private one at dial.

Also tighten address validation (NormalizeHost rejects anything
that isn't a bare hostname or IP literal — no embedded paths,
userinfo, schemes) and switch URL construction from fmt.Sprintf to
url.URL{} + net.JoinHostPort so admin-supplied values can't smuggle
URL components.

custom_geo.go's isBlockedIP now delegates to netsafe so there's
one source of truth.
2026-05-13 13:33:53 +02:00

102 lines
3.3 KiB
Go

// Package netsafe provides SSRF-safe HTTP dialing primitives. A dialer
// installed via SSRFGuardedDialContext resolves the host, rejects
// private/internal IPs unless the per-request context whitelists them,
// and dials the resolved IP directly so the IP checked is the IP used —
// closing the DNS-rebinding TOCTOU window.
package netsafe
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
)
// IsBlockedIP returns true for loopback, RFC1918 private, link-local
// (including 169.254.169.254 cloud-metadata), and unspecified addresses.
func IsBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
type allowPrivateCtxKey struct{}
// ContextWithAllowPrivate marks a context as permitting outbound requests
// to private/internal IPs. Use only for callers (e.g. LAN-resident nodes)
// where the admin has opted in explicitly.
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
}
func AllowPrivateFromContext(ctx context.Context) bool {
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
return v
}
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
// SSRFGuardedDialContext is a net/http Transport.DialContext implementation
// that enforces IsBlockedIP unless the context opts in via
// ContextWithAllowPrivate.
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
allowPrivate := AllowPrivateFromContext(ctx)
var ips []net.IPAddr
if ip := net.ParseIP(host); ip != nil {
ips = []net.IPAddr{{IP: ip}}
} else {
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
}
var lastErr error
for _, ipAddr := range ips {
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
continue
}
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if derr == nil {
return conn, nil
}
lastErr = derr
}
if lastErr == nil {
lastErr = fmt.Errorf("no usable address for %s", host)
}
return nil, lastErr
}
// hostnamePattern accepts RFC 1123 hostnames (letters, digits, hyphens,
// dots). Bracketed IPv6 forms ("[::1]") are stripped before this check
// runs in NormalizeHost.
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
// NormalizeHost validates that addr is a plain hostname or IP literal with
// no embedded path/userinfo/port/scheme — anything that could be used to
// smuggle URL components past callers that string-format URLs from user
// input. Returns the bare host (no brackets); callers wrap IPv6 via
// net.JoinHostPort as needed.
func NormalizeHost(addr string) (string, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "", fmt.Errorf("address is required")
}
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
addr = addr[1 : len(addr)-1]
}
if ip := net.ParseIP(addr); ip != nil {
return ip.String(), nil
}
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
return "", fmt.Errorf("invalid host %q", addr)
}
return addr, nil
}