package httpclient import ( "fmt" "net" "net/url" "strings" ) // privateCIDRs lists IPv4 and IPv6 ranges that must not be reached by the // outbound HTTP client (SSRF prevention). Includes RFC 1918, loopback, link- // local, multicast, documentation, and IPv4-mapped IPv6 ranges. var privateCIDRs = []string{ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96", } // privateNets holds the parsed CIDR blocks from privateCIDRs. var privateNets []*net.IPNet func init() { for _, cidr := range privateCIDRs { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { // net.ParseCIDR only fails on malformed input; the list is static. panic(fmt.Sprintf("httpclient: unable to parse private CIDR %q: %v", cidr, err)) } privateNets = append(privateNets, ipNet) } } // CheckURL validates that rawURL is an http(s) URL and that its hostname does // not resolve to a private/reserved IP address. Hosts matching any entry in // allowedHosts bypass the IP check. // // allowedHosts entries support exact matches, suffix matches (e.g. // "example.com" matches "rm.example.com"), and wildcard suffixes (e.g. // "*.example.com"). func CheckURL(rawURL string, allowedHosts ...string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("httpclient: parse url: %w", err) } if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { return fmt.Errorf("httpclient: unsupported url scheme %q", u.Scheme) } host := strings.ToLower(u.Hostname()) if host == "" { return fmt.Errorf("httpclient: url has no hostname") } for _, allowed := range allowedHosts { if matchAllowedHost(host, allowed) { return nil } } ips, err := net.LookupIP(host) if err != nil { return fmt.Errorf("httpclient: lookup host %q: %w", host, err) } if len(ips) == 0 { return fmt.Errorf("httpclient: host %q resolved to no IPs", host) } for _, ip := range ips { if isPrivateIP(ip) { return fmt.Errorf("httpclient: host %q resolves to private IP %s", host, ip) } } return nil } // isPrivateIP reports whether ip falls inside any of the reserved CIDR blocks. func isPrivateIP(ip net.IP) bool { if v4 := ip.To4(); v4 != nil { for _, ipNet := range privateNets { if isIPv4MappedNet(ipNet) { continue } if ipNet.Contains(v4) { return true } } return false } for _, ipNet := range privateNets { if ipNet.Contains(ip) { return true } } return false } // isIPv4MappedNet reports whether n is an IPv4-mapped IPv6 CIDR such as // ::ffff:0:0/96. These must not be used to classify plain IPv4 addresses. func isIPv4MappedNet(n *net.IPNet) bool { return len(n.IP) == net.IPv6len && n.IP.To4() != nil && n.IP[10] == 0xff && n.IP[11] == 0xff } // matchAllowedHost reports whether host matches pattern. Patterns may be exact // hostnames, domain suffixes ("example.com" matches "rm.example.com"), or // wildcard suffixes ("*.example.com"). func matchAllowedHost(host, pattern string) bool { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == "" { return false } if pattern == host { return true } if strings.HasPrefix(pattern, "*.") { pattern = pattern[2:] } if strings.HasPrefix(pattern, ".") { pattern = pattern[1:] } return host == pattern || strings.HasSuffix(host, "."+pattern) }