package shared import ( "fmt" "net" "net/http" "strings" "sync" "github.com/sirupsen/logrus" "github.com/tamakyi/TamaBox/internal/conf" ) // NormalizeIP 规范化 IP:去空白、剥端口、校验合法性(非法返回空串), // 并把 IPv4-mapped IPv6(::ffff:1.2.3.4)归一为 IPv4,便于黑名单 / 限流精确匹配。 func NormalizeIP(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } if h, _, err := net.SplitHostPort(s); err == nil { s = h } ip := net.ParseIP(s) if ip == nil { return "" } if v4 := ip.To4(); v4 != nil { return v4.String() } return ip.String() } // LimitKey 把客户端 IP 归一化为「限流 / 自动封禁」用的聚合键。 // IPv4 原样;IPv6 掩码到 /64 —— 住宅 / 隧道分配通常就是一个 /64(2^64 个地址), // 若按完整地址计数,攻击者每请求换源地址即可让阈值永不触发,且键会无限膨胀内存。 // // ⚠️ 仅用于限流 / 封禁计数。IP 黑名单必须继续精确匹配(见 ipblacklist.go), // 否则会把整个 /64 段一并拉黑。 func LimitKey(ip string) string { parsed := net.ParseIP(ip) if parsed == nil { // 无法解析时原样返回,保持调用方可预期(空串由调用方自行判断)。 return ip } if v4 := parsed.To4(); v4 != nil { return v4.String() } return parsed.Mask(net.CIDRMask(64, 128)).String() + "/64" } // trustedProxyNets 缓存解析后的可信代理网段(conf.Init 之后由 InitTrustedProxies 填充)。 var trustedProxyNets []*net.IPNet // InitTrustedProxies 解析 conf.Security.TrustedProxies(逗号分隔 IP / CIDR),在 conf.Init 之后调用一次。 // 仅限"你自己的"反向代理 / LB(如本机 nginx 127.0.0.1、私有网络 LB);列表为空时 XFF / X-Real-IP 永不被信任。 // // 不为 CDN 维护庞大 CIDR 清单:站点架在 CDN 后时,应配合 Tunnel / Authenticated Origin Pull // 或防火墙仅放行回源网段锁定源站,此时 CF-Connecting-IP / EO-Client-IP 即权威客户端 IP。 func InitTrustedProxies() error { trustedProxyNets = nil for _, raw := range strings.Split(conf.Security.TrustedProxies, ",") { item := strings.TrimSpace(raw) if item == "" { continue } ipnet, err := parseTrustedProxy(item) if err != nil { return fmt.Errorf("parse trusted_proxies entry %q: %w", item, err) } trustedProxyNets = append(trustedProxyNets, ipnet) } return nil } // parseTrustedProxy 接受单 IP(自动补 /32 或 /128)或 CIDR。 func parseTrustedProxy(s string) (*net.IPNet, error) { if ip := net.ParseIP(s); ip != nil { if ip4 := ip.To4(); ip4 != nil { return &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}, nil } return &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}, nil } _, ipnet, err := net.ParseCIDR(s) if err != nil { return nil, err } return ipnet, nil } // isTrustedProxy 判断给定 IP 是否落在可信代理清单内。 func isTrustedProxy(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } for _, n := range trustedProxyNets { if n.Contains(ip) { return true } } return false } // headerSourceTrusted 判断本次请求的 TCP 对端是否有资格「决定」客户端 IP。 // // 只有两种情况成立: // 1. 对端是回环地址 —— cloudflared / 本机反代回源,连接必然由本机进程发起,外部造不出来; // 2. 对端命中 trusted_proxies —— 你自己的反代 / LB,或明确登记过的 CDN 回源网段。 // // 公网对端一律不采信,哪怕它带了格式完全合法的 CF-Connecting-IP。 // 这条约束就是 P1-3 的修复核心:双 CDN(境内 EdgeOne + 境外 Cloudflare)并存时, // EdgeOne **不剥离**客户端自带的 CF-Connecting-IP,而旧实现无条件优先读这个头, // 于是「经 EdgeOne 进来的请求」其客户端 IP 完全由请求方决定 —— 限流 / IP 黑名单全失效, // 且 autoban 按这个假 IP 记账,可被用来栽赃任意正常用户(连打 10 次敏感路径即封 15 分钟)。 // // 反过来说,只要要求「权威头只能由 CDN 写入」这一前提无法被代码验证,就不能把它当公理; // 唯一能被代码验证的依据是 TCP 对端地址,因此这里 fail-closed。 func headerSourceTrusted(remote string) bool { if remote == "" { return false } if ip := net.ParseIP(remote); ip != nil && ip.IsLoopback() { return true } return isTrustedProxy(remote) } // cdnClientIPHeader 取请求里的 CDN 权威客户端 IP(Cloudflare 优先,其次腾讯 EdgeOne)。 // 只负责取值与规范化,不判断来源是否可信 —— 可信性由 headerSourceTrusted 负责。 func cdnClientIPHeader(r *http.Request) string { if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" { return cfIP } return NormalizeIP(r.Header.Get("EO-Client-IP")) } // warnIgnoredCDNHeaderOnce 进程内只告警一次:避免伪造请求把日志刷满, // 同时保证「链路与配置不匹配」这件事在上线后一定能被看见。 var warnIgnoredCDNHeaderOnce sync.Once // ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。 // // 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 仅当 TCP 对端可信 // (回环或 trusted_proxies 命中)才采信 CDN 权威头 CF-Connecting-IP → EO-Client-IP, // (b) 仅当同一对端落在 trusted_proxies 内才从右向左解析 XFF / X-Real-IP, // (c) 否则回退 RemoteAddr —— 绝不采信客户端自供的头。 // // ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造 // CF-Connecting-IP / EO-Client-IP。本函数现已 fail-closed(公网对端不采信), // 但仍建议用 Tunnel / 仅放行回源网段锁死源站,否则攻击者虽伪造不了身份, // 却仍能直接打到源站。 // // ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)并存且都直连回源时,两家回源网段 // 都必须登记到 trusted_proxies,否则该项 CDN 的权威头会被忽略、退化成按边缘 IP 聚合 // (限流互相挤占、自动封禁误伤整片区域)。重新启用 EdgeOne 时请一并核对这里。 func ResolveClientIP(r *http.Request) string { remote := NormalizeIP(r.RemoteAddr) if !conf.Security.TrustProxy { return remote } // (a) CDN 权威客户端头:只有对端可信时才采信。 if headerSourceTrusted(remote) { if ip := cdnClientIPHeader(r); ip != "" { return ip } } else if forged := cdnClientIPHeader(r); forged != "" { // 公网对端却带了权威头:要么是 CDN 直连回源但没登记网段,要么是有人在伪造身份。 // 只告警、不改变行为(行为已由上面的分支确定为「用 remote」),避免影响业务。 warnIgnoredCDNHeaderOnce.Do(func() { logrus.WithFields(logrus.Fields{ "remote_addr": remote, "header_ip": forged, }).Warn("来自不可信公网对端的请求携带了 CDN 权威客户端头,已忽略并改用 TCP 对端地址。" + "若站点由 CDN 直连回源(非 cloudflared Tunnel),请把该 CDN 的回源网段加入 " + "[security] trusted_proxies;若本不该出现此类请求,说明有人在尝试伪造客户端身份以绕过限流 / 封禁。") }) } // (b)/(c) XFF / X-Real-IP 仍只认 trusted_proxies:这两个头是多跳链、语义更弱, // 回环对端不隐含信任它们。 if isTrustedProxy(remote) { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" { // 从右向左取第一个非可信代理跳:客户端的真实 IP。 parts := strings.Split(forwarded, ",") for i := len(parts) - 1; i >= 0; i-- { hop := NormalizeIP(parts[i]) if hop == "" { continue } if !isTrustedProxy(hop) { return hop } } } if realIP := NormalizeIP(r.Header.Get("X-Real-IP")); realIP != "" { return realIP } } return remote }