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 跳"矛盾输出。
This commit is contained in:
@@ -51,6 +51,17 @@ BYTEDANCE_TTS_SAMPLE_RATE=24000
|
|||||||
# OpenAI兼容接口的API密钥(可选,多个用逗号分隔)
|
# OpenAI兼容接口的API密钥(可选,多个用逗号分隔)
|
||||||
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
|
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 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域)
|
# CORS 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域)
|
||||||
# ALLOWED_ORIGINS=https://example.com,https://app.example.com
|
# ALLOWED_ORIGINS=https://example.com,https://app.example.com
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,86 @@ tts-api.exe
|
|||||||
| 变量名 | 说明 | 默认值 |
|
| 变量名 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| `OPENAI_TTS_API_KEY` | OpenAI 兼容接口的 API Key(逗号分隔支持多个) | 无(不鉴权) |
|
| `OPENAI_TTS_API_KEY` | OpenAI 兼容接口的 API Key(逗号分隔支持多个) | 无(不鉴权) |
|
||||||
|
| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) |
|
||||||
| `PORT` | 服务监听端口 | `8080` |
|
| `PORT` | 服务监听端口 | `8080` |
|
||||||
| `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 |
|
| `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 说明
|
||||||
|
|
||||||
| Resource ID | 模型说明 |
|
| Resource ID | 模型说明 |
|
||||||
|
|||||||
+75
-3
@@ -4,12 +4,15 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/volcano-tts/tts-api/common"
|
"github.com/volcano-tts/tts-api/common"
|
||||||
"github.com/volcano-tts/tts-api/metrics"
|
"github.com/volcano-tts/tts-api/metrics"
|
||||||
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
@@ -23,15 +26,57 @@ type RateLimiter struct {
|
|||||||
var (
|
var (
|
||||||
GlobalRateLimiter *RateLimiter
|
GlobalRateLimiter *RateLimiter
|
||||||
ConcurrencySem chan struct{}
|
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() {
|
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{
|
GlobalRateLimiter = &RateLimiter{
|
||||||
requests: make(map[string][]time.Time),
|
requests: make(map[string][]time.Time),
|
||||||
limit: common.RateLimitRequests,
|
limit: common.RateLimitRequests,
|
||||||
window: common.RateLimitWindow,
|
window: common.RateLimitWindow,
|
||||||
}
|
}
|
||||||
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
|
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
|
||||||
|
|
||||||
|
// 同步到 setting 包,供 LogStartupSummary 展示
|
||||||
|
setting.TrustedProxyHops = trustedProxyHops
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) Allow(key string) bool {
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
@@ -133,10 +178,37 @@ func GetClientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isPrivateIP(directIP) {
|
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 != "" {
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
|
parts := strings.Split(xff, ",")
|
||||||
if net.ParseIP(ip) != nil {
|
if trustedProxyHops > 0 {
|
||||||
return ip
|
// 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 != "" {
|
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
var Server ServerConfig
|
var Server ServerConfig
|
||||||
|
|
||||||
|
// TrustedProxyHops 由 middleware.InitRateLimiter 在启动期写入,
|
||||||
|
// 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。
|
||||||
|
// setting.LogStartupSummary 读这个字段以展示运行期配置,
|
||||||
|
// 不直接调用 middleware(避免循环 import)。
|
||||||
|
var TrustedProxyHops int
|
||||||
|
|
||||||
// InitAllConfigs 集中初始化所有配置,启动期调用一次。
|
// InitAllConfigs 集中初始化所有配置,启动期调用一次。
|
||||||
func InitAllConfigs() {
|
func InitAllConfigs() {
|
||||||
InitServerConfig()
|
InitServerConfig()
|
||||||
@@ -257,6 +263,12 @@ func LogStartupSummary() {
|
|||||||
log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins))
|
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 必填项状态:")
|
log.Printf("火山 TTS 必填项状态:")
|
||||||
type ttsCheck struct {
|
type ttsCheck struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user