mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:08:03 +08:00
Merge pull request #5327 from fengshao1227/fix/fingerprint-user-agent-validation
fix(identity): validate user-agent before persisting account fingerprint
This commit is contained in:
@@ -23,8 +23,58 @@ import (
|
||||
var (
|
||||
// 匹配 User-Agent 版本号: xxx/x.y.z
|
||||
userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`)
|
||||
|
||||
// fingerprintUserAgentPattern 校验可写入账号级持久身份的 User-Agent 形态:
|
||||
// <product>/<major>.<minor>.<patch> 之后必须紧跟空白或字符串结束。
|
||||
// 版本号带 -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user