Files
spark-mcp/internal/httpclient/ssrf.go
T
tao.chenandClaude 475e353850 httpclient: 清理 ssrf.go S1017 lint 警告
matchAllowedHost 里两处 if strings.HasPrefix(...) pattern = pattern[N:]
替成无条件 strings.TrimPrefix, 行为完全等价 (前缀不匹配时 TrimPrefix
是 no-op). 触发 S1017 (Should replace this if statement with an
unconditional strings.TrimPrefix).

净 -4 行, 函数语义不变, matchAllowedHost 现有测试 (ssrf_test.go)
覆盖的精确/后缀/wildcard 三种 host 匹配路径都不变.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 16:26:17 +08:00

124 lines
3.4 KiB
Go

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
}
pattern = strings.TrimPrefix(pattern, "*.")
pattern = strings.TrimPrefix(pattern, ".")
return host == pattern || strings.HasSuffix(host, "."+pattern)
}