Merge pull request #3833 from superman2003/fix/security-and-frontend-hardening

fix(gateway,frontend): 修复 Gemini 鉴权绕过与前端支付/会话缺陷
This commit is contained in:
Wesley Liddick
2026-07-09 14:37:19 +08:00
committed by GitHub
10 changed files with 185 additions and 54 deletions
@@ -2,10 +2,12 @@ package middleware
import (
"errors"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -46,10 +48,32 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
// user/group/platform。
SetOpsFallbackAPIKey(c, apiKey)
if !apiKey.IsActive() {
// disabled / 未知状态 → 无条件拦截(expired 和 quota_exhausted 留给计费阶段,
// 与主中间件 api_key_auth.go 保持一致)。
if !apiKey.IsActive() &&
apiKey.Status != service.StatusAPIKeyExpired &&
apiKey.Status != service.StatusAPIKeyQuotaExhausted {
abortWithGoogleError(c, 401, "API key is disabled")
return
}
// 检查 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)
}
allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist)
if !allowed {
if clientIP == "" {
clientIP = "unknown"
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction)
abortWithGoogleError(c, 403, fmt.Sprintf("Access denied. Your IP is %s", clientIP))
return
}
}
if apiKey.User == nil {
abortWithGoogleError(c, 401, "User associated with API key not found")
return
@@ -63,6 +87,12 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
abortWithGoogleError(c, 403, message)
return
}
// 专属分组授权校验:用户对该专属分组的授权被撤销后应拒绝(与主中间件一致,防止越权)。
if !validateAPIKeyGroupAllowed(apiKey) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable)
abortWithGoogleError(c, 403, "API Key 所属专属分组不再允许当前用户使用")
return
}
// 简易模式:跳过余额和订阅检查
if cfg.RunMode == config.RunModeSimple {
@@ -78,6 +108,26 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
return
}
// Key 状态检查(状态字段可能因后台异步刷新而滞后,故显式拦截)。
switch apiKey.Status {
case service.StatusAPIKeyQuotaExhausted:
abortWithGoogleError(c, 429, "API key 额度已用完")
return
case service.StatusAPIKeyExpired:
abortWithGoogleError(c, 403, "API key 已过期")
return
}
// 运行时过期/配额检查(即使状态是 active,也要检查时间和用量,与主中间件一致)。
if apiKey.IsExpired() {
abortWithGoogleError(c, 403, "API key 已过期")
return
}
if apiKey.IsQuotaExhausted() {
abortWithGoogleError(c, 429, "API key 额度已用完")
return
}
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
if isSubscriptionType && subscriptionService != nil {
subscription, err := subscriptionService.GetActiveSubscription(
+9 -3
View File
@@ -165,7 +165,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 最低缓存门槛,导致系统级缓存失效)。
//
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
var clientUserAgent string
if c != nil {
clientUserAgent = c.GetHeader("User-Agent")
}
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
if shouldMimicClaudeCode {
@@ -190,7 +194,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
if s.identityService != nil {
if s.identityService != nil && c != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil {
// metadata 透传开启时跳过 metadata 注入
@@ -220,7 +224,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
return nil, err
}
c.Set(toolNameRewriteKey, rw)
if c != nil {
c.Set(toolNameRewriteKey, rw)
}
} else {
if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
return nil, err
@@ -360,15 +360,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
stickyCacheMissReason = "session_limit"
// 会话限制已满,继续到负载感知选择
} else {
return &AccountSelectionResult{
Account: stickyAccount,
WaitPlan: &AccountWaitPlan{
AccountID: stickyAccountID,
MaxConcurrency: stickyAccount.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
},
}, nil
// 必须走 newSelectionResult 以 hydrate 账号凭证:
// 调度快照中的账号是精简版(OAuth token 等被剥离),
// 直接返回会导致后续转发缺少凭证而鉴权失败。
return s.newSelectionResult(ctx, stickyAccount, false, nil, &AccountWaitPlan{
AccountID: stickyAccountID,
MaxConcurrency: stickyAccount.Concurrency,
Timeout: cfg.StickySessionWaitTimeout,
MaxWaiting: cfg.StickySessionMaxWaiting,
})
}
} else {
stickyCacheMissReason = "wait_queue_full"
@@ -356,7 +356,13 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err
}
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) {
body, _ := s.readUpstreamErrorBody(resp)
body, readErr := s.readUpstreamErrorBody(resp)
if readErr != nil {
// 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查,
// 避免静默吞掉导致误判。
logger.LegacyPrintf("service.gateway", "[Forward] Failed to fully read upstream error body: Account=%d(%s) Status=%d err=%v",
account.ID, account.Name, resp.StatusCode, readErr)
}
// 调试日志:打印上游错误响应
logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (non-retryable): Account=%d(%s) Status=%d RequestID=%s Body=%s",
@@ -1023,11 +1029,14 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if _, werr := fmt.Fprint(w, string(restored)); werr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
break
// 不 break:客户端断开后仍需继续合并本事件及后续事件的 usage,
// 否则会漏计当前事件携带的 usage 导致少计费。后续写入由
// clientDisconnected 守卫跳过。
} else {
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
}
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
}
if data != "" {
if firstTokenMs == nil && data != "[DONE]" {