Phase 3+4: 共享 HTTP 客户端 + admin API + audit log
Phase 3 (httpclient): - shared Client (Timeout + MaxResponseBytes 截断) - SSRF: 16 段私网 CIDR (含 169.254/::ffff:0:0/96 IPv4-mapped) + hostname allowlist (精确/后缀/*. 通配) + scheme 校验 (仅 http/https) - auth 适配器: none / simple (YARN user.name) / basic (SetBasicAuth) - DoWithRedirect: 跨主机跳转保留 Authorization, max 5 默认 - 19 个测试全绿 (httptest 模拟) Phase 4 (admin + audit): - internal/audit: Entry + Repo.Insert/List + MarshalDetails, snake_case JSON - internal/middleware: AdminAuth/AgentAuth (constant-time 比对) - internal/admin: 6 端点 (GET/POST/PUT/DELETE /admin/clusters, GET /admin/audit) + 写操作触发 audit_log (含 before/after diff) + AuthPassword 空=保留旧密码 + 校验: 必填字段 + auth_type 枚举 + rate_limit>=0 - main.go: 挂载 storage.Open + admin.Mount - L fix: audit.Entry 加 json tag (smoke test 发现大写 key 不规范) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user