account_header_override_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. //go:build unit
  2. package service
  3. import (
  4. "net/http"
  5. "strings"
  6. "testing"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func headerOverrideTestAccount(platform, accountType string, credentials map[string]any) *Account {
  10. return &Account{
  11. Platform: platform,
  12. Type: accountType,
  13. Credentials: credentials,
  14. }
  15. }
  16. func TestIsHeaderOverrideEligible(t *testing.T) {
  17. tests := []struct {
  18. name string
  19. platform string
  20. accType string
  21. want bool
  22. }{
  23. {"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true},
  24. {"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true},
  25. {"kimi apikey", PlatformKimi, AccountTypeAPIKey, true},
  26. {"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true},
  27. {"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true},
  28. {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
  29. {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
  30. {"kimi oauth", PlatformKimi, AccountTypeOAuth, false},
  31. {"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false},
  32. {"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false},
  33. {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
  34. {"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
  35. {"grok oauth", PlatformGrok, AccountTypeOAuth, true},
  36. {"antigravity apikey", PlatformAntigravity, AccountTypeAPIKey, false},
  37. {"anthropic bedrock", PlatformAnthropic, AccountTypeBedrock, false},
  38. }
  39. for _, tt := range tests {
  40. t.Run(tt.name, func(t *testing.T) {
  41. acc := headerOverrideTestAccount(tt.platform, tt.accType, nil)
  42. require.Equal(t, tt.want, acc.IsHeaderOverrideEligible())
  43. })
  44. }
  45. var nilAccount *Account
  46. require.False(t, nilAccount.IsHeaderOverrideEligible())
  47. require.False(t, nilAccount.IsHeaderOverrideEnabled())
  48. require.Nil(t, nilAccount.GetHeaderOverrides())
  49. }
  50. func TestIsHeaderOverrideEnabled(t *testing.T) {
  51. acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
  52. credKeyHeaderOverrideEnabled: true,
  53. })
  54. require.True(t, acc.IsHeaderOverrideEnabled())
  55. // 未配置 / 非 bool / false 均视为未启用
  56. require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, nil).IsHeaderOverrideEnabled())
  57. require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
  58. credKeyHeaderOverrideEnabled: "true",
  59. }).IsHeaderOverrideEnabled())
  60. require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
  61. credKeyHeaderOverrideEnabled: false,
  62. }).IsHeaderOverrideEnabled())
  63. // 不符合平台/类型条件时即使配置了 true 也不启用
  64. require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
  65. credKeyHeaderOverrideEnabled: true,
  66. }).IsHeaderOverrideEnabled())
  67. require.False(t, headerOverrideTestAccount(PlatformGemini, AccountTypeAPIKey, map[string]any{
  68. credKeyHeaderOverrideEnabled: true,
  69. }).IsHeaderOverrideEnabled())
  70. }
  71. func TestGetHeaderOverrides(t *testing.T) {
  72. acc := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
  73. credKeyHeaderOverrideEnabled: true,
  74. credKeyHeaderOverrides: map[string]any{
  75. "User-Agent": "my-agent/1.0", // 大写 key 归一化为小写
  76. " X-App ": "cli", // 名称去空白
  77. "x-empty": "", // 空 value(模板占位)跳过
  78. "authorization": "Bearer leaked", // 禁止覆写的头跳过
  79. "bad name": "value", // 非法 header 名跳过
  80. "x-padded": " padded ", // value 去空白
  81. },
  82. })
  83. overrides := acc.GetHeaderOverrides()
  84. require.Equal(t, map[string]string{
  85. "user-agent": "my-agent/1.0",
  86. "x-app": "cli",
  87. "x-padded": "padded",
  88. }, overrides)
  89. // 未启用时返回 nil
  90. disabled := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
  91. credKeyHeaderOverrides: map[string]any{"user-agent": "x"},
  92. })
  93. require.Nil(t, disabled.GetHeaderOverrides())
  94. // 启用但全部为空 value 时返回 nil
  95. empty := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
  96. credKeyHeaderOverrideEnabled: true,
  97. credKeyHeaderOverrides: map[string]any{"user-agent": ""},
  98. })
  99. require.Nil(t, empty.GetHeaderOverrides())
  100. // 未经 Normalize 落库的超长数据 / WebSocket 握手头在应用时被防御性跳过
  101. oversizedValue := strings.Repeat("a", maxHeaderOverrideValueLength+1)
  102. defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
  103. credKeyHeaderOverrideEnabled: true,
  104. credKeyHeaderOverrides: map[string]any{
  105. "x-big": oversizedValue,
  106. "sec-websocket-key": "forged",
  107. "content-type": "application/json", // 名单扩充前落库的数据也要被拦截
  108. "x-claude-code-session-id": "pinned-session",
  109. "x-ok": "ok",
  110. },
  111. })
  112. require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
  113. }
  114. func TestApplyHeaderOverrides(t *testing.T) {
  115. acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
  116. credKeyHeaderOverrideEnabled: true,
  117. credKeyHeaderOverrides: map[string]any{
  118. "user-agent": "override-agent/2.0",
  119. "anthropic-beta": "custom-beta-1",
  120. "x-custom": "custom-value",
  121. },
  122. })
  123. h := http.Header{}
  124. // 模拟转发链路:canonical key 与 wire casing 原样 key 混合存在
  125. h.Set("User-Agent", "claude-cli/2.1.161 (external, cli)")
  126. h["anthropic-beta"] = []string{"claude-code-20250219,oauth-2025-04-20"} // 非 canonical 原样 key
  127. h.Set("Content-Type", "application/json")
  128. acc.ApplyHeaderOverrides(h)
  129. // user-agent 覆盖且只有一个值(已知头恢复 wire casing)
  130. require.Equal(t, []string{"override-agent/2.0"}, h["User-Agent"])
  131. // anthropic-beta:非 canonical 旧值被清除,写入 wire casing(小写)
  132. require.Equal(t, []string{"custom-beta-1"}, h["anthropic-beta"])
  133. require.Empty(t, h["Anthropic-Beta"])
  134. // 新增头(未知头以小写原样键写入,与转发链路 wire casing 约定一致)
  135. require.Equal(t, []string{"custom-value"}, h["x-custom"])
  136. require.Equal(t, "custom-value", getHeaderRaw(h, "x-custom"))
  137. // 未覆写的头不受影响
  138. require.Equal(t, "application/json", h.Get("Content-Type"))
  139. // 覆盖后不存在任何大小写重复
  140. count := 0
  141. for k := range h {
  142. if k == "anthropic-beta" || k == "Anthropic-Beta" {
  143. count++
  144. }
  145. }
  146. require.Equal(t, 1, count)
  147. }
  148. func TestApplyHeaderOverridesNoOpPaths(t *testing.T) {
  149. baseline := func() http.Header {
  150. h := http.Header{}
  151. h.Set("User-Agent", "orig")
  152. return h
  153. }
  154. // OAuth 账号:即使配置了覆写也不生效
  155. oauth := headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
  156. credKeyHeaderOverrideEnabled: true,
  157. credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
  158. })
  159. h := baseline()
  160. oauth.ApplyHeaderOverrides(h)
  161. require.Equal(t, "orig", h.Get("User-Agent"))
  162. // 未启用开关
  163. off := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
  164. credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
  165. })
  166. h = baseline()
  167. off.ApplyHeaderOverrides(h)
  168. require.Equal(t, "orig", h.Get("User-Agent"))
  169. // 禁止覆写的头(authorization / x-api-key / host 等)不会被应用
  170. blocked := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
  171. credKeyHeaderOverrideEnabled: true,
  172. credKeyHeaderOverrides: map[string]any{
  173. "Authorization": "Bearer evil",
  174. "X-Api-Key": "evil",
  175. "Host": "evil.example.com",
  176. "Content-Length": "0",
  177. },
  178. })
  179. h = http.Header{}
  180. h.Set("Authorization", "Bearer real-key")
  181. blocked.ApplyHeaderOverrides(h)
  182. require.Equal(t, "Bearer real-key", h.Get("Authorization"))
  183. require.Empty(t, h.Get("X-Api-Key"))
  184. require.Empty(t, h.Get("Host"))
  185. // nil header 不 panic
  186. blocked.ApplyHeaderOverrides(nil)
  187. }
  188. func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
  189. t.Run("nil credentials no-op", func(t *testing.T) {
  190. require.NoError(t, NormalizeHeaderOverrideCredentials(nil))
  191. })
  192. t.Run("missing keys no-op", func(t *testing.T) {
  193. creds := map[string]any{"api_key": "sk-xxx"}
  194. require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
  195. _, exists := creds[credKeyHeaderOverrides]
  196. require.False(t, exists)
  197. })
  198. t.Run("normalizes names and values", func(t *testing.T) {
  199. creds := map[string]any{
  200. credKeyHeaderOverrideEnabled: true,
  201. credKeyHeaderOverrides: map[string]any{
  202. " User-Agent ": " my-agent ",
  203. "X-App": "",
  204. "": "", // 完全空行被丢弃
  205. },
  206. }
  207. require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
  208. require.Equal(t, map[string]any{
  209. "user-agent": "my-agent",
  210. "x-app": "",
  211. }, creds[credKeyHeaderOverrides])
  212. })
  213. t.Run("accepts map[string]string input", func(t *testing.T) {
  214. creds := map[string]any{
  215. credKeyHeaderOverrides: map[string]string{"X-App": "cli"},
  216. }
  217. require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
  218. require.Equal(t, map[string]any{"x-app": "cli"}, creds[credKeyHeaderOverrides])
  219. })
  220. t.Run("rejects non-bool enabled", func(t *testing.T) {
  221. err := NormalizeHeaderOverrideCredentials(map[string]any{
  222. credKeyHeaderOverrideEnabled: "yes",
  223. })
  224. require.Error(t, err)
  225. })
  226. t.Run("rejects non-object overrides", func(t *testing.T) {
  227. err := NormalizeHeaderOverrideCredentials(map[string]any{
  228. credKeyHeaderOverrides: []any{"user-agent"},
  229. })
  230. require.Error(t, err)
  231. })
  232. t.Run("rejects non-string value", func(t *testing.T) {
  233. err := NormalizeHeaderOverrideCredentials(map[string]any{
  234. credKeyHeaderOverrides: map[string]any{"x-app": 123},
  235. })
  236. require.Error(t, err)
  237. })
  238. t.Run("rejects invalid header name", func(t *testing.T) {
  239. for _, name := range []string{"bad name", "bad:name", "bad\nname", "值"} {
  240. err := NormalizeHeaderOverrideCredentials(map[string]any{
  241. credKeyHeaderOverrides: map[string]any{name: "v"},
  242. })
  243. require.Error(t, err, "name %q should be rejected", name)
  244. }
  245. })
  246. t.Run("rejects empty name with value", func(t *testing.T) {
  247. err := NormalizeHeaderOverrideCredentials(map[string]any{
  248. credKeyHeaderOverrides: map[string]any{" ": "v"},
  249. })
  250. require.Error(t, err)
  251. })
  252. t.Run("rejects blocked headers", func(t *testing.T) {
  253. for _, name := range []string{
  254. "Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
  255. "connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
  256. "conversation_id", "x-codex-turn-state", "chatgpt-account-id",
  257. "Content-Type", "Cookie", "x-goog-api-key",
  258. "X-Claude-Code-Session-Id", "x-client-request-id",
  259. } {
  260. err := NormalizeHeaderOverrideCredentials(map[string]any{
  261. credKeyHeaderOverrides: map[string]any{name: "v"},
  262. })
  263. require.Error(t, err, "blocked header %q should be rejected", name)
  264. }
  265. })
  266. t.Run("allows tab inside value", func(t *testing.T) {
  267. creds := map[string]any{
  268. credKeyHeaderOverrides: map[string]any{"x-app": "a\tb"},
  269. }
  270. require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
  271. require.Equal(t, map[string]any{"x-app": "a\tb"}, creds[credKeyHeaderOverrides])
  272. })
  273. t.Run("rejects invalid value", func(t *testing.T) {
  274. err := NormalizeHeaderOverrideCredentials(map[string]any{
  275. credKeyHeaderOverrides: map[string]any{"x-app": "bad\nvalue"},
  276. })
  277. require.Error(t, err)
  278. })
  279. t.Run("rejects duplicate names case-insensitively", func(t *testing.T) {
  280. err := NormalizeHeaderOverrideCredentials(map[string]any{
  281. credKeyHeaderOverrides: map[string]any{
  282. "User-Agent": "a",
  283. "user-agent": "b",
  284. },
  285. })
  286. require.Error(t, err)
  287. })
  288. t.Run("rejects too many entries", func(t *testing.T) {
  289. entries := make(map[string]any, maxHeaderOverrideEntries+1)
  290. for i := 0; i <= maxHeaderOverrideEntries; i++ {
  291. entries["x-h-"+string(rune('a'+i%26))+string(rune('a'+(i/26)%26))+string(rune('a'+(i/676)%26))] = "v"
  292. }
  293. err := NormalizeHeaderOverrideCredentials(map[string]any{
  294. credKeyHeaderOverrides: entries,
  295. })
  296. require.Error(t, err)
  297. })
  298. t.Run("rejects oversized value", func(t *testing.T) {
  299. big := make([]byte, maxHeaderOverrideValueLength+1)
  300. for i := range big {
  301. big[i] = 'a'
  302. }
  303. err := NormalizeHeaderOverrideCredentials(map[string]any{
  304. credKeyHeaderOverrides: map[string]any{"x-app": string(big)},
  305. })
  306. require.Error(t, err)
  307. })
  308. }