Files
tamabox/internal/shared/ip.go
T

151 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}