fix(security): unify audit log & session binding client IP with API key ACL trust toggle

Behind a reverse proxy (e.g. nginx with X-Real-IP), admin audit logs and
session IP/UA binding always recorded 127.0.0.1 because they hardcoded
the gin trusted_proxies chain, while API key IP restriction already
honored the "trust forwarded client IP" system setting.

- add ip.GetSecurityClientIP(c, trustForwarded) as the single source of
  truth for security-sensitive client IP selection; API key auth
  middlewares (main + google) refactored onto it with zero behavior change
- SessionBindingContext(cfg) now resolves the client IP via the same
  toggle and injects it into the request context; token issuance,
  binding enforcement and its mismatch audit record all read the
  injected value, so issue/verify can never diverge
- audit log middleware and audit-log clear trace record the same
  security client IP (middleware.SecurityClientIP), falling back to the
  trusted proxy chain when the injection is absent
- settings UI hint (zh/en) documents the broadened toggle scope and the
  one-time re-login after toggling while session binding is enabled

With the toggle off (default) behavior is byte-for-byte unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2026-07-17 09:33:18 +08:00
co-authored by Claude
parent 980a46ffb0
commit 7c48f9a85f
11 changed files with 188 additions and 29 deletions
@@ -97,10 +97,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
// 检查 IP 限制(白名单/黑名单)
// 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
clientIP := ip.GetTrustedClientIP(c)
if cfg.TrustForwardedIPForAPIKeyACL() {
clientIP = ip.GetClientIP(c)
}
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
if !allowed {
if clientIP == "" {
@@ -59,10 +59,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
// 检查 IP 限制(白名单/黑名单)。与主中间件保持一致,避免 Gemini 端点绕过 Key 的 IP ACL。
if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 {
clientIP := ip.GetTrustedClientIP(c)
if cfg.TrustForwardedIPForAPIKeyACL() {
clientIP = ip.GetClientIP(c)
}
clientIP := ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL())
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
if !allowed {
if clientIP == "" {
@@ -7,7 +7,6 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -147,7 +146,7 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle
Action: action,
Method: c.Request.Method,
Path: c.FullPath(),
ClientIP: ip.GetTrustedClientIP(c),
ClientIP: SecurityClientIP(c),
UserAgent: c.Request.UserAgent(),
RequestBody: bodyRedacted,
StatusCode: status,
@@ -1,19 +1,25 @@
package middleware
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// SessionBindingContext 全局中间件:将请求的可信客户端 IP 与 User-Agent 注入
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定。
// 必须使用 GetTrustedClientIP(走 trusted_proxies 链),不可信头会导致绑定被伪造绕过。
func SessionBindingContext() gin.HandlerFunc {
// SessionBindingContext 全局中间件:将请求的客户端 IP 与 User-Agent 注入
// request context,供 token 签发路径(登录 / 刷新 / OAuth 回调)读取并写入会话绑定,
// 同时作为审计日志、会话绑定校验的统一客户端 IP 来源。
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关:
// 开启时信任反代转发头(CF-Connecting-IP / X-Real-IP / X-Forwarded-For),
// 关闭时走 trusted_proxies 解析链,避免不可信头伪造绕过绑定。
func SessionBindingContext(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
binding := &service.SessionBinding{
IP: ip.GetTrustedClientIP(c),
IP: ip.GetSecurityClientIP(c, cfg.TrustForwardedIPForAPIKeyACL()),
UserAgent: c.Request.UserAgent(),
}
c.Request = c.Request.WithContext(service.WithSessionBinding(c.Request.Context(), binding))
@@ -21,13 +27,27 @@ func SessionBindingContext() gin.HandlerFunc {
}
}
// currentSessionBindingHash 计算当前请求的会话指纹哈希。
func currentSessionBindingHash(c *gin.Context) string {
binding := &service.SessionBinding{
// requestSessionBinding 返回当前请求的会话指纹,优先取 SessionBindingContext
// 注入的解析结果(保证与 token 签发路径取值一致);注入缺失时按 trusted_proxies
// 链回退兜底(等价于开关关闭时的行为)。
func requestSessionBinding(c *gin.Context) *service.SessionBinding {
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil {
return binding
}
return &service.SessionBinding{
IP: ip.GetTrustedClientIP(c),
UserAgent: c.Request.UserAgent(),
}
return binding.Hash()
}
// SecurityClientIP 返回当前请求用于安全敏感记录(审计日志等)的客户端 IP。
// 与会话绑定、API Key IP 限制共用同一套「信任反代传递的客户端 IP」开关语义。
func SecurityClientIP(c *gin.Context) string {
if binding := service.SessionBindingFromContext(c.Request.Context()); binding != nil &&
strings.TrimSpace(binding.IP) != "" {
return binding.IP
}
return ip.GetTrustedClientIP(c)
}
// enforceSessionBinding 校验 access token 的会话指纹(IP/UA 绑定)。
@@ -49,7 +69,8 @@ func enforceSessionBinding(
if claims == nil || claims.BindingHash == "" {
return true
}
current := currentSessionBindingHash(c)
binding := requestSessionBinding(c)
current := binding.Hash()
if current == "" || current == claims.BindingHash {
return true
}
@@ -71,7 +92,7 @@ func enforceSessionBinding(
Action: service.AuditActionSessionBindingMismatch,
Method: c.Request.Method,
Path: path,
ClientIP: ip.GetTrustedClientIP(c),
ClientIP: binding.IP,
UserAgent: c.Request.UserAgent(),
StatusCode: 401,
})
@@ -0,0 +1,103 @@
//go:build unit
package middleware
import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 反代场景:RemoteAddr 为 127.0.0.1,真实客户端 IP 在 X-Real-IP 中。
// 会话绑定注入与审计 IP 必须与 API Key IP 限制共用「信任反代传递的客户端 IP」开关语义。
func TestSessionBindingContextHonorsTrustForwardedToggle(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
trustForwarded bool
wantIP string
}{
{name: "trust disabled records proxy address", trustForwarded: false, wantIP: "127.0.0.1"},
{name: "trust enabled records forwarded client IP", trustForwarded: true, wantIP: "1.2.3.4"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &config.Config{}
cfg.SetTrustForwardedIPForAPIKeyACL(tc.trustForwarded)
r := gin.New()
require.NoError(t, r.SetTrustedProxies(nil))
r.Use(SessionBindingContext(cfg))
r.GET("/t", func(c *gin.Context) {
binding := service.SessionBindingFromContext(c.Request.Context())
require.NotNil(t, binding)
require.Equal(t, tc.wantIP, binding.IP)
require.Equal(t, "test-agent", binding.UserAgent)
require.Equal(t, tc.wantIP, SecurityClientIP(c))
c.Status(200)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/t", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Real-IP", "1.2.3.4")
req.Header.Set("User-Agent", "test-agent")
r.ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
})
}
}
// 未经过 SessionBindingContext 注入时(异常挂载顺序/单测直调),回退 trusted_proxies 链,
// 等价于开关关闭时的历史行为。
func TestSecurityClientIPFallsBackWithoutInjectedBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
require.NoError(t, r.SetTrustedProxies(nil))
r.GET("/t", func(c *gin.Context) {
c.String(200, SecurityClientIP(c))
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/t", nil)
req.RemoteAddr = "9.9.9.9:12345"
req.Header.Set("X-Real-IP", "1.2.3.4")
r.ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
require.Equal(t, "9.9.9.9", w.Body.String())
}
// requestSessionBinding 优先取注入值:开关开启时校验哈希必须基于注入的转发 IP 计算,
// 与 token 签发路径取值一致,否则同一客户端会被误判为指纹变化。
func TestRequestSessionBindingPrefersInjectedBinding(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.SetTrustForwardedIPForAPIKeyACL(true)
r := gin.New()
require.NoError(t, r.SetTrustedProxies(nil))
r.Use(SessionBindingContext(cfg))
r.GET("/t", func(c *gin.Context) {
issued := &service.SessionBinding{IP: "1.2.3.4", UserAgent: "test-agent"}
require.Equal(t, issued.Hash(), requestSessionBinding(c).Hash())
c.Status(200)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/t", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Real-IP", "1.2.3.4")
req.Header.Set("User-Agent", "test-agent")
r.ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
}
+3 -2
View File
@@ -54,8 +54,9 @@ func SetupRouter(
// 应用中间件
r.Use(middleware2.RequestLogger())
// 将可信客户端 IP + UA 注入 request context,供 token 签发路径写入会话绑定
r.Use(middleware2.SessionBindingContext())
// 将客户端 IP + UA 注入 request context,供 token 签发/会话绑定/审计日志统一读取。
// IP 取值与 API Key IP 限制共用「信任反代传递的客户端 IP」系统开关。
r.Use(middleware2.SessionBindingContext(cfg))
r.Use(middleware2.Logger())
r.Use(middleware2.CORS(cfg.CORS))
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string {