From fe2c265c91f58c68426495acb875ff9bd1b0440c Mon Sep 17 00:00:00 2001 From: li Date: Thu, 6 Aug 2026 15:01:28 +0800 Subject: [PATCH] fix(identity): validate user-agent before persisting account fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdentityService 把客户端 User-Agent 作为账号级持久身份缓存(Redis fingerprint:),覆盖规则是"只升不降"的纯数值版本比较,且对 UA 内容 不做任何合法性校验: - userAgentVersionRegex 不锚定,能从 claude-cli/999.0.0-local 解析出 999.0.0 - isNewerVersion 只比数值 + 产品名,999.0.0 恒大于任何真实版本 - fingerprintTTL 7 天配合每 24 小时懒续期,活跃账号上的指纹近乎永不过期 - IdentityCache 只有 Get/Set,管理端也没有重置入口 一个本地构建客户端发出 claude-cli/999.0.0-local 即可把账号身份永久钉死。此后 该账号所有上游请求在 HTTP 头与请求体 cc_version 两处都声称这个不存在的版本, 被上游判为非正版客户端并返回不带限流重置头的 429;无重置头又落到 5 秒兜底冷却, 账号池收缩后对外表现为 503 风暴。一条 failover 请求即可连带毒化多个账号—— 指纹写入发生在上游调用之前,三次上游尝试全失败也照样毒化。 改动: - 新增 isAcceptableFingerprintUserAgent,校验 /.. 后必须紧跟空白或结束(拒绝 -local/-dev/+build 等本地构建后缀),并对 claude-cli 的主版本号相对 claude.CLICurrentVersion 设上限(挡掉 999 这类哨兵版本)。 非 claude-cli 产品只校验形态,避免误伤其他合法客户端。 - 创建与升级两条路径共用该校验。只在 isNewerVersion 处加校验不够: createFingerprintFromHeaders 首次创建时同样会原样保存畸形 UA,删键恢复后 账号可被同一客户端立即再次毒化。 - 读取时自愈:缓存中已是畸形 UA 时(本次加固之前写入的),用本次请求的合法 UA 或默认指纹替换并回写。否则存量已中招的账号仍然只能靠手工删 Redis 键恢复。 - 仅在真正阻止了一次写入时记录被拒 UA,便于定位污染源,同时避免被毒化客户端的 高频重试刷屏。 Fixes #5254 --- backend/internal/service/identity_service.go | 97 +++++++- ...tity_service_user_agent_validation_test.go | 233 ++++++++++++++++++ 2 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/identity_service_user_agent_validation_test.go diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 72635c1d68..afa882a9c9 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -23,8 +23,58 @@ import ( var ( // 匹配 User-Agent 版本号: xxx/x.y.z userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) + + // fingerprintUserAgentPattern 校验可写入账号级持久身份的 User-Agent 形态: + // /.. 之后必须紧跟空白或字符串结束。 + // 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。 + fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`) ) +const ( + // claudeCLIUserAgentProduct 是官方 Claude Code CLI 的产品名(小写)。 + claudeCLIUserAgentProduct = "claude-cli" + // maxFingerprintUserAgentLength 限制写入缓存的 User-Agent 长度。 + maxFingerprintUserAgentLength = 256 + // maxClaudeCLIMajorVersionSkew 是 claude-cli 主版本号相对 sub2api 自身伪装 + // 版本(claude.CLICurrentVersion)允许的最大超前量。给足两个大版本的升级 + // 窗口,同时挡掉 999 这类哨兵版本号。 + maxClaudeCLIMajorVersionSkew = 2 +) + +// isAcceptableFingerprintUserAgent 判断 User-Agent 是否可作为账号级持久身份写入缓存。 +// +// 指纹是账号级、“只升不降”、活跃账号懒续期后近乎永不过期的持久状态,且系统内 +// 没有重置入口。一旦写入畸形或哨兵版本(如 claude-cli/999.0.0-local),该账号 +// 此后所有上游请求都会在 HTTP 头与请求体 cc_version 两处声称这个不存在的版本, +// 被上游判定为非正版客户端并持续返回不带限流重置头的 429;无重置头又会落到 5 秒 +// 兜底冷却,账号池收缩后对外表现为 503 风暴。 +// +// 校验必须放在创建与升级两条路径的共同入口:只在 isNewerVersion 处加校验是不够的, +// createFingerprintFromHeaders 首次创建时同样会原样保存畸形 UA,删键恢复后账号可被 +// 同一客户端立即再次毒化。 +func isAcceptableFingerprintUserAgent(ua string) bool { + ua = strings.TrimSpace(ua) + if ua == "" || len(ua) > maxFingerprintUserAgentLength { + return false + } + if !fingerprintUserAgentPattern.MatchString(ua) { + return false + } + // 非 claude-cli 产品不做版本区间约束:形态合法即可,避免误伤其他合法客户端。 + if extractProduct(ua) != claudeCLIUserAgentProduct { + return true + } + major, _, _, ok := parseUserAgentVersion(ua) + if !ok { + return false + } + currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion) + if !currentOK { + return true + } + return major <= currentMajor+maxClaudeCLIMajorVersionSkew +} + // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", @@ -76,20 +126,46 @@ func NewIdentityService(cache IdentityCache) *IdentityService { // 如果缓存存在,检测user-agent版本,新版本则更新 // 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存 func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header) (*Fingerprint, error) { + // 入口统一校验:创建与升级两条路径共用,任一路径漏掉都会让畸形 UA 被持久化。 + clientUA := strings.TrimSpace(headers.Get("User-Agent")) + uaAcceptable := isAcceptableFingerprintUserAgent(clientUA) + // 尝试从缓存获取指纹 cached, err := s.cache.GetFingerprint(ctx, accountID) if err == nil && cached != nil { needWrite := false - // 检查客户端的user-agent是否是更新版本 - clientUA := headers.Get("User-Agent") - if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { + // 只在真正阻止了一次写入时记录,便于定位污染源,同时避免被毒化客户端的 + // 高频重试刷屏(无重置头的 429 会落到 5 秒兜底冷却,重试相当密集)。 + if !uaAcceptable && clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { + logger.LegacyPrintf("service.identity", + "Rejected fingerprint user-agent for account %d: %q (malformed or implausible version)", + accountID, clientUA) + } + + if !isAcceptableFingerprintUserAgent(cached.UserAgent) { + // 自愈:缓存中已是畸形/哨兵 UA(本次加固之前写入的)。指纹在活跃账号上 + // 懒续期后近乎永不过期,且系统内没有重置入口——不在读取时纠正,存量被 + // 毒化的账号就只能靠手工删 Redis 键恢复。 + poisoned := cached.UserAgent + if uaAcceptable { + mergeHeadersIntoFingerprint(cached, headers) + } else { + cached.UserAgent = defaultFingerprint.UserAgent + } + needWrite = true + logger.LegacyPrintf("service.identity", + "Replaced malformed cached fingerprint for account %d: %q -> %q", + accountID, poisoned, cached.UserAgent) + } else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) mergeHeadersIntoFingerprint(cached, headers) needWrite = true logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) - } else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { + } + + if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { // 距上次写入超过24小时,续期TTL needWrite = true } @@ -103,7 +179,13 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID return cached, nil } - // 缓存不存在或解析失败,创建新指纹 + // 缓存不存在或解析失败,创建新指纹。首次创建同样是持久化写入, + // 畸形 UA 在这里落库后就成了账号的长期身份,必须同样拒绝。 + if !uaAcceptable && clientUA != "" { + logger.LegacyPrintf("service.identity", + "Rejected fingerprint user-agent for account %d: %q (malformed or implausible version)", + accountID, clientUA) + } fp := s.createFingerprintFromHeaders(headers) // 生成随机ClientID @@ -123,8 +205,9 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fingerprint { fp := &Fingerprint{} - // 获取User-Agent - if ua := headers.Get("User-Agent"); ua != "" { + // 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。 + // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 + if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { fp.UserAgent = ua } else { fp.UserAgent = defaultFingerprint.UserAgent diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go new file mode 100644 index 0000000000..5206eb04f0 --- /dev/null +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -0,0 +1,233 @@ +package service + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +type stubIdentityCache struct { + fingerprint *Fingerprint + setCalls int + lastSet *Fingerprint +} + +func (s *stubIdentityCache) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) { + if s.fingerprint == nil { + return nil, nil + } + clone := *s.fingerprint + return &clone, nil +} + +func (s *stubIdentityCache) SetFingerprint(_ context.Context, _ int64, fp *Fingerprint) error { + s.setCalls++ + clone := *fp + s.lastSet = &clone + s.fingerprint = &clone + return nil +} + +func (s *stubIdentityCache) GetMaskedSessionID(_ context.Context, _ int64) (string, error) { + return "", nil +} + +func (s *stubIdentityCache) SetMaskedSessionID(_ context.Context, _ int64, _ string) error { + return nil +} + +func headersWithUA(ua string) http.Header { + h := http.Header{} + if ua != "" { + h.Set("User-Agent", ua) + } + return h +} + +func TestIsAcceptableFingerprintUserAgent(t *testing.T) { + cases := []struct { + name string + ua string + want bool + }{ + {"official_cli", "claude-cli/2.1.220 (external, cli)", true}, + {"official_cli_no_meta", "claude-cli/2.1.220", true}, + {"next_major_still_allowed", "claude-cli/4.0.0 (external, cli)", true}, + {"other_product_valid_form", "some-sdk/1.2.3 (node)", true}, + + // 本地/开发构建:版本号后带后缀,正是 #5254 的毒化 UA 形态。 + {"local_build_suffix", "claude-cli/999.0.0-local (undefined, cli)", false}, + {"dev_build_suffix", "claude-cli/2.1.220-dev (external, cli)", false}, + {"build_metadata_suffix", "claude-cli/2.1.220+build1 (external, cli)", false}, + + // 哨兵版本号:形态合法但主版本号远超 sub2api 自身伪装版本。 + {"sentinel_major", "claude-cli/999.0.0 (external, cli)", false}, + + {"empty", "", false}, + {"no_version", "claude-cli (external, cli)", false}, + {"two_segment_version", "claude-cli/2.1 (external, cli)", false}, + // 浏览器 UA 是两段版本号,不符合三段 semver 形态:不适合作为账号身份。 + {"browser_ua_two_segment", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", false}, + {"leading_junk", "x claude-cli/2.1.220 (external, cli)", false}, + {"too_long", "claude-cli/2.1.220 (" + strings.Repeat("a", 300) + ")", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, isAcceptableFingerprintUserAgent(tc.ua)) + }) + } +} + +// 首次创建路径:畸形 UA 不得被原样持久化,回退默认指纹。 +// 只在 isNewerVersion 处加校验是不够的——删键恢复后账号会被同一客户端立即再次毒化。 +func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent, + "畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份") + require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") +} + +// 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。 +// isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。 +func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { + cached := &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + } + cache := &stubIdentityCache{fingerprint: cached} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent, + "真实指纹不得被哨兵版本覆盖") + require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入") +} + +// 合法的真实版本升级必须照常生效,校验不能把正常升级一起挡掉。 +func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + newUA := "claude-cli/2.1.223 (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) + + require.NoError(t, err) + require.Equal(t, newUA, fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 合法 UA 的首次创建路径不受影响。 +func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + ua := "claude-cli/" + claude.CLICurrentVersion + " (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(ua)) + + require.NoError(t, err) + require.Equal(t, ua, fp.UserAgent) + require.NotEmpty(t, fp.ClientID) + require.Equal(t, 1, cache.setCalls) +} + +// 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。 +func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) { + require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent), + "defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写") +} + +// 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。 +// 指纹在活跃账号上懒续期后近乎永不过期,且系统内没有重置入口—— +// 不在读取时纠正,已中招的账号只能靠手工删 Redis 键恢复。 +func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/999.0.0-local (undefined, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + realUA := "claude-cli/2.1.22 (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA)) + + require.NoError(t, err) + require.Equal(t, realUA, fp.UserAgent, + "真实客户端必须能从被毒化的指纹手中夺回账号身份") + require.Equal(t, 1, cache.setCalls) + require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") + require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID") +} + +// 毒化指纹 + 同样畸形的客户端 UA:回退默认指纹,不保留任何一方的畸形值。 +func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/999.0.0-local (undefined, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。 +func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.22 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent) + require.Zero(t, cache.setCalls) +} + +// 无 UA 时的既有行为(回退默认指纹)保持不变。 +func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{}) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) +}