Files
Volcano-Engine-TTS-UI/middleware/ratelimit_test.go
T
sun 272565f736 fix: VUL-003 完整修复,启发式/精确双模式 XFF 解析
VUL-003 (中): X-Forwarded-For 信任链可被伪造 IP 绕过限流

本服务定位为公网入口,即使单人使用,公网暴露意味着攻击面
与公开服务等同,不能"够用就行"。

采用渐进式披露设计,平衡易用性与功能性:

  1. 启发式模式(默认, 不设环境变量 或 TRUSTED_PROXY_HOPS=0)
     - 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP
     - 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数
     - 限制:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"
     - 直出部署:整个 XFF 分支不会执行

  2. 精确模式(TRUSTED_PROXY_HOPS=N, N>0)
     - 从 XFF 链尾倒数第 N+1 个位置取值
     - 精准到真实 client,需按实际反代跳数正确配置
     - N=1:单跳反代;N=2:CDN+反代;以此类推

  3. 两种模式都从链尾扫描
     - XFF 首值是客户端可控的,信任首值等于信任攻击者
     - 链尾由受控的反代添加,天然免疫伪造绕过

  4. 默认值从 1 改为 0(行为变化)
     - 旧默认:精确模式 N=1,取 XFF 末值
     - 新默认:启发式模式,跳过链尾私有 IP
     - 对单跳场景行为相同
     - 对多跳/链尾含私有 IP 场景新版更准确(返回真实公网 IP)

  5. 配套
     - middleware/ratelimit_test.go:24 个表驱动测试用例,
       覆盖直出/单跳/多跳/伪造/畸形/精确 N 边界,全部通过
     - setting/config.go:LogStartupSummary 显示当前 XFF 模式
     - .env.example:重写说明,标注默认行为 + 何时需配
     - README.md:新增"反代拓扑与 X-Forwarded-For 解析"章节
       (何时需要/两种模式/行为对比/为什么从链尾/启动日志验证)

  6. 已知边界:TRUSTED_PROXY_HOPS=00 等被 Atoi 解析为 0 的
     输入归入启发式模式,日志不会出现"精确模式 0 跳"矛盾输出。
2026-08-26 00:36:22 +08:00

230 lines
5.6 KiB
Go

package middleware
import (
"net/http/httptest"
"testing"
)
// TestGetClientIP 覆盖 XFF 解析在两种模式下的关键场景。
// 表驱动测试,每个 case 独立设置 trustedProxyHops,验证 GetClientIP 输出。
func TestGetClientIP(t *testing.T) {
tests := []struct {
name string
mode int // 0=启发式, N>0=精确 N 跳
remoteAddr string // 直连 IP:port
xff string // X-Forwarded-For 头(空则不设)
xri string // X-Real-IP 头(空则不设)
want string
}{
// === 直出部署(directIP 是公网,XFF 分支不进)===
{
name: "直出_无XFF",
mode: 0,
remoteAddr: "1.2.3.4:5678",
want: "1.2.3.4",
},
{
name: "直出_XFF被忽略",
mode: 0,
remoteAddr: "1.2.3.4:5678",
xff: "fake",
want: "1.2.3.4", // 公网直连不走 XFF 分支
},
{
name: "直出_精确模式也不走XFF",
mode: 2,
remoteAddr: "1.2.3.4:5678",
xff: "fake, 5.6.7.8",
want: "1.2.3.4",
},
// === 单跳反代 ===
{
name: "单跳_启发式",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "单跳_精确N1",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
// === 攻击者伪造首值 ===
{
name: "伪造_启发式跳过fake",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "伪造_精确N1也跳过fake",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4", // target=1, 跳过 fake 取 real
},
{
name: "伪造_多个假值前缀",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake1, fake2, 1.2.3.4",
want: "1.2.3.4", // 从尾扫,只看最后一个
},
// === 多跳 CDN+nginx ===
{
name: "多跳_启发式返回CDN边缘",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 链尾公网=CDN 边缘
},
{
name: "多跳_精确N2返回真实client",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "1.2.3.4", // 倒数第2=真实 client
},
{
name: "多跳_精确N1不够穿透",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 数到 nginx,没穿透到 client
},
// === 链尾私有 IP ===
{
name: "链尾私有_启发式跳过",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "1.2.3.4", // 跳过私有取公网
},
{
name: "链尾私有_精确N1取末值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "10.0.0.1", // 精确模式不跳私有
},
// === X-Real-IP 兜底 ===
{
name: "无XFF_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XFF全非法_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_ip, also_not",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XRI被XFF优先_但XFF全非法",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip",
xri: "1.2.3.4",
want: "1.2.3.4",
},
// === 全部私有 IP(启发式无解)===
{
name: "全私有_启发式回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "192.168.1.1, 172.16.0.1",
want: "10.0.0.1", // 全跳私有,走 directIP
},
// === 畸形/空 XFF ===
{
name: "畸形XFF_启发式跳过畸形",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "全空XFF_回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " , , ",
want: "10.0.0.1",
},
{
name: "XFF带前后空格",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " 1.2.3.4 , 5.6.7.8 ",
want: "5.6.7.8", // TrimSpace 处理
},
// === 精确模式 N 超出链长 ===
{
name: "精确N超出链长_回退到首值",
mode: 5,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0 保护,取首个合法
},
{
name: "精确N等于链长_取首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target=0
},
{
name: "精确N大于链长_取首值",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0,fall back
},
// === 精确模式链中含畸形 ===
{
name: "精确N1_链中畸形回退到首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, not_ip",
want: "1.2.3.4", // target=1(not_ip 失败)→ i=0(1.2.3.4 成功)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trustedProxyHops = tt.mode
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
}
if tt.xri != "" {
r.Header.Set("X-Real-IP", tt.xri)
}
got := GetClientIP(r)
if got != tt.want {
t.Errorf("GetClientIP() = %q, want %q", got, tt.want)
}
})
}
// 重置为默认,避免影响其他测试或运行时行为
trustedProxyHops = 0
}