Procházet zdrojové kódy

feat: 静态代码审计,修复逻辑bug和安全漏洞

BaiLuoYan před 4 týdny
rodič
revize
dca0823e0b

+ 32 - 0
README.md

@@ -1187,9 +1187,41 @@ server/
 | `Auth.RefreshSecret` | JWT refreshToken 签名密钥 | — |
 | `Auth.RefreshExpire` | refreshToken 有效期(秒) | `604800`(7d) |
 | `Auth.ManagementKey` | 管理后台登录密钥(`/auth/adminLogin` 接口验证) | — |
+| `BehindProxy` | 是否部署在反向代理(Nginx 等)后面 | `false` |
 
 > **生产环境部署前,务必修改 `AccessSecret`、`RefreshSecret` 和 `ManagementKey` 为安全的随机字符串,并确保产品后端的本地验证密钥与 `AccessSecret` 一致。`ManagementKey` 仅管理后台前端持有,不可泄露给产品端。**
 
+### 反向代理部署
+
+当服务部署在 Nginx 等反向代理后面时,需要开启 `BehindProxy` 配置以正确获取客户端真实 IP(用于登录限流等安全策略):
+
+**1. 配置文件设置**
+
+```yaml
+BehindProxy: true
+```
+
+**2. Nginx 配置要求**
+
+必须在 Nginx 中正确设置 `X-Real-IP` 并清除客户端可伪造的 `X-Forwarded-For`:
+
+```nginx
+server {
+    location /api/ {
+        proxy_pass http://127.0.0.1:10001;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For "";
+        proxy_set_header Host $host;
+    }
+}
+```
+
+**安全说明**:
+
+- `BehindProxy: false`(默认)— 仅使用 TCP 连接的 `RemoteAddr` 作为客户端 IP,适合服务直接暴露或在无法信任代理头的场景
+- `BehindProxy: true` — 信任 Nginx 设置的 `X-Real-IP` 头获取真实客户端 IP,**必须**确保 Nginx 正确配置且外部无法绕过 Nginx 直连后端
+- gRPC 接口不受此配置影响,始终通过 TCP 对端地址获取 IP
+
 ---
 
 ## 测试

+ 2 - 0
internal/config/config.go

@@ -28,4 +28,6 @@ type Config struct {
 		RefreshExpire int64
 		ManagementKey string
 	}
+
+	BehindProxy bool `json:",optional"`
 }

+ 14 - 5
internal/middleware/ratelimitMiddleware.go

@@ -13,17 +13,18 @@ import (
 )
 
 type RateLimitMiddleware struct {
-	limiter *limit.PeriodLimit
+	limiter     *limit.PeriodLimit
+	behindProxy bool
 }
 
-func NewRateLimitMiddleware(rds *redis.Redis, period int, quota int, keyPrefix string) *RateLimitMiddleware {
+func NewRateLimitMiddleware(rds *redis.Redis, period int, quota int, keyPrefix string, behindProxy bool) *RateLimitMiddleware {
 	limiter := limit.NewPeriodLimit(period, quota, rds, keyPrefix)
-	return &RateLimitMiddleware{limiter: limiter}
+	return &RateLimitMiddleware{limiter: limiter, behindProxy: behindProxy}
 }
 
 func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
-		ip := extractClientIP(r)
+		ip := ExtractClientIP(r, m.behindProxy)
 		key := fmt.Sprintf("ip:%s", ip)
 		code, _ := m.limiter.Take(key)
 		if code == limit.OverQuota {
@@ -34,7 +35,15 @@ func (m *RateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
 	}
 }
 
-func extractClientIP(r *http.Request) string {
+// ExtractClientIP extracts client IP from the request.
+// When behindProxy is true, it trusts X-Real-IP header set by the reverse proxy.
+// When false, it only uses RemoteAddr for security.
+func ExtractClientIP(r *http.Request, behindProxy bool) string {
+	if behindProxy {
+		if ip := r.Header.Get("X-Real-IP"); ip != "" {
+			return ip
+		}
+	}
 	host, _, err := net.SplitHostPort(r.RemoteAddr)
 	if err != nil {
 		return r.RemoteAddr

+ 94 - 4
internal/middleware/ratelimitMiddleware_test.go

@@ -34,7 +34,7 @@ func newTestRedis() *redis.Redis {
 
 func newTestMiddleware(rds *redis.Redis, quota int) *RateLimitMiddleware {
 	prefix := fmt.Sprintf("test_rl_%d_%d", time.Now().UnixNano(), rand.Intn(100000))
-	return NewRateLimitMiddleware(rds, 60, quota, prefix)
+	return NewRateLimitMiddleware(rds, 60, quota, prefix, false)
 }
 
 // TC-0525: 正常请求(未超限)
@@ -86,7 +86,7 @@ func TestRateLimit_OverQuotaRejected(t *testing.T) {
 	assert.Equal(t, "请求过于频繁,请稍后再试", body.Msg)
 }
 
-// TC-0527: X-Forwarded-For被忽略(M-1安全修复验证)
+// TC-0527: behindProxy=false时XFF被忽略
 func TestRateLimit_XForwardedForIgnored(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
@@ -112,7 +112,7 @@ func TestRateLimit_XForwardedForIgnored(t *testing.T) {
 	assert.Equal(t, 1, nextCount, "different X-Forwarded-For should NOT bypass rate limit; RemoteAddr is used")
 }
 
-// TC-0528: X-Real-IP被忽略(M-1安全修复验证)
+// TC-0528: behindProxy=false时X-Real-IP被忽略
 func TestRateLimit_XRealIPIgnored(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
@@ -138,7 +138,7 @@ func TestRateLimit_XRealIPIgnored(t *testing.T) {
 	assert.Equal(t, 1, nextCount, "different X-Real-IP should NOT bypass rate limit; RemoteAddr is used")
 }
 
-// TC-0529: IP从RemoteAddr获取
+// TC-0529: IP从RemoteAddr解析
 func TestRateLimit_IPFromRemoteAddr(t *testing.T) {
 	rds := newTestRedis()
 	m := newTestMiddleware(rds, 1)
@@ -198,3 +198,93 @@ func TestRateLimit_DifferentIPsIndependent(t *testing.T) {
 	handler(httptest.NewRecorder(), req4)
 	assert.Equal(t, 2, nextCount, "addr2 should be over quota")
 }
+
+func newTestMiddlewareProxy(rds *redis.Redis, quota int) *RateLimitMiddleware {
+	prefix := fmt.Sprintf("test_rl_%d_%d", time.Now().UnixNano(), rand.Intn(100000))
+	return NewRateLimitMiddleware(rds, 60, quota, prefix, true)
+}
+
+// TC-0531: behindProxy=true时信任X-Real-IP
+func TestRateLimit_BehindProxy_TrustsXRealIP(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddlewareProxy(rds, 1)
+	remoteAddr := uniqueIP() + ":12345"
+
+	var nextCount int
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		nextCount++
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req1.RemoteAddr = remoteAddr
+	req1.Header.Set("X-Real-IP", uniqueIP())
+	handler(httptest.NewRecorder(), req1)
+	assert.Equal(t, 1, nextCount)
+
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.RemoteAddr = remoteAddr
+	req2.Header.Set("X-Real-IP", uniqueIP())
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 2, nextCount, "different X-Real-IP should have independent quotas when behindProxy=true")
+}
+
+// TC-0532: behindProxy=true时无X-Real-IP回退RemoteAddr
+func TestRateLimit_BehindProxy_FallbackToRemoteAddr(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddlewareProxy(rds, 1)
+	remoteAddr := uniqueIP() + ":12345"
+
+	var nextCount int
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		nextCount++
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req1.RemoteAddr = remoteAddr
+	handler(httptest.NewRecorder(), req1)
+	assert.Equal(t, 1, nextCount)
+
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.RemoteAddr = remoteAddr
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 1, nextCount, "should fall back to RemoteAddr when X-Real-IP is absent")
+}
+
+// TC-0533: behindProxy=true时XFF仍被忽略
+func TestRateLimit_BehindProxy_XFFStillIgnored(t *testing.T) {
+	rds := newTestRedis()
+	m := newTestMiddlewareProxy(rds, 1)
+	remoteAddr := uniqueIP() + ":12345"
+
+	var nextCount int
+	handler := m.Handle(func(w http.ResponseWriter, r *http.Request) {
+		nextCount++
+		w.WriteHeader(http.StatusOK)
+	})
+
+	req1 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req1.RemoteAddr = remoteAddr
+	req1.Header.Set("X-Forwarded-For", uniqueIP())
+	handler(httptest.NewRecorder(), req1)
+	assert.Equal(t, 1, nextCount)
+
+	req2 := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+	req2.RemoteAddr = remoteAddr
+	req2.Header.Set("X-Forwarded-For", uniqueIP())
+	handler(httptest.NewRecorder(), req2)
+	assert.Equal(t, 1, nextCount, "X-Forwarded-For should NOT bypass rate limit even with behindProxy=true")
+}
+
+// TC-0534: RemoteAddr无端口格式
+func TestExtractClientIP_RemoteAddrNoPort(t *testing.T) {
+	req := httptest.NewRequest(http.MethodPost, "/api/test", nil)
+	req.RemoteAddr = "1.2.3.4"
+
+	ip := ExtractClientIP(req, false)
+	assert.Equal(t, "1.2.3.4", ip, "should return raw RemoteAddr when SplitHostPort fails")
+
+	ip2 := ExtractClientIP(req, true)
+	assert.Equal(t, "1.2.3.4", ip2, "behindProxy=true without X-Real-IP should also fallback")
+}

+ 2 - 2
internal/svc/servicecontext.go

@@ -27,8 +27,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	rds := redis.MustNewRedis(c.CacheRedis.Nodes[0].RedisConf)
 	models := model.NewModels(conn, c.CacheRedis.Nodes, c.CacheRedis.KeyPrefix)
 	udLoader := loaders.NewUserDetailsLoader(rds, c.CacheRedis.KeyPrefix, models)
-	rlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login")
-	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync")
+	rlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login", c.BehindProxy)
+	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
 
 	return &ServiceContext{

+ 7 - 3
test-design.md

@@ -919,7 +919,11 @@ MySQL (InnoDB) + Redis Cache
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | TC-0525 | 正常请求(未超限) | 首次请求 | 请求正常通过, next被调用 | 正常路径 | P0 | code!=OverQuota→next |
 | TC-0526 | 超限请求被拒绝 | 超出配额后的请求 | code=429, "请求过于频繁,请稍后再试" | 异常路径 | P0 | code==OverQuota→ErrTooManyRequests |
-| TC-0527 | X-Forwarded-For不可绕过限流 | 不同XFF头+相同RemoteAddr | 仍被限流(XFF不应影响key), 预期: nextCount保持为1 | 安全 | P0 | M-1安全要求: 仅用RemoteAddr防止IP伪造 |
-| TC-0528 | X-Real-IP不可绕过限流 | 不同XRI头+相同RemoteAddr | 仍被限流(XRI不应影响key), 预期: nextCount保持为1 | 安全 | P0 | M-1安全要求: 仅用RemoteAddr防止IP伪造 |
-| TC-0529 | IP从RemoteAddr获取 | 无代理头 | 使用RemoteAddr作为限流key | 分支覆盖 | P0 | 第三个ip获取分支(fallback) |
+| TC-0527 | behindProxy=false时XFF被忽略 | behindProxy=false, 不同XFF头+相同RemoteAddr | 仍被限流, nextCount保持为1 | 安全 | P0 | behindProxy=false: 仅用RemoteAddr |
+| TC-0528 | behindProxy=false时X-Real-IP被忽略 | behindProxy=false, 不同XRI头+相同RemoteAddr | 仍被限流, nextCount保持为1 | 安全 | P0 | behindProxy=false: 仅用RemoteAddr |
+| TC-0529 | IP从RemoteAddr解析 | 无代理头, RemoteAddr="ip:port" | 使用SplitHostPort解析host作为限流key | 分支覆盖 | P0 | SplitHostPort解析host |
 | TC-0530 | 不同IP独立限流 | 两个不同IP | 各自独立计数, 互不影响 | 功能验证 | P0 | key隔离 |
+| TC-0531 | behindProxy=true时信任X-Real-IP | behindProxy=true, 不同X-Real-IP头 | 按X-Real-IP独立限流 | 正常路径 | P0 | behindProxy=true: X-Real-IP优先 |
+| TC-0532 | behindProxy=true时无X-Real-IP回退RemoteAddr | behindProxy=true, 无X-Real-IP头 | 使用RemoteAddr作为限流key | 分支覆盖 | P0 | X-Real-IP为空→fallback RemoteAddr |
+| TC-0533 | behindProxy=true时XFF仍被忽略 | behindProxy=true, XFF头+无X-Real-IP | 按RemoteAddr限流, XFF不影响 | 安全 | P0 | 仅信任X-Real-IP, 不信任XFF |
+| TC-0534 | RemoteAddr无端口格式 | RemoteAddr="1.2.3.4"(无端口) | 返回原始RemoteAddr "1.2.3.4" | 边界 | P1 | SplitHostPort失败→r.RemoteAddr |

+ 14 - 10
test-report.md

@@ -10,13 +10,13 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 558 |
-| 已覆盖 TC 数 | 557 |
+| 测试用例总数 (test-design.md) | 534 |
+| 已覆盖 TC 数 | 533 |
 | 未实现 TC 数 | 1 (TC-0228 不可达防御分支 t.Skip) |
-| 测试函数总数 | 707 |
-| 测试子用例总数 (含 table-driven) | 788 |
+| 测试函数总数 | 711 |
+| 测试子用例总数 (含 table-driven) | 793 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **787 / 788** |
+| ✅ 通过 | **792 / 793** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0228 — 防御性不可达分支) |
 
@@ -34,7 +34,7 @@
 | logic/pub | ✅ ok | 6.485s |
 | logic/role | ✅ ok | 6.197s |
 | logic/user | ✅ ok | 7.753s |
-| middleware | ✅ ok | 8.274s |
+| middleware | ✅ ok | 9.911s |
 | model/dept | ✅ ok | 9.302s |
 | model/perm | ✅ ok | 10.421s |
 | model/product | ✅ ok | 11.193s |
@@ -337,7 +337,7 @@
 | TC-0221 | 超管 | ✅ pass |
 | TC-0222 | MEMBER-DENY覆盖 | ✅ pass |
 
-### 2.13 中间件 / 统一响应 (TC-0223 ~ TC-0233, TC-0508 ~ TC-0510, TC-0525 ~ TC-0530)
+### 2.13 中间件 / 统一响应 (TC-0223 ~ TC-0233, TC-0508 ~ TC-0510, TC-0525 ~ TC-0534)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -357,10 +357,14 @@
 | TC-0233 | 成功(无data) | ✅ pass |
 | TC-0525 | 限流-正常请求(未超限) | ✅ pass |
 | TC-0526 | 限流-超限请求被拒绝(429) | ✅ pass |
-| TC-0527 | 限流-X-Forwarded-For不可绕过(M-1安全修复) | ✅ pass |
-| TC-0528 | 限流-X-Real-IP不可绕过(M-1安全修复) | ✅ pass |
-| TC-0529 | 限流-IP从RemoteAddr获取 | ✅ pass |
+| TC-0527 | 限流-behindProxy=false时XFF被忽略 | ✅ pass |
+| TC-0528 | 限流-behindProxy=false时X-Real-IP被忽略 | ✅ pass |
+| TC-0529 | 限流-IP从RemoteAddr解析 | ✅ pass |
 | TC-0530 | 限流-不同IP独立限流 | ✅ pass |
+| TC-0531 | 限流-behindProxy=true时信任X-Real-IP | ✅ pass |
+| TC-0532 | 限流-behindProxy=true时无X-Real-IP回退RemoteAddr | ✅ pass |
+| TC-0533 | 限流-behindProxy=true时XFF仍被忽略 | ✅ pass |
+| TC-0534 | 限流-RemoteAddr无端口格式 | ✅ pass |
 
 ### 2.14 util 层 (TC-0234 ~ TC-0256)