Files
sub2api/backend/internal/service/identity_service_user_agent_validation_test.go
T
li fe2c265c91 fix(identity): validate user-agent before persisting account fingerprint
IdentityService 把客户端 User-Agent 作为账号级持久身份缓存(Redis
fingerprint:<accountID>),覆盖规则是"只升不降"的纯数值版本比较,且对 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,校验 <product>/<major>.<minor>.<patch>
  后必须紧跟空白或结束(拒绝 -local/-dev/+build 等本地构建后缀),并对 claude-cli
  的主版本号相对 claude.CLICurrentVersion 设上限(挡掉 999 这类哨兵版本)。
  非 claude-cli 产品只校验形态,避免误伤其他合法客户端。
- 创建与升级两条路径共用该校验。只在 isNewerVersion 处加校验不够:
  createFingerprintFromHeaders 首次创建时同样会原样保存畸形 UA,删键恢复后
  账号可被同一客户端立即再次毒化。
- 读取时自愈:缓存中已是畸形 UA 时(本次加固之前写入的),用本次请求的合法 UA
  或默认指纹替换并回写。否则存量已中招的账号仍然只能靠手工删 Redis 键恢复。
- 仅在真正阻止了一次写入时记录被拒 UA,便于定位污染源,同时避免被毒化客户端的
  高频重试刷屏。

Fixes #5254
2026-08-06 15:01:28 +08:00

234 lines
7.9 KiB
Go

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)
}