From 272565f73684acbeea927fd84595f9bfa870f147 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Wed, 26 Aug 2026 00:36:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20VUL-003=20=E5=AE=8C=E6=95=B4=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D,=E5=90=AF=E5=8F=91=E5=BC=8F/=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E5=8F=8C=E6=A8=A1=E5=BC=8F=20XFF=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 跳"矛盾输出。 --- .env.example | 11 ++ README.md | 77 ++++++++++++ middleware/ratelimit.go | 78 +++++++++++- middleware/ratelimit_test.go | 229 +++++++++++++++++++++++++++++++++++ setting/config.go | 12 ++ 5 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 middleware/ratelimit_test.go diff --git a/.env.example b/.env.example index a65dfb4..b44a3c9 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ BYTEDANCE_TTS_SAMPLE_RATE=24000 # OpenAI兼容接口的API密钥(可选,多个用逗号分隔) OPENAI_TTS_API_KEY=your_openai_compatible_key_here +# 反代拓扑配置(0-10)。控制 X-Forwarded-For 解析方式,影响 IP 限流的 key。 +# 不设置 / 0:启发式模式(默认)—— 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP +# 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数 +# N (N>0) :精确模式 —— 精准到真实 client IP,需要正确配置跳数 +# N=1:单跳反代(client → nginx → 本服务) +# N=2:双跳反代(client → CDN → nginx → 本服务,如 Cloudflare + nginx) +# N=3:三跳,以此类推 +# 直出部署(无反代):无需配置,XFF 分支不会执行 +# 详见 README "反代拓扑与 X-Forwarded-For 解析"章节 +# TRUSTED_PROXY_HOPS= + # CORS 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域) # ALLOWED_ORIGINS=https://example.com,https://app.example.com diff --git a/README.md b/README.md index 5ac04b1..44f4f2b 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,86 @@ tts-api.exe | 变量名 | 说明 | 默认值 | |--------|------|--------| | `OPENAI_TTS_API_KEY` | OpenAI 兼容接口的 API Key(逗号分隔支持多个) | 无(不鉴权) | +| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) | | `PORT` | 服务监听端口 | `8080` | | `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 | +### 反代拓扑与 X-Forwarded-For 解析 + +当服务部署在反代(nginx / caddy / CDN)后面时,反代会通过 `X-Forwarded-For`(XFF)头传递真实客户端 IP。本服务通过 `TRUSTED_PROXY_HOPS` 环境变量控制 XFF 解析方式,支持两种模式。 + +#### 何时需要关心这个配置 + +| 部署方式 | 是否需要配置 | +|---|---| +| 服务直接暴露公网 IP(无反代)| ❌ 不适用,跳过本节 | +| 服务前有 1 个反代(nginx / caddy)| ❌ 不必配置,启发式模式自动处理 | +| 服务前有 2 跳以上反代(CDN + 自建反代)| ⚠️ 启发式模式"够用",需要精准按真实 client 限流时再设 | + +> **直出部署(无反代)的用户**:本节不适用,跳过阅读。`TRUSTED_PROXY_HOPS` 在你的部署下不会被读取。 + +#### 启发式模式(默认 / `TRUSTED_PROXY_HOPS=0`) + +从 XFF 链尾向前扫描,**跳过私有 IP,返回第一个公网 IP**。 + +适用场景:单跳反代(最常见)、多跳含公网代理(CDN + nginx)。 + +**行为示例**: + +| XFF 链 | 启发式返回 | 备注 | +|---|---|---| +| `1.2.3.4` | `1.2.3.4` | 单跳,真实 client | +| `fake, 1.2.3.4` | `1.2.3.4` | 攻击者伪造首值,跳过 fake | +| `1.2.3.4, 5.6.7.8, 10.0.0.1` | `5.6.7.8` | 多跳,返回最末公网 IP(CDN 边缘) | +| `1.2.3.4, 192.168.1.1` | `1.2.3.4` | 链尾是私有 IP,跳过 | + +**优点**:零配置,大多数部署自动正确。 + +**限制**:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"而非"按真实 client"。攻击者填满某 CDN 边缘配额可能影响该 CDN 下的其他用户——但无法伪造身份、无法越权。 + +#### 精确模式(`TRUSTED_PROXY_HOPS=N`,N > 0) + +从 XFF 链尾倒数第 N+1 个位置取值,即"信任最近 N 跳反代,取该信任链之前那一跳的 IP"。 + +适用场景:多跳 CDN + 反代,且需要精准按真实 client 限流。 + +**N 的确定方法**:统计客户端到本服务之间的反代跳数。 + +| 拓扑 | 跳数 | 配置 | +|---|---|---| +| `client → nginx → 本服务` | 1 | `TRUSTED_PROXY_HOPS=1` | +| `client → Cloudflare → nginx → 本服务` | 2 | `TRUSTED_PROXY_HOPS=2` | +| `client → CDN → WAF → nginx → 本服务` | 3 | `TRUSTED_PROXY_HOPS=3` | + +**行为对比**(以 `client(1.2.3.4) → CDN(203.0.113.5) → nginx(10.0.0.1) → 本服务` 为例,XFF 链 = `1.2.3.4, 203.0.113.5`): + +| `TRUSTED_PROXY_HOPS` | 返回 | 评价 | +|---|---|---| +| 0(默认启发式)| `203.0.113.5` | CDN 边缘 IP,限流粒度粗 | +| 1(数到 nginx,未穿透)| `203.0.113.5` | 配置不当,与默认相同 | +| 2(穿透到真实 client)| `1.2.3.4` | 精准到真实 client ✓ | +| 3(超出实际跳数)| `directIP`(链长不足保护)| 配置错误,需修正 | + +#### 为什么两种模式都从链尾扫描 + +XFF 链的第一个值是**客户端可控**的:攻击者可以发送任意 `X-Forwarded-For: 1.2.3.4`,若反代用追加模式(如 nginx 默认的 `$proxy_add_x_forwarded_for`),链尾才会追加真实 IP。 + +若代码取首值,攻击者每次换伪造 IP 即可绕过 IP 限流,也可伪装成受害 IP 把其配额耗尽(间接 DoS)。两种模式都从链尾扫描,天然免疫这种攻击。 + +#### 验证当前模式 + +启动期日志会显示当前模式: + +``` +TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP) +# 或 +已配置 TRUSTED_PROXY_HOPS=0(启发式模式,等同默认) +# 或 +已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代) +``` + +也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或写一个 Go 测试用例(参见 DEBT-1 单元测试任务)来覆盖不同 XFF 链场景。生产环境不要保留 debug 日志。 + ### Resource ID 说明 | Resource ID | 模型说明 | diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 3fafc5d..8cddfc4 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -4,12 +4,15 @@ import ( "log" "net" "net/http" + "os" + "strconv" "strings" "sync" "time" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/metrics" + "github.com/volcano-tts/tts-api/setting" ) type RateLimiter struct { @@ -23,15 +26,57 @@ type RateLimiter struct { var ( GlobalRateLimiter *RateLimiter ConcurrencySem chan struct{} + // trustedProxyHops controls how X-Forwarded-For (XFF) is parsed when the + // direct connection comes from a private IP (i.e., we're behind a reverse + // proxy). Two modes are supported, switched by this single value: + // + // HEURISTIC MODE (trustedProxyHops == 0, the default): + // Walk XFF from the end, return the first PUBLIC IP. Skips private + // and loopback hops automatically. Works for ~90% of deployments + // without the operator needing to know the exact number of proxy + // hops. Trade-off in multi-hop: rate limiting is per-CDN-edge rather + // than per-real-client, which is "good enough" for abuse protection + // but not for fine-grained per-user quotas. + // + // PRECISE MODE (trustedProxyHops > 0): + // Count back N hops from the end of XFF and return that value. Gives + // precise per-real-client rate limiting even in multi-hop setups + // (e.g., Cloudflare + nginx). Operator MUST set this to the number + // of trusted reverse proxies between this service and the client. + // + // Both modes walk from the END of the XFF chain. The first value is + // client-controllable; trusting it would let attackers bypass IP rate + // limiting by sending a forged X-Forwarded-For header. + trustedProxyHops = 0 ) func InitRateLimiter() { + switch v := os.Getenv("TRUSTED_PROXY_HOPS"); { + case v == "": + log.Printf("TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP)") + default: + n, err := strconv.Atoi(v) + switch { + case err != nil || n < 0 || n > 10: + log.Printf("警告: TRUSTED_PROXY_HOPS=%q 无效(需 0-10 的整数),回退到默认启发式模式", v) + case n == 0: + // "0" 或 "00" 等被 Atoi 解析为 0 的形式都归到启发式模式, + // 避免日志出现"精确模式, 信任 0 跳"这种自相矛盾的输出。 + log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(启发式模式,等同默认)", n) + default: + trustedProxyHops = n + log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(精确模式,信任 %d 跳反代)", n, n) + } + } GlobalRateLimiter = &RateLimiter{ requests: make(map[string][]time.Time), limit: common.RateLimitRequests, window: common.RateLimitWindow, } ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests) + + // 同步到 setting 包,供 LogStartupSummary 展示 + setting.TrustedProxyHops = trustedProxyHops } func (rl *RateLimiter) Allow(key string) bool { @@ -133,10 +178,37 @@ func GetClientIP(r *http.Request) string { } if isPrivateIP(directIP) { + // Parse X-Forwarded-For when there's a reverse proxy in front (direct + // connection is from a private IP). Both modes walk from the END of + // the chain so that the client-controllable first value cannot be + // used to spoof a different client IP for rate limit bypass. if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - ip := strings.TrimSpace(strings.Split(xff, ",")[0]) - if net.ParseIP(ip) != nil { - return ip + parts := strings.Split(xff, ",") + if trustedProxyHops > 0 { + // PRECISE MODE: count back N hops from end. Real client IP + // sits at index (len(parts) - N). Walk backwards to skip + // any malformed values; if chain is shorter than expected, + // fall through to the first valid IP in the chain. + target := len(parts) - trustedProxyHops + if target < 0 { + target = 0 + } + for i := target; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if net.ParseIP(ip) != nil { + return ip + } + } + } else { + // HEURISTIC MODE (default): walk from end, return first + // PUBLIC IP. Skips private/loopback hops that come from + // internal proxies between the public-facing proxy and us. + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if parsed := net.ParseIP(ip); parsed != nil && !isPrivateIP(ip) { + return ip + } + } } } if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { diff --git a/middleware/ratelimit_test.go b/middleware/ratelimit_test.go new file mode 100644 index 0000000..b9ed2c2 --- /dev/null +++ b/middleware/ratelimit_test.go @@ -0,0 +1,229 @@ +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 +} diff --git a/setting/config.go b/setting/config.go index c48b3b3..2a1d202 100644 --- a/setting/config.go +++ b/setting/config.go @@ -46,6 +46,12 @@ type ServerConfig struct { var Server ServerConfig +// TrustedProxyHops 由 middleware.InitRateLimiter 在启动期写入, +// 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。 +// setting.LogStartupSummary 读这个字段以展示运行期配置, +// 不直接调用 middleware(避免循环 import)。 +var TrustedProxyHops int + // InitAllConfigs 集中初始化所有配置,启动期调用一次。 func InitAllConfigs() { InitServerConfig() @@ -257,6 +263,12 @@ func LogStartupSummary() { log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins)) } + if h := TrustedProxyHops; h == 0 { + log.Printf("TRUSTED_PROXY_HOPS: 启发式模式(默认,XFF 链尾第一个公网 IP)") + } else { + log.Printf("TRUSTED_PROXY_HOPS: 精确模式,信任 %d 跳反代", h) + } + log.Printf("火山 TTS 必填项状态:") type ttsCheck struct { name string