151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
package shared
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"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
|
||
}
|
||
|
||
// ResolveClientIP 统一解析客户端真实 IP,供速率限制、IP 黑名单与审计记录共用。
|
||
//
|
||
// 策略:未开 TrustProxy 时一律用不可伪造的 TCP RemoteAddr;开启后 (a) 先信 CDN 权威头
|
||
// CF-Connecting-IP → EO-Client-IP,(b) 仅当 TCP 对端落在 trusted_proxies 内才从右向左
|
||
// 解析 XFF / X-Real-IP,(c) 否则回退 RemoteAddr,绝不信任客户端自供的头。
|
||
//
|
||
// ⚠️ trust_proxy=true 但源站可被直连(未用 Tunnel / 未锁源站)时,攻击者可自造
|
||
// CF-Connecting-IP / EO-Client-IP 并被本函数信任 —— 生产开启必须锁定源站只接受 CDN 回源。
|
||
//
|
||
// ⚠️ 双 CDN(境内 EdgeOne + 境外 Cloudflare)时必须:① 防火墙仅放行两家回源网段;
|
||
// ② 在各家规则引擎中删除入站请求里「对方 CDN」的权威头,否则攻击者可借道另一家伪造身份,
|
||
// 绕过限流 / 自动封禁 / 单 IP 投票限制。步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md。
|
||
func ResolveClientIP(r *http.Request) string {
|
||
if !conf.Security.TrustProxy {
|
||
return NormalizeIP(r.RemoteAddr)
|
||
}
|
||
|
||
// (a) CDN 权威客户端头(只能由 CDN 边缘写入):Cloudflare 优先,其次腾讯 EdgeOne。
|
||
if cfIP := NormalizeIP(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
|
||
return cfIP
|
||
}
|
||
if eoIP := NormalizeIP(r.Header.Get("EO-Client-IP")); eoIP != "" {
|
||
return eoIP
|
||
}
|
||
|
||
// (b)/(c) 仅当 TCP 对端是受信代理时才解析 XFF/X-Real-IP。
|
||
remote := NormalizeIP(r.RemoteAddr)
|
||
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
|
||
}
|