单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.2 KiB
Go
164 lines
5.2 KiB
Go
// 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())
|
||
}
|