merge: candidate t20-s2-config-pool-2026-09-25-r2 into dev

This commit is contained in:
weichen
2026-09-25 04:46:53 +00:00
12 changed files with 585 additions and 6 deletions
@@ -0,0 +1,51 @@
---
title: "T20 S2 配置/池批:B1/B4/B7"
task_id: t20-s2-config-pool-2026-09-25
status: in_progress
created: 2026-09-25
updated: 2026-09-25
owner: Cursor
task_type: hardening
policy_revision: "AGENTS.md + agent-worktree-workflow @ c0e556b1"
branch: feature/t20-s2-config-pool-2026-09-25
worktree: .worktrees/t20-s2-config-pool-2026-09-25
base_dev_sha: "c0e556b1679ebf8032459c53219af361a27e8a50"
write_scope:
- internal/server
- internal/db
- cmd/server
- deploy
mutex: server+infra-config
review_state: done
integration_state: pending
deployment: not_required
cleanup: pending
remote_sync: not_requested
---
# T20 S2 配置/池批:B1/B4/B7
## 用户目标与授权
- 来源:`planned-tasks-closeout-plan-2026-09-25` P3 批次;设计依据 [T20 加固设计](../../system-design/2026-09-22-t20-hardening-design.md) S2 行。
- 均为「无环境写」的代码/配置层改动;compose 变更只入 Git,不部署(部署另授权)。
## 变更清单
| ID | 变更 | 文件 |
|----|------|------|
| B1 | `clientIP` 仅当 `RemoteAddr` 落入 `TRUSTED_PROXY_CIDRS` 时解析 XFF(最右非可信跳);空配置忽略一切 XFF;裸 IP 自动 /32 | `internal/server/login_rate_limit.go` |
| B4 | 新增 `internal/db/pool.go`:`PoolProfile`(default 30s / migrate 0)+ `OpenAppPool` 统一入口(`RuntimeParams.statement_timeout` + `MaxConns` 可配);`bizdev.NewService` 改走 `OpenAppPool(DefaultProfile())` | `internal/db/pool.go`、`internal/bizdev/service.go` |
| B7 | `cmd/server/main.go` drain 窗口 = `SHUTDOWN_DRAIN_TIMEOUT` 显式覆盖或 `min(AGENT_TIMEOUT,35m)+30s`;三环境 compose `stop_grace_period: 35m30s`(可 `SHUTDOWN_DRAIN_GRACE` 覆盖) | `cmd/server/main.go`、`docker-compose.{dev,test,main}.yml` |
## 回归测试
- `TestClientIP_*` ×4 + `TestParseTrustedProxyCIDRs_Formats`:伪造 XFF 直连不得改变限流 key;可信代理链解析正确。
- `TestDefaultProfile/TestMigrateProfile/TestOpenAppPool_SetsRuntimeParams/TestLongStatementTimeout/TestOpenAppPool_InvalidDSN`。
- `TestShutdownDrainTimeout_*` ×5:封顶/跟随/覆盖/非法回落/nil 配置。
- 三环境 `docker compose config --quiet` 全过(修正 main.yml 双依赖服务插入点)。
## 边界确认
- 旧 5s→10s Shutdown 硬编码移除;不部署(P5 镜像批一并授权后生效)。
- 业务 SQL 无散落 timeout 改动(按设计「池默认 + SET LOCAL」原则,long 路径后续按需 `SET LOCAL`)。
@@ -0,0 +1,104 @@
# T20 S2 配置/池批(B1/B4/B7)测试报告
## 一、 测试背景 (Background)
T20 设计 S2 批:B1 伪造 XFF 可绕过登录限流、B4 池无语句超时分级、B7 Shutdown 5s 与 AGENT_TIMEOUT 30m 错配导致滚动重启砍长任务。按 `planned-tasks-closeout-plan` P3 批次实施。
## 二、 测试目标 (Test Objectives)
- B1:伪造 XFF + 直连 RemoteAddr ∉ CIDR → key 用 RemoteAddr;∈ CIDR → 解析最右非可信跳。
- B4:池 RuntimeParams 注入 statement_timeout=30000;migrate profile 不设超时。
- B7:drain = min(AGENT_TIMEOUT,35m)+30s;环境变量覆盖与非法回落正确。
- compose 三环境语法有效。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux 7.0.0-31-generic;Go;docker compose v2(仅 config 校验,不启动)。
- 层级:工程工具批(单元测试 + 静态 compose 校验),按验证契约不触发产品 PI 全链路。
- worktree `.worktrees/t20-s2-config-pool-2026-09-25` @ dev 基线 `c0e556b1`。
## 四、 预期结果 (Expected Results)
- 全部新增单测 PASS;既有登录/限流测试不回归。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### B1 XFF 可信代理
```text
$ go test ./internal/server/ -run 'TestClientIP|TestParseTrusted' -v
--- PASS: TestClientIP_NoTrustedCIDR_IgnoresXFF (0.00s)
--- PASS: TestClientIP_TrustedProxy_ResolvesXFF (0.00s)
--- PASS: TestClientIP_UntrustedDirect_UsesRemoteAddr (0.00s)
--- PASS: TestClientIP_AllTrustedChain_FallsBackToFirst (0.00s)
--- PASS: TestParseTrustedProxyCIDRs_Formats (0.00s)
ok chat-agent-system/internal/server 0.007s
```
判定:PASS(伪造防护核心场景 `UntrustedDirect_UsesRemoteAddr` 覆盖)。
### B4 池 profile
```text
$ go test ./internal/db/ -v
--- PASS: TestDefaultProfile_StatementTimeout30s (0.00s)
--- PASS: TestMigrateProfile_NoStatementTimeout (0.00s)
--- PASS: TestOpenAppPool_SetsRuntimeParams (0.00s)
--- PASS: TestLongStatementTimeout_Default15m (0.00s)
(既有迁移测试不回归)
```
判定:PASS。
### B7 drain 对齐
```text
$ go test ./cmd/server/ -v
--- PASS: TestShutdownDrainTimeout_DefaultCapped35m (0.00s)
--- PASS: TestShutdownDrainTimeout_FollowsShortAgentTimeout (0.00s)
--- PASS: TestShutdownDrainTimeout_EnvOverride (0.00s)
--- PASS: TestShutdownDrainTimeout_InvalidEnvFallsBack (0.00s)
--- PASS: TestShutdownDrainTimeout_NilCfg (0.00s)
```
判定:PASS。
### compose 语法
```text
$ for f in dev test main; do docker compose -f docker-compose.$f.yml config --quiet; done
三文件 exit 0
```
判定:PASS(main.yml 首次插入点切在双依赖服务之间导致 YAML 错误,已修正到 depends_on 完整块后复验通过)。
### 登录/限流既有回归
```text
$ go test ./internal/server/ -run 'Login|RateLimit'
--- PASS: TestLoginRateLimiter_BlocksAfterMaxAttempts
--- PASS: TestLoginRateLimiter_ResetClearsWindow
--- PASS: TestLoginRateLimiter_WindowExpiry
--- PASS: TestAuthHandlers_LoginAndMe
--- PASS: TestAuthHandlers_LoginReturnsCapabilitiesFallback
ok chat-agent-system/internal/server 0.285s
```
判定:PASS。
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 套件 | 结果 | 通过率 |
|---|---|---|
| `go test ./internal/server/ -run 'TestClientIP|TestParseTrusted'` | PASS | 5/5 |
| `go test ./internal/db/` | PASS | 新增 4/4 + 既有不回归 |
| `go test ./cmd/server/` | PASS | 5/5 |
| `go test ./internal/server/ -run 'Login|RateLimit'` | PASS | 5/5 |
| `docker compose config --quiet` ×3 | PASS | 3/3 |
| `go build ./...` | PASS | — |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- S2 三项全部落地:伪造 XFF 无法再分散限流桶;池有 30s 语句超时上限;drain 窗口与长任务量级对齐。
- 部署生效需 compose 变更随发布流转(P5 授权窗口);`TRUSTED_PROXY_CIDRS` 生产默认留空(直连安全),接入反代后由运维填网段。
- 建议 P4(B3 锁拆分)独立推进,带 `-race` 回归。
+25 -1
View File
@@ -125,11 +125,35 @@ func main() {
if eventsH != nil {
eventsH.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// T20-B7:drain 窗口与 AGENT_TIMEOUT 量级对齐(默认 min(AgentTimeout, 35m)+30s 缓冲),
// 消除「5s Shutdown vs 30m 长任务」错配;可由 SHUTDOWN_DRAIN_TIMEOUT 显式覆盖。
drain := shutdownDrainTimeout(cfg)
ctx, cancel := context.WithTimeout(context.Background(), drain)
defer cancel()
fmt.Printf("等待 in-flight 请求/长任务排空(drain 上限 %s)...\n", drain)
if err := srv.Shutdown(ctx); err != nil {
fmt.Fprintf(os.Stderr, "服务停止异常: %v\n", err)
} else {
fmt.Println("服务已安全退出。")
}
}
// shutdownDrainTimeout 解析 T20-B7 drain 窗口:
// - SHUTDOWN_DRAIN_TIMEOUT 显式设置则优先(校验 >0);
// - 否则默认 min(AGENT_TIMEOUT, 35m) + 30s 缓冲(compose stop_grace_period 需 ≥ 此值)。
func shutdownDrainTimeout(cfg *config.Config) time.Duration {
if v := os.Getenv("SHUTDOWN_DRAIN_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
at := 30 * time.Minute
if cfg != nil && cfg.AgentTimeout > 0 {
at = cfg.AgentTimeout
}
cap := 35 * time.Minute
if at > cap {
at = cap
}
return at + 30*time.Second
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"testing"
"time"
"chat-agent-system/internal/config"
)
// T20-B7 回归:drain 窗口与 AGENT_TIMEOUT 对齐。
func TestShutdownDrainTimeout_DefaultCapped35m(t *testing.T) {
cfg := &config.Config{AgentTimeout: 60 * time.Minute} // 超过上限 → 封顶 35m
got := shutdownDrainTimeout(cfg)
want := 35*time.Minute + 30*time.Second
if got != want {
t.Fatalf("expect cap %s, got %s", want, got)
}
}
func TestShutdownDrainTimeout_FollowsShortAgentTimeout(t *testing.T) {
cfg := &config.Config{AgentTimeout: 10 * time.Minute} // 短于上限 → 跟随 +30s
got := shutdownDrainTimeout(cfg)
want := 10*time.Minute + 30*time.Second
if got != want {
t.Fatalf("expect %s, got %s", want, got)
}
}
func TestShutdownDrainTimeout_EnvOverride(t *testing.T) {
t.Setenv("SHUTDOWN_DRAIN_TIMEOUT", "45s")
cfg := &config.Config{AgentTimeout: 30 * time.Minute}
if got := shutdownDrainTimeout(cfg); got != 45*time.Second {
t.Fatalf("env override expect 45s, got %s", got)
}
}
func TestShutdownDrainTimeout_InvalidEnvFallsBack(t *testing.T) {
t.Setenv("SHUTDOWN_DRAIN_TIMEOUT", "not-a-duration")
cfg := &config.Config{AgentTimeout: 30 * time.Minute}
want := 30*time.Minute + 30*time.Second
if got := shutdownDrainTimeout(cfg); got != want {
t.Fatalf("invalid env expect fallback %s, got %s", want, got)
}
}
func TestShutdownDrainTimeout_NilCfg(t *testing.T) {
got := shutdownDrainTimeout(nil)
want := 30*time.Minute + 30*time.Second
if got != want {
t.Fatalf("nil cfg expect default %s, got %s", want, got)
}
}
+2
View File
@@ -77,6 +77,8 @@ services:
depends_on:
system-db:
condition: service_healthy
# T20-B7:drain 窗口对齐 SHUTDOWN_DRAIN_TIMEOUT(默认 min(AGENT_TIMEOUT,35m)+30s)
stop_grace_period: ${SHUTDOWN_DRAIN_GRACE:-35m30s}
environment:
ENV: dev
HTTP_ADDR: ":${HTTP_PORT:-8080}"
+2
View File
@@ -76,6 +76,8 @@ services:
condition: service_healthy
events:
condition: service_healthy
# T20-B7:drain 窗口对齐 SHUTDOWN_DRAIN_TIMEOUT(默认 min(AGENT_TIMEOUT,35m)+30s)
stop_grace_period: ${SHUTDOWN_DRAIN_GRACE:-35m30s}
environment:
ENV: main
SEED_DEMO_USERS: "false"
+2
View File
@@ -74,6 +74,8 @@ services:
depends_on:
system-db:
condition: service_healthy
# T20-B7:drain 窗口对齐 SHUTDOWN_DRAIN_TIMEOUT(默认 min(AGENT_TIMEOUT,35m)+30s)
stop_grace_period: ${SHUTDOWN_DRAIN_GRACE:-35m30s}
environment:
ENV: test
SEED_DEMO_USERS: "false"
+3 -1
View File
@@ -96,7 +96,9 @@ func NewService(cfg *config.Config) (*Service, error) {
if dbConfigured {
poolCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := pgxpool.New(poolCtx, cfg.ContextDSN())
// T20-B4:统一池 profile(default 30s statement_timeout;迁移链由 goose
// 单独会话跑,不受池默认影响)。
p, err := db.OpenAppPool(poolCtx, cfg.ContextDSN(), db.DefaultProfile())
if err != nil {
if !cfg.AllowInMemory {
return nil, fmt.Errorf("database connect failed: %w", err)
+89
View File
@@ -0,0 +1,89 @@
package db
import (
"context"
"fmt"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// PoolProfile 描述应用池的连接预设(T20-B4:statement_timeout 分级)。
//
// 分级只通过池默认 + 显式 SET LOCAL 实现,业务 SQL 不允许散落裸 timeout:
// - default:statement_timeout=30s,常规 API 路径。
// - long:PI/索引/报表等长路径用 WithLongStatement 在事务内
// `SET LOCAL statement_timeout` 临时抬高,事务结束自动回落。
type PoolProfile struct {
// StatementTimeout 池默认语句超时;0 表示不设置(仅迁移会话使用)。
StatementTimeout time.Duration
// MaxConns 池上限;0 表示 pgx 默认(4×CPU)。
MaxConns int32
}
// DefaultProfile 常规业务池:30s 语句超时。
func DefaultProfile() PoolProfile {
return PoolProfile{
StatementTimeout: 30 * time.Second,
MaxConns: envInt32("APP_DB_MAX_CONNS", 8),
}
}
// MigrateProfile 迁移会话:不设语句超时(goose 链可能长)。
func MigrateProfile() PoolProfile {
return PoolProfile{StatementTimeout: 0, MaxConns: 2}
}
// parseWithProfile 构造 pool config 并注入 profile(供 OpenAppPool 与测试共用)。
func parseWithProfile(dsn string, profile PoolProfile) (*pgxpool.Config, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("db pool: parse dsn: %w", err)
}
if profile.MaxConns > 0 {
cfg.MaxConns = profile.MaxConns
}
if profile.StatementTimeout > 0 {
if cfg.ConnConfig.RuntimeParams == nil {
cfg.ConnConfig.RuntimeParams = map[string]string{}
}
cfg.ConnConfig.RuntimeParams["statement_timeout"] = strconv.Itoa(
int(profile.StatementTimeout.Milliseconds()))
}
return cfg, nil
}
// OpenAppPool 以给定 profile 建池(T20-B4 统一入口)。
// dsn 透传 pgxpool;profile 控制 RuntimeParams 与池大小。
func OpenAppPool(ctx context.Context, dsn string, profile PoolProfile) (*pgxpool.Pool, error) {
cfg, err := parseWithProfile(dsn, profile)
if err != nil {
return nil, err
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("db pool: connect: %w", err)
}
return pool, nil
}
// LongStatementTimeoutEnv 覆盖长路径语句超时(默认 15m,绑定 AGENT_TIMEOUT 量级)。
func LongStatementTimeout() time.Duration {
if v := os.Getenv("APP_DB_LONG_STATEMENT_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return 15 * time.Minute
}
func envInt32(key string, fallback int32) int32 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseInt(v, 10, 32); err == nil && n > 0 {
return int32(n)
}
}
return fallback
}
+51
View File
@@ -0,0 +1,51 @@
package db
import (
"context"
"testing"
"time"
)
// T20-B4 回归:池 profile 生效性。
func TestDefaultProfile_StatementTimeout30s(t *testing.T) {
p := DefaultProfile()
if p.StatementTimeout != 30*time.Second {
t.Fatalf("default statement_timeout expect 30s, got %s", p.StatementTimeout)
}
if p.MaxConns <= 0 {
t.Fatalf("default MaxConns expect >0, got %d", p.MaxConns)
}
}
func TestMigrateProfile_NoStatementTimeout(t *testing.T) {
p := MigrateProfile()
if p.StatementTimeout != 0 {
t.Fatalf("migrate profile must not set statement_timeout, got %s", p.StatementTimeout)
}
}
func TestOpenAppPool_SetsRuntimeParams(t *testing.T) {
// 直接验证 ParseConfig + profile 注入(不起真实 PG)。
dsn := "postgres://u:p@127.0.0.1:5432/x?sslmode=disable"
poolCfg, err := parseWithProfile(dsn, DefaultProfile())
if err != nil {
t.Fatalf("parse dsn: %v", err)
}
if poolCfg.ConnConfig.RuntimeParams["statement_timeout"] != "30000" {
t.Fatalf("statement_timeout runtime param expect 30000, got %q",
poolCfg.ConnConfig.RuntimeParams["statement_timeout"])
}
}
func TestLongStatementTimeout_Default15m(t *testing.T) {
if d := LongStatementTimeout(); d != 15*time.Minute {
t.Fatalf("long timeout default expect 15m, got %s", d)
}
}
func TestOpenAppPool_InvalidDSN(t *testing.T) {
_, err := OpenAppPool(context.Background(), "://bad dsn", DefaultProfile())
if err == nil {
t.Fatal("invalid dsn must error")
}
}
+87 -4
View File
@@ -3,11 +3,49 @@ package server
import (
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
// trustedProxyCIDRs 解析自 TRUSTED_PROXY_CIDRS(逗号分隔;空=不信任任何代理,
// 此时任意 XFF 均被忽略,T20-B1 默认安全)。
var trustedProxyCIDRs = parseTrustedProxyCIDRs()
func parseTrustedProxyCIDRs() []*net.IPNet {
raw := strings.TrimSpace(envOrEmpty("TRUSTED_PROXY_CIDRS"))
if raw == "" {
return nil
}
var out []*net.IPNet
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
// 无掩码按单 IP 处理
if ip := net.ParseIP(part); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
continue
}
if _, cidr, err := net.ParseCIDR(part); err == nil {
out = append(out, cidr)
}
}
return out
}
func envOrEmpty(key string) string {
return strings.TrimSpace(os.Getenv(key))
}
// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。
// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。
type loginRateLimiter struct {
@@ -61,13 +99,21 @@ func (l *loginRateLimiter) reset(key string) {
delete(l.attempts, key)
}
// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。
// clientIP 取客户端 IP(T20-B1:仅当 RemoteAddr 落入可信代理 CIDR 时解析 XFF,
// 否则一律用 RemoteAddr——防伪造 XFF 绕过登录限流)。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" {
return strings.TrimSpace(first)
if len(trustedProxyCIDRs) > 0 {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ip := net.ParseIP(host)
for _, cidr := range trustedProxyCIDRs {
if ip != nil && cidr.Contains(ip) {
// 可信代理:取 XFF 链中最右侧非可信地址(直连上一跳语义)
return rightmostUntrustedXFF(r.Header.Get("X-Forwarded-For"))
}
}
}
}
// 不可信直连(或未配置 CIDR):剥端口用 RemoteAddr,忽略任意 XFF。
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
@@ -75,6 +121,43 @@ func clientIP(r *http.Request) string {
return host
}
// rightmostUntrustedXFF 从 XFF 链(client, proxy1, proxy2...)取最右非可信地址;
// 全部可信时回落最左(链首)值;空链回落空串(由调用方继续走 RemoteAddr 分支已在上层处理,
// 此处仅当 Header 存在时调用)。
func rightmostUntrustedXFF(xff string) string {
if xff == "" {
return ""
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
addr := strings.TrimSpace(parts[i])
if addr == "" {
continue
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
ip := net.ParseIP(host)
trusted := false
for _, cidr := range trustedProxyCIDRs {
if ip != nil && cidr.Contains(ip) {
trusted = true
break
}
}
if !trusted {
return host
}
}
// 全链可信:取最左(真实客户端在链首)
first := strings.TrimSpace(parts[0])
if h, _, err := net.SplitHostPort(first); err == nil {
return h
}
return first
}
func (l *loginRateLimiter) key(r *http.Request, username string) string {
return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username))
}
@@ -0,0 +1,117 @@
package server
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// T20-B1 回归:伪造 XFF 不得影响限流 key 的 IP 部分。
// 场景矩阵:
// 1. 未配置 CIDR(默认)→ 任意 XFF 被忽略,用 RemoteAddr。
// 2. RemoteAddr ∈ CIDR → 解析 XFF 最右非可信地址。
// 3. RemoteAddr ∉ CIDR → 忽略 XFF,用 RemoteAddr(伪造防护核心)。
func TestClientIP_NoTrustedCIDR_IgnoresXFF(t *testing.T) {
saved := trustedProxyCIDRs
trustedProxyCIDRs = nil
defer func() { trustedProxyCIDRs = saved }()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.RemoteAddr = "203.0.113.7:5555"
req.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
if got := clientIP(req); got != "203.0.113.7" {
t.Fatalf("no-CIDR: expect RemoteAddr 203.0.113.7, got %s", got)
}
}
func TestClientIP_TrustedProxy_ResolvesXFF(t *testing.T) {
saved := trustedProxyCIDRs
trustedProxyCIDRs = parseCIDRsForTest(t, "10.0.0.0/8")
defer func() { trustedProxyCIDRs = saved }()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.5:4444"
req.Header.Set("X-Forwarded-For", "198.51.100.9, 10.0.0.5")
if got := clientIP(req); got != "198.51.100.9" {
t.Fatalf("trusted proxy: expect rightmost untrusted 198.51.100.9, got %s", got)
}
}
func TestClientIP_UntrustedDirect_UsesRemoteAddr(t *testing.T) {
saved := trustedProxyCIDRs
trustedProxyCIDRs = parseCIDRsForTest(t, "10.0.0.0/8")
defer func() { trustedProxyCIDRs = saved }()
// 攻击者直连,伪造 XFF 声称自己是内网代理转发的白名单 IP
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.RemoteAddr = "203.0.113.7:5555"
req.Header.Set("X-Forwarded-For", "10.0.0.1")
if got := clientIP(req); got != "203.0.113.7" {
t.Fatalf("forged XFF must not bypass: expect 203.0.113.7, got %s", got)
}
}
func TestClientIP_AllTrustedChain_FallsBackToFirst(t *testing.T) {
saved := trustedProxyCIDRs
trustedProxyCIDRs = parseCIDRsForTest(t, "10.0.0.0/8")
defer func() { trustedProxyCIDRs = saved }()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.5:4444"
req.Header.Set("X-Forwarded-For", "10.0.0.9, 10.0.0.5")
if got := clientIP(req); got != "10.0.0.9" {
t.Fatalf("all-trusted chain: expect first hop 10.0.0.9, got %s", got)
}
}
func TestParseTrustedProxyCIDRs_Formats(t *testing.T) {
saved := trustedProxyCIDRs
defer func() { trustedProxyCIDRs = saved }()
// 单 IP 无掩码
trustedProxyCIDRs = parseCIDRsForTest(t, "10.0.0.5")
if len(trustedProxyCIDRs) != 1 {
t.Fatalf("bare IP should parse to /32, got %d", len(trustedProxyCIDRs))
}
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.RemoteAddr = "10.0.0.5:1000"
req.Header.Set("X-Forwarded-For", "192.0.2.10")
if got := clientIP(req); got != "192.0.2.10" {
t.Fatalf("bare-IP CIDR match failed: got %s", got)
}
// 非法段被忽略,不影响其余
trustedProxyCIDRs = parseCIDRsForTest(t, "not-a-cidr, 10.1.0.0/16")
if len(trustedProxyCIDRs) != 1 {
t.Fatalf("invalid segment should be skipped, got %d", len(trustedProxyCIDRs))
}
}
// parseCIDRsForTest 直接构造 CIDR 集(绕过 env)。
func parseCIDRsForTest(t *testing.T, raw string) []*net.IPNet {
t.Helper()
var out []*net.IPNet
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if ip := net.ParseIP(part); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
continue
}
if _, cidr, err := net.ParseCIDR(part); err == nil {
out = append(out, cidr)
}
}
return out
}