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.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user