identity_service_user_agent_validation_test.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "strings"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/require"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  10. )
  11. type stubIdentityCache struct {
  12. fingerprint *Fingerprint
  13. setCalls int
  14. lastSet *Fingerprint
  15. }
  16. func (s *stubIdentityCache) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) {
  17. if s.fingerprint == nil {
  18. return nil, nil
  19. }
  20. clone := *s.fingerprint
  21. return &clone, nil
  22. }
  23. func (s *stubIdentityCache) SetFingerprint(_ context.Context, _ int64, fp *Fingerprint) error {
  24. s.setCalls++
  25. clone := *fp
  26. s.lastSet = &clone
  27. s.fingerprint = &clone
  28. return nil
  29. }
  30. func (s *stubIdentityCache) GetMaskedSessionID(_ context.Context, _ int64) (string, error) {
  31. return "", nil
  32. }
  33. func (s *stubIdentityCache) SetMaskedSessionID(_ context.Context, _ int64, _ string) error {
  34. return nil
  35. }
  36. func headersWithUA(ua string) http.Header {
  37. h := http.Header{}
  38. if ua != "" {
  39. h.Set("User-Agent", ua)
  40. }
  41. return h
  42. }
  43. func TestIsAcceptableFingerprintUserAgent(t *testing.T) {
  44. cases := []struct {
  45. name string
  46. ua string
  47. want bool
  48. }{
  49. {"official_cli", "claude-cli/2.1.220 (external, cli)", true},
  50. {"official_cli_no_meta", "claude-cli/2.1.220", true},
  51. {"next_major_still_allowed", "claude-cli/4.0.0 (external, cli)", true},
  52. {"other_product_valid_form", "some-sdk/1.2.3 (node)", true},
  53. // 本地/开发构建:版本号后带后缀,正是 #5254 的毒化 UA 形态。
  54. {"local_build_suffix", "claude-cli/999.0.0-local (undefined, cli)", false},
  55. {"dev_build_suffix", "claude-cli/2.1.220-dev (external, cli)", false},
  56. {"build_metadata_suffix", "claude-cli/2.1.220+build1 (external, cli)", false},
  57. // 哨兵版本号:形态合法但主版本号远超 sub2api 自身伪装版本。
  58. {"sentinel_major", "claude-cli/999.0.0 (external, cli)", false},
  59. {"empty", "", false},
  60. {"no_version", "claude-cli (external, cli)", false},
  61. {"two_segment_version", "claude-cli/2.1 (external, cli)", false},
  62. // 浏览器 UA 是两段版本号,不符合三段 semver 形态:不适合作为账号身份。
  63. {"browser_ua_two_segment", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", false},
  64. {"leading_junk", "x claude-cli/2.1.220 (external, cli)", false},
  65. {"too_long", "claude-cli/2.1.220 (" + strings.Repeat("a", 300) + ")", false},
  66. }
  67. for _, tc := range cases {
  68. t.Run(tc.name, func(t *testing.T) {
  69. require.Equal(t, tc.want, isAcceptableFingerprintUserAgent(tc.ua))
  70. })
  71. }
  72. }
  73. // 首次创建路径:畸形 UA 不得被原样持久化,回退默认指纹。
  74. // 只在 isNewerVersion 处加校验是不够的——删键恢复后账号会被同一客户端立即再次毒化。
  75. func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) {
  76. cache := &stubIdentityCache{}
  77. svc := NewIdentityService(cache)
  78. fp, err := svc.GetOrCreateFingerprint(
  79. context.Background(), 1,
  80. headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
  81. )
  82. require.NoError(t, err)
  83. require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent,
  84. "畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份")
  85. require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
  86. }
  87. // 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。
  88. // isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。
  89. func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) {
  90. // 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。
  91. cached := &Fingerprint{
  92. UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
  93. ClientID: "cid-1",
  94. UpdatedAt: time.Now().Unix(),
  95. }
  96. cache := &stubIdentityCache{fingerprint: cached}
  97. svc := NewIdentityService(cache)
  98. fp, err := svc.GetOrCreateFingerprint(
  99. context.Background(), 1,
  100. headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
  101. )
  102. require.NoError(t, err)
  103. require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent,
  104. "真实指纹不得被哨兵版本覆盖")
  105. require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入")
  106. }
  107. // 合法的真实版本升级必须照常生效,校验不能把正常升级一起挡掉。
  108. func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) {
  109. cache := &stubIdentityCache{fingerprint: &Fingerprint{
  110. UserAgent: "claude-cli/2.1.22 (external, cli)",
  111. ClientID: "cid-1",
  112. UpdatedAt: time.Now().Unix(),
  113. }}
  114. svc := NewIdentityService(cache)
  115. // 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级,
  116. // 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。
  117. newUA := "claude-cli/2.9.0 (external, cli)"
  118. fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
  119. require.NoError(t, err)
  120. require.Equal(t, newUA, fp.UserAgent)
  121. require.Equal(t, 1, cache.setCalls)
  122. }
  123. // 合法 UA 的首次创建路径不受影响。
  124. func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) {
  125. cache := &stubIdentityCache{}
  126. svc := NewIdentityService(cache)
  127. ua := "claude-cli/" + claude.CLICurrentVersion + " (external, cli)"
  128. fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(ua))
  129. require.NoError(t, err)
  130. require.Equal(t, ua, fp.UserAgent)
  131. require.NotEmpty(t, fp.ClientID)
  132. require.Equal(t, 1, cache.setCalls)
  133. }
  134. // 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。
  135. func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) {
  136. require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent),
  137. "defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写")
  138. }
  139. // 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。
  140. // 指纹在活跃账号上懒续期后近乎永不过期,且系统内没有重置入口——
  141. // 不在读取时纠正,已中招的账号只能靠手工删 Redis 键恢复。
  142. func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T) {
  143. cache := &stubIdentityCache{fingerprint: &Fingerprint{
  144. UserAgent: "claude-cli/999.0.0-local (undefined, cli)",
  145. ClientID: "cid-1",
  146. UpdatedAt: time.Now().Unix(),
  147. }}
  148. svc := NewIdentityService(cache)
  149. realUA := "claude-cli/2.1.22 (external, cli)"
  150. fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA))
  151. require.NoError(t, err)
  152. // 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效:
  153. // 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。
  154. require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
  155. require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库")
  156. require.Equal(t, 1, cache.setCalls)
  157. require.NotContains(t, cache.lastSet.UserAgent, "999.0.0")
  158. require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID")
  159. }
  160. // 毒化指纹 + 同样畸形的客户端 UA:回退默认指纹,不保留任何一方的畸形值。
  161. func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing.T) {
  162. cache := &stubIdentityCache{fingerprint: &Fingerprint{
  163. UserAgent: "claude-cli/999.0.0-local (undefined, cli)",
  164. ClientID: "cid-1",
  165. UpdatedAt: time.Now().Unix(),
  166. }}
  167. svc := NewIdentityService(cache)
  168. fp, err := svc.GetOrCreateFingerprint(
  169. context.Background(), 1,
  170. headersWithUA("claude-cli/999.0.0-local (undefined, cli)"),
  171. )
  172. require.NoError(t, err)
  173. require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
  174. require.Equal(t, 1, cache.setCalls)
  175. }
  176. // 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。
  177. // 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。
  178. func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) {
  179. cache := &stubIdentityCache{fingerprint: &Fingerprint{
  180. UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)",
  181. ClientID: "cid-1",
  182. UpdatedAt: time.Now().Unix(),
  183. }}
  184. svc := NewIdentityService(cache)
  185. fp, err := svc.GetOrCreateFingerprint(
  186. context.Background(), 1,
  187. headersWithUA("claude-cli/2.1.22 (external, cli)"),
  188. )
  189. require.NoError(t, err)
  190. require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
  191. require.Zero(t, cache.setCalls)
  192. }
  193. // 无 UA 时的既有行为(回退默认指纹)保持不变。
  194. func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) {
  195. cache := &stubIdentityCache{}
  196. svc := NewIdentityService(cache)
  197. fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{})
  198. require.NoError(t, err)
  199. require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent)
  200. }