// Package policy 出站策略引擎:SSRF fail-closed 守卫、域名规则 trie、robots 缓存。 // // 复用声明:SSRF 私网判定逻辑移植自 bench/trafilatura-http/app.py `_BLOCKED_NETS`/ // `_forbidden_host`/`_guard_url`(Go 版语义等价 + 每次重定向重验,design-arch §5.4)。 package policy import ( "context" "fmt" "net" "net/url" "strings" "time" ) // blockedNets 禁止出站的网段(bench app.py _BLOCKED_NETS 全集,勿增删顺序): // 0.0.0.0/8、RFC1918、127/8、169.254/16(链路本地/云 metadata)、::1、fc00::/7、fe80::/10。 var blockedNets = func() []*net.IPNet { cidrs := []string{ "0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10", } out := make([]*net.IPNet, 0, len(cidrs)) for _, c := range cidrs { _, n, err := net.ParseCIDR(c) if err != nil { panic(fmt.Sprintf("policy: 解析内建 CIDR %s 失败: %v", c, err)) } out = append(out, n) } return out }() // blockedHosts 云 metadata / K8s 服务域(bench app.py _BLOCKED_HOSTS + 同形扩展)。 var blockedHosts = map[string]bool{ "metadata.google.internal": true, "metadata.google.com": true, "kubernetes.default.svc": true, } // DeniedError 合规拦截错误(rule_id 供审计)。 type DeniedError struct { RuleID string Reason string } // Error 实现 error。 func (e *DeniedError) Error() string { return "denied:" + e.RuleID + ": " + e.Reason } // Guard SSRF 守卫。resolver 可注入(测试用),nil 用 net.DefaultResolver。 type Guard struct { Resolver *net.Resolver } // allowedPort 仅 80/443(design-arch §5.4 请求侧)。 func allowedPort(port string) bool { return port == "" || port == "80" || port == "443" } // CheckURL 校验 URL 合法性(协议/端口/host),不做 DNS(分离以支持每跳重验)。 // 返回 DeniedError 即拦截。 func (g *Guard) CheckURL(rawURL string) *DeniedError { u, err := url.Parse(rawURL) if err != nil { return &DeniedError{RuleID: "ssrf_parse", Reason: "URL 解析失败"} } if u.Scheme != "http" && u.Scheme != "https" { return &DeniedError{RuleID: "ssrf_scheme", Reason: "仅允许 http/https"} } host := u.Hostname() if host == "" { return &DeniedError{RuleID: "ssrf_nohost", Reason: "缺少 host"} } if !allowedPort(u.Port()) { return &DeniedError{RuleID: "ssrf_port", Reason: "仅允许 80/443 端口"} } return g.CheckHost(host) } // CheckHost 校验 host(字面量与黑名单层;DNS 解析层由 ResolveCheck 承担)。 func (g *Guard) CheckHost(host string) *DeniedError { h := strings.ToLower(strings.Trim(host, "[]")) if blockedHosts[h] || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".local") { return &DeniedError{RuleID: "ssrf_metadata_host", Reason: "禁内网/元数据域名"} } // localhost / *.localhost 解析恒为回环(RFC 6761),DNS 前即拦。 if h == "localhost" || strings.HasSuffix(h, ".localhost") { return &DeniedError{RuleID: "ssrf_private_ip", Reason: "禁 localhost"} } if ip := net.ParseIP(h); ip != nil { return g.checkIP(ip) } return nil } // ResolveCheck DNS 解析后逐 IP 校验(bench app.py:解析结果逐一比对 _BLOCKED_NETS)。 // 防域名解析到私网/元数据 IP 的绕过。fail-closed:解析失败即拒绝。 func (g *Guard) ResolveCheck(ctx context.Context, host string) *DeniedError { if d := g.CheckHost(host); d != nil { return d } if ip := net.ParseIP(strings.ToLower(strings.Trim(host, "[]"))); ip != nil { return nil // 字面量 IP 已在 CheckHost 判过 } resolver := g.Resolver if resolver == nil { resolver = net.DefaultResolver } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() addrs, err := resolver.LookupIPAddr(cctx, host) if err != nil { return &DeniedError{RuleID: "ssrf_dns_fail", Reason: "DNS 解析失败(fail-closed)"} } if len(addrs) == 0 { return &DeniedError{RuleID: "ssrf_dns_empty", Reason: "DNS 无记录"} } for _, addr := range addrs { if d := g.checkIP(addr.IP); d != nil { return d } } return nil } // checkIP 单 IP 私网判定(bench app.py 循环体)。 func (g *Guard) checkIP(ip net.IP) *DeniedError { for _, n := range blockedNets { if n.Contains(ip) { return &DeniedError{RuleID: "ssrf_private_ip", Reason: fmt.Sprintf("私网 IP %s", ip)} } } // IPv4-mapped IPv6(::ffff:10.0.0.1)归一后再判一次。 if ip4 := ip.To4(); ip4 != nil && !isV4(ip) { for _, n := range blockedNets { if n.Contains(ip4) { return &DeniedError{RuleID: "ssrf_private_ip", Reason: fmt.Sprintf("私网 IP %s", ip4)} } } } return nil } func isV4(ip net.IP) bool { return ip.To4() != nil && strings.Contains(ip.String(), ".") } // CheckRedirect 每跳重验(design-arch §5.4:每次重定向重验)。 // httpx Client 的 CheckRedirect hook 每跳调用:新 URL 先 CheckURL 再 ResolveCheck。 func (g *Guard) CheckRedirect(ctx context.Context, rawURL string) *DeniedError { if d := g.CheckURL(rawURL); d != nil { return d } u, err := url.Parse(rawURL) if err != nil { return &DeniedError{RuleID: "ssrf_parse", Reason: "重定向 URL 解析失败"} } return g.ResolveCheck(ctx, u.Hostname()) }