Files
tamabox/internal/shared/ip.go
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

177 lines
6.8 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 字符串:
// - 去除两端空白
// - 剥离端口("1.2.3.4:8080" / "[::1]:8080")
// - 校验合法性,非法返回空串
// - 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 前缀后返回,形如 "2001:db8:1:2::/64"。
//
// 为什么 IPv6 必须聚合:单个住宅或隧道 IPv6 分配通常就是一个 /64(2^64 个地址)。
// 若以完整地址作键,攻击者每次请求换一个源地址即可让每个键只命中一次,
// 使限流与 autoban 的阈值永远无法触发,同时海量键还会持续膨胀内存。
// /64 是 IPv6 的最小实际分配单位,按此聚合可在不误伤正常用户的前提下堵住绕过。
//
// ⚠️ 仅用于限流 / 封禁计数。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 列表),
// 仅限“你自己的”反向代理/负载均衡基础设施(例如本机 nginx 的 127.0.0.1、私有网络 LB),
// 在 conf.Init 之后调用一次。列表为空时,X-Forwarded-For / X-Real-IP 永不被信任。
//
// 设计目标:避免维护 Cloudflare / EdgeOne 等上游 CDN 的庞大 CIDR 清单。
// 站点若架在 CDN 后,应配合 Cloudflare Tunnel / Authenticated Origin Pull(Cloudflare)或
// 防火墙仅放行回源网段(Cloudflare / EdgeOne)锁定源站只接受 CDN 连接,此时
// CF-Connecting-IP / EO-Client-IP 即为权威客户端 IP,无需任何 CIDR 白名单。
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 黑名单与审计记录共用。
//
// 解析策略(不依赖上游 CDN 的庞大 CIDR 清单):
// 1. 未开启 TrustProxy:一切以 TCP 对端 RemoteAddr 为准(不可伪造)。
// 2. 开启 TrustProxy:
// a. 优先信任 CDN 权威客户端头:CF-Connecting-IP(Cloudflare)→ EO-Client-IP
// (腾讯 EdgeOne)。这类头只能由对应 CDN 边缘按真实客户端写入,前提是源站已锁定
// 为仅接受这些 CDN 的回源连接(攻击者无法直连源站伪造)。
// b. 仅当 TCP 对端落在 trusted_proxies(你自己的基础设施)内时,才解析
// X-Forwarded-For / X-Real-IP,并从右向左剥离已知可信跳数取原始客户端。
// c. 否则回退到 RemoteAddr,绝不信任客户端自行提供的头。
//
// ⚠️ 若 trust_proxy = true 但源站仍可被人直连(未用 Tunnel / 未锁源站),攻击者可自行
// 发送 CF-Connecting-IP / EO-Client-IP 头,此时本函数会信任之。因此生产开启 trust_proxy
// 时,务必配合「Cloudflare Tunnel / Authenticated Origin Pull / 防火墙仅放行 CDN 回源
// 网段」等手段锁定源站,使伪造头无法抵达应用。
//
// 双 CDN 部署(境内 EdgeOne + 境外 Cloudflare)的额外约束:
// 若回源请求携带对方 CDN 的权威头(经 EdgeOne 回源的请求带 CF-Connecting-IP、经
// Cloudflare 回源的请求带 EO-Client-IP),攻击者即可借道另一家伪造身份,绕过限流 /
// 自动封禁 / 单 IP 投票限制,或嫁祸他人。因此必须:① 防火墙仅放行两家 CDN 的回源网段;
// ② 在各家 CDN 的规则引擎中删除入站请求里「对方 CDN」的权威头,使每个请求最终只携带
// 自己所在 CDN 的头(操作步骤见 outputs/edgeone-cloudflare-dual-cdn-guide.md)。
// Cloudflare 侧因 CF-Connecting-IP 恒被覆盖,且用 Transform Rule 删掉 EO-Client-IP 即可;
// EdgeOne 侧用规则引擎删掉 CF-Connecting-IP 即可。
func ResolveClientIP(r *http.Request) string {
if !conf.Security.TrustProxy {
return NormalizeIP(r.RemoteAddr)
}
// (a) CDN 权威客户端头:CF-Connecting-IP(Cloudflare)优先,其次 EO-Client-IP(腾讯
// EdgeOne,等效于 CF-Connecting-IP 的权威头,回源时由 EdgeOne 边缘写入客户端真实 IP)。
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
}