347 lines
12 KiB
Go
347 lines
12 KiB
Go
//go:build unit
|
||||
|
|
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func headerOverrideTestAccount(platform, accountType string, credentials map[string]any) *Account {
|
|||
|
|
return &Account{
|
|||
|
|
Platform: platform,
|
|||
|
|
Type: accountType,
|
|||
|
|
Credentials: credentials,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIsHeaderOverrideEligible(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
platform string
|
|||
|
|
accType string
|
|||
|
|
want bool
|
|||
|
|
}{
|
|||
|
|
{"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true},
|
|||
|
|
{"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true},
|
|||
|
|
{"kimi apikey", PlatformKimi, AccountTypeAPIKey, true},
|
|||
|
|
{"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true},
|
|||
|
|
{"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true},
|
|||
|
|
{"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
|
|||
|
|
{"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
|
|||
|
|
{"kimi oauth", PlatformKimi, AccountTypeOAuth, false},
|
|||
|
|
{"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false},
|
|||
|
|
{"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false},
|
|||
|
|
{"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
|
|||
|
|
{"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
|
|||
|
|
{"grok oauth", PlatformGrok, AccountTypeOAuth, true},
|
|||
|
|
{"antigravity apikey", PlatformAntigravity, AccountTypeAPIKey, false},
|
|||
|
|
{"anthropic bedrock", PlatformAnthropic, AccountTypeBedrock, false},
|
|||
|
|
}
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
acc := headerOverrideTestAccount(tt.platform, tt.accType, nil)
|
|||
|
|
require.Equal(t, tt.want, acc.IsHeaderOverrideEligible())
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var nilAccount *Account
|
|||
|
|
require.False(t, nilAccount.IsHeaderOverrideEligible())
|
|||
|
|
require.False(t, nilAccount.IsHeaderOverrideEnabled())
|
|||
|
|
require.Nil(t, nilAccount.GetHeaderOverrides())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestIsHeaderOverrideEnabled(t *testing.T) {
|
|||
|
|
acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
})
|
|||
|
|
require.True(t, acc.IsHeaderOverrideEnabled())
|
|||
|
|
|
|||
|
|
// 未配置 / 非 bool / false 均视为未启用
|
|||
|
|
require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, nil).IsHeaderOverrideEnabled())
|
|||
|
|
require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: "true",
|
|||
|
|
}).IsHeaderOverrideEnabled())
|
|||
|
|
require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: false,
|
|||
|
|
}).IsHeaderOverrideEnabled())
|
|||
|
|
|
|||
|
|
// 不符合平台/类型条件时即使配置了 true 也不启用
|
|||
|
|
require.False(t, headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
}).IsHeaderOverrideEnabled())
|
|||
|
|
require.False(t, headerOverrideTestAccount(PlatformGemini, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
}).IsHeaderOverrideEnabled())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestGetHeaderOverrides(t *testing.T) {
|
|||
|
|
acc := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
"User-Agent": "my-agent/1.0", // 大写 key 归一化为小写
|
|||
|
|
" X-App ": "cli", // 名称去空白
|
|||
|
|
"x-empty": "", // 空 value(模板占位)跳过
|
|||
|
|
"authorization": "Bearer leaked", // 禁止覆写的头跳过
|
|||
|
|
"bad name": "value", // 非法 header 名跳过
|
|||
|
|
"x-padded": " padded ", // value 去空白
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
overrides := acc.GetHeaderOverrides()
|
|||
|
|
require.Equal(t, map[string]string{
|
|||
|
|
"user-agent": "my-agent/1.0",
|
|||
|
|
"x-app": "cli",
|
|||
|
|
"x-padded": "padded",
|
|||
|
|
}, overrides)
|
|||
|
|
|
|||
|
|
// 未启用时返回 nil
|
|||
|
|
disabled := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"user-agent": "x"},
|
|||
|
|
})
|
|||
|
|
require.Nil(t, disabled.GetHeaderOverrides())
|
|||
|
|
|
|||
|
|
// 启用但全部为空 value 时返回 nil
|
|||
|
|
empty := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"user-agent": ""},
|
|||
|
|
})
|
|||
|
|
require.Nil(t, empty.GetHeaderOverrides())
|
|||
|
|
|
|||
|
|
// 未经 Normalize 落库的超长数据 / WebSocket 握手头在应用时被防御性跳过
|
|||
|
|
oversizedValue := strings.Repeat("a", maxHeaderOverrideValueLength+1)
|
|||
|
|
defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
"x-big": oversizedValue,
|
|||
|
|
"sec-websocket-key": "forged",
|
|||
|
|
"content-type": "application/json", // 名单扩充前落库的数据也要被拦截
|
|||
|
|
"x-claude-code-session-id": "pinned-session",
|
|||
|
|
"x-ok": "ok",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestApplyHeaderOverrides(t *testing.T) {
|
|||
|
|
acc := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
"user-agent": "override-agent/2.0",
|
|||
|
|
"anthropic-beta": "custom-beta-1",
|
|||
|
|
"x-custom": "custom-value",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
h := http.Header{}
|
|||
|
|
// 模拟转发链路:canonical key 与 wire casing 原样 key 混合存在
|
|||
|
|
h.Set("User-Agent", "claude-cli/2.1.161 (external, cli)")
|
|||
|
|
h["anthropic-beta"] = []string{"claude-code-20250219,oauth-2025-04-20"} // 非 canonical 原样 key
|
|||
|
|
h.Set("Content-Type", "application/json")
|
|||
|
|
|
|||
|
|
acc.ApplyHeaderOverrides(h)
|
|||
|
|
|
|||
|
|
// user-agent 覆盖且只有一个值(已知头恢复 wire casing)
|
|||
|
|
require.Equal(t, []string{"override-agent/2.0"}, h["User-Agent"])
|
|||
|
|
// anthropic-beta:非 canonical 旧值被清除,写入 wire casing(小写)
|
|||
|
|
require.Equal(t, []string{"custom-beta-1"}, h["anthropic-beta"])
|
|||
|
|
require.Empty(t, h["Anthropic-Beta"])
|
|||
|
|
// 新增头(未知头以小写原样键写入,与转发链路 wire casing 约定一致)
|
|||
|
|
require.Equal(t, []string{"custom-value"}, h["x-custom"])
|
|||
|
|
require.Equal(t, "custom-value", getHeaderRaw(h, "x-custom"))
|
|||
|
|
// 未覆写的头不受影响
|
|||
|
|
require.Equal(t, "application/json", h.Get("Content-Type"))
|
|||
|
|
|
|||
|
|
// 覆盖后不存在任何大小写重复
|
|||
|
|
count := 0
|
|||
|
|
for k := range h {
|
|||
|
|
if k == "anthropic-beta" || k == "Anthropic-Beta" {
|
|||
|
|
count++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
require.Equal(t, 1, count)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestApplyHeaderOverridesNoOpPaths(t *testing.T) {
|
|||
|
|
baseline := func() http.Header {
|
|||
|
|
h := http.Header{}
|
|||
|
|
h.Set("User-Agent", "orig")
|
|||
|
|
return h
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// OAuth 账号:即使配置了覆写也不生效
|
|||
|
|
oauth := headerOverrideTestAccount(PlatformAnthropic, AccountTypeOAuth, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
|
|||
|
|
})
|
|||
|
|
h := baseline()
|
|||
|
|
oauth.ApplyHeaderOverrides(h)
|
|||
|
|
require.Equal(t, "orig", h.Get("User-Agent"))
|
|||
|
|
|
|||
|
|
// 未启用开关
|
|||
|
|
off := headerOverrideTestAccount(PlatformAnthropic, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"user-agent": "hacked"},
|
|||
|
|
})
|
|||
|
|
h = baseline()
|
|||
|
|
off.ApplyHeaderOverrides(h)
|
|||
|
|
require.Equal(t, "orig", h.Get("User-Agent"))
|
|||
|
|
|
|||
|
|
// 禁止覆写的头(authorization / x-api-key / host 等)不会被应用
|
|||
|
|
blocked := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
"Authorization": "Bearer evil",
|
|||
|
|
"X-Api-Key": "evil",
|
|||
|
|
"Host": "evil.example.com",
|
|||
|
|
"Content-Length": "0",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
h = http.Header{}
|
|||
|
|
h.Set("Authorization", "Bearer real-key")
|
|||
|
|
blocked.ApplyHeaderOverrides(h)
|
|||
|
|
require.Equal(t, "Bearer real-key", h.Get("Authorization"))
|
|||
|
|
require.Empty(t, h.Get("X-Api-Key"))
|
|||
|
|
require.Empty(t, h.Get("Host"))
|
|||
|
|
|
|||
|
|
// nil header 不 panic
|
|||
|
|
blocked.ApplyHeaderOverrides(nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
|
|||
|
|
t.Run("nil credentials no-op", func(t *testing.T) {
|
|||
|
|
require.NoError(t, NormalizeHeaderOverrideCredentials(nil))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("missing keys no-op", func(t *testing.T) {
|
|||
|
|
creds := map[string]any{"api_key": "sk-xxx"}
|
|||
|
|
require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
|
|||
|
|
_, exists := creds[credKeyHeaderOverrides]
|
|||
|
|
require.False(t, exists)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("normalizes names and values", func(t *testing.T) {
|
|||
|
|
creds := map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: true,
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
" User-Agent ": " my-agent ",
|
|||
|
|
"X-App": "",
|
|||
|
|
"": "", // 完全空行被丢弃
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
|
|||
|
|
require.Equal(t, map[string]any{
|
|||
|
|
"user-agent": "my-agent",
|
|||
|
|
"x-app": "",
|
|||
|
|
}, creds[credKeyHeaderOverrides])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("accepts map[string]string input", func(t *testing.T) {
|
|||
|
|
creds := map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]string{"X-App": "cli"},
|
|||
|
|
}
|
|||
|
|
require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
|
|||
|
|
require.Equal(t, map[string]any{"x-app": "cli"}, creds[credKeyHeaderOverrides])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects non-bool enabled", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrideEnabled: "yes",
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects non-object overrides", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: []any{"user-agent"},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects non-string value", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"x-app": 123},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects invalid header name", func(t *testing.T) {
|
|||
|
|
for _, name := range []string{"bad name", "bad:name", "bad\nname", "值"} {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{name: "v"},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err, "name %q should be rejected", name)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects empty name with value", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{" ": "v"},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects blocked headers", func(t *testing.T) {
|
|||
|
|
for _, name := range []string{
|
|||
|
|
"Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
|
|||
|
|
"connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
|
|||
|
|
"conversation_id", "x-codex-turn-state", "chatgpt-account-id",
|
|||
|
|
"Content-Type", "Cookie", "x-goog-api-key",
|
|||
|
|
"X-Claude-Code-Session-Id", "x-client-request-id",
|
|||
|
|
} {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{name: "v"},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err, "blocked header %q should be rejected", name)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("allows tab inside value", func(t *testing.T) {
|
|||
|
|
creds := map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"x-app": "a\tb"},
|
|||
|
|
}
|
|||
|
|
require.NoError(t, NormalizeHeaderOverrideCredentials(creds))
|
|||
|
|
require.Equal(t, map[string]any{"x-app": "a\tb"}, creds[credKeyHeaderOverrides])
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects invalid value", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"x-app": "bad\nvalue"},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects duplicate names case-insensitively", func(t *testing.T) {
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{
|
|||
|
|
"User-Agent": "a",
|
|||
|
|
"user-agent": "b",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects too many entries", func(t *testing.T) {
|
|||
|
|
entries := make(map[string]any, maxHeaderOverrideEntries+1)
|
|||
|
|
for i := 0; i <= maxHeaderOverrideEntries; i++ {
|
|||
|
|
entries["x-h-"+string(rune('a'+i%26))+string(rune('a'+(i/26)%26))+string(rune('a'+(i/676)%26))] = "v"
|
|||
|
|
}
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: entries,
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
t.Run("rejects oversized value", func(t *testing.T) {
|
|||
|
|
big := make([]byte, maxHeaderOverrideValueLength+1)
|
|||
|
|
for i := range big {
|
|||
|
|
big[i] = 'a'
|
|||
|
|
}
|
|||
|
|
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
|||
|
|
credKeyHeaderOverrides: map[string]any{"x-app": string(big)},
|
|||
|
|
})
|
|||
|
|
require.Error(t, err)
|
|||
|
|
})
|
|||
|
|
}
|