Files
sub2api/backend/internal/service/claude_code_validator_test.go
T
Wesley LiddickandGitHub af1a032d35 Merge pull request #3375 from StarryKira/fix/3358-vertex-beta-and-cch
fix(gateway): filter anthropic-beta on the Vertex Anthropic path + drop cch sign (#3358)
2026-06-21 21:13:14 +08:00

385 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/stretchr/testify/require"
)
const claudeCodeMetadataUserIDJSON = `{"device_id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","account_uuid":"","session_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}`
func TestClaudeCodeValidator_ProbeBypass(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
ok := validator.Validate(req, map[string]any{
"model": "claude-haiku-4-5",
"max_tokens": 1,
})
require.True(t, ok)
}
func TestClaudeCodeValidator_ProbeBypassRequiresUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
ok := validator.Validate(req, map[string]any{
"model": "claude-haiku-4-5",
"max_tokens": 1,
})
require.False(t, ok)
}
func TestClaudeCodeValidator_MessagesWithoutProbeStillNeedStrictValidation(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
ok := validator.Validate(req, map[string]any{
"model": "claude-haiku-4-5",
"max_tokens": 1,
})
require.False(t, ok)
}
func TestClaudeCodeValidator_CountTokensPathUAOnly(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages/count_tokens", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
})
require.True(t, ok)
}
func TestClaudeCodeValidator_CountTokensPathRequiresUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages/count_tokens", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
})
require.False(t, ok)
}
func TestClaudeCodeValidator_MessagesPathFullValid(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
req.Header.Set("X-App", "claude-code")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
"stream": true,
"system": []any{
map[string]any{
"type": "text",
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
},
},
"metadata": map[string]any{
"user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
},
})
require.True(t, ok)
}
func TestClaudeCodeValidator_BillingBlockRecognizedWithoutIdentityPrompt(t *testing.T) {
// 真实抓取的完整安全监视器 system prompt(不含身份 prose)。
monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
require.NoError(t, err)
validator := NewClaudeCodeValidator()
// 前提:完整监视器正文经 Dice 相似度远低于阈值,无法被身份 prose 机制识别——
// 故下面 Validate 的放行只可能来自计费归因块识别。
require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
// Claude Code 安全监视器子请求:不携带身份 prose,但 system 数组携带计费归因块
// cc_entrypoint=cli,应据此识别为 Claude Code 客户端。
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli; cch=d8726;",
},
map[string]any{
"type": "text",
"text": string(monitorPrompt),
},
},
"metadata": map[string]any{
"user_id": claudeCodeMetadataUserIDJSON,
},
})
require.True(t, ok)
}
func TestClaudeCodeValidator_BillingBlockVSCodeEntrypointRecognized(t *testing.T) {
// 回归:Claude Code 在 VSCode 扩展内运行时,计费块入口为 cc_entrypoint=claude-vscode
// 而非 cli。其安全监视器子请求同样不携带身份 prose,此前写死 cc_entrypoint=cli 的
// 快速通道无法识别它,导致 claude_code_only 分组误拒。入口值不应作为识别条件。
monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
require.NoError(t, err)
validator := NewClaudeCodeValidator()
// 前提:完整监视器正文经 Dice 相似度远低于阈值,放行只可能来自计费归因块识别。
require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.181 (external, claude-vscode, agent-sdk/0.3.181)")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.181.f17; cc_entrypoint=claude-vscode;",
},
map[string]any{
"type": "text",
"text": string(monitorPrompt),
},
},
"metadata": map[string]any{
"user_id": claudeCodeMetadataUserIDJSON,
},
})
require.True(t, ok)
}
func TestClaudeCodeValidator_BillingBlockWithoutEntrypointFallsThrough(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
// 计费块前缀命中但完全没有 cc_entrypoint= 字段,且无身份 prose:
// 不应凭前缀放行,应落回 Dice 检查并失败。验证 cc_entrypoint= 字段的存在仍是必要条件。
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cch=d8726;",
},
map[string]any{
"type": "text",
"text": "Some unrelated system prompt that does not resemble Claude Code.",
},
},
"metadata": map[string]any{
"user_id": claudeCodeMetadataUserIDJSON,
},
})
require.False(t, ok)
}
func TestClaudeCodeValidator_BillingBlockStillRequiresClaudeCodeUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
// 计费块无法绕过 UA 校验:非 claude-cli 客户端在 Step 1 即被拒。
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli; cch=d8726;",
},
},
})
require.False(t, ok)
}
// 新版 Claude Code CLI 已取消 cch=... 签名字段,billing block 形如
// `x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli;`(无 cch)。
// 检测依赖前缀 + cc_entrypoint=cli,不依赖 cch,故无身份 prose 的子请求仍应被识别。
// 这同时覆盖了本仓 mimicry 注入的新格式 block(见 buildBillingAttributionText)。
func TestClaudeCodeValidator_BillingBlockRecognizedWithoutCCH(t *testing.T) {
monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt")
require.NoError(t, err)
validator := NewClaudeCodeValidator()
require.Less(t, validator.bestSimilarityScore(string(monitorPrompt)), systemPromptThreshold)
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.162 (external, cli)")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
// 注意:无 cch 段,对齐新版 CLI 与本仓新的注入格式。
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
},
map[string]any{
"type": "text",
"text": string(monitorPrompt),
},
},
"metadata": map[string]any{
"user_id": claudeCodeMetadataUserIDJSON,
},
})
require.True(t, ok, "无 cch 的新版 billing block 仍应被识别为 Claude Code")
}
// 安全回归:去掉 cch 后检测并未放松——非 claude-cli UA 即便携带无 cch 的 billing block
// 仍在 Step 1 被拒,ClaudeCodeOnly group 不会因此被仿冒绕过。
func TestClaudeCodeValidator_NoCCHBlockStillRequiresClaudeCodeUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
req.Header.Set("X-App", "cli")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-3-5-haiku-20241022",
"system": []any{
map[string]any{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.162.884; cc_entrypoint=cli;",
},
},
})
require.False(t, ok)
}
func TestClaudeCodeValidator_MessagesPathRejectsNonClaudeCodeUA(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "curl/8.0.0")
req.Header.Set("X-App", "claude-code")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
"stream": true,
"system": []any{
map[string]any{
"type": "text",
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
},
},
"metadata": map[string]any{
"user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
},
})
require.False(t, ok)
}
func TestClaudeCodeValidator_MessagesPathWithoutSystemPromptStillRejected(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
req.Header.Set("User-Agent", "claude-cli/2.1.156 (Claude Code)")
req.Header.Set("X-App", "claude-code")
req.Header.Set("anthropic-beta", "claude-code-20250219")
req.Header.Set("anthropic-version", "2023-06-01")
ok := validator.Validate(req, map[string]any{
"model": "claude-opus-4-8",
"stream": true,
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
"metadata": map[string]any{
"user_id": "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
},
})
require.False(t, ok)
}
func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
validator := NewClaudeCodeValidator()
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/models", nil)
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
ok := validator.Validate(req, nil)
require.True(t, ok)
}
func TestExtractVersion(t *testing.T) {
v := NewClaudeCodeValidator()
tests := []struct {
ua string
want string
}{
{"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"},
{"claude-cli/1.0.0", "1.0.0"},
{"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
{"curl/8.0.0", ""}, // 非 Claude CLI
{"", ""}, // 空字符串
{"claude-cli/", ""}, // 无版本号
{"claude-cli/2.1.22-beta", "2.1.22"}, // 带后缀仍提取主版本号
}
for _, tt := range tests {
got := v.ExtractVersion(tt.ua)
require.Equal(t, tt.want, got, "ExtractVersion(%q)", tt.ua)
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"2.1.0", "2.1.0", 0}, // 相等
{"2.1.1", "2.1.0", 1}, // patch 更大
{"2.0.0", "2.1.0", -1}, // minor 更小
{"3.0.0", "2.99.99", 1}, // major 更大
{"1.0.0", "2.0.0", -1}, // major 更小
{"0.0.1", "0.0.0", 1}, // patch 差异
{"", "1.0.0", -1}, // 空字符串 vs 正常版本
{"v2.1.0", "2.1.0", 0}, // v 前缀处理
}
for _, tt := range tests {
got := CompareVersions(tt.a, tt.b)
require.Equal(t, tt.want, got, "CompareVersions(%q, %q)", tt.a, tt.b)
}
}
func TestSetGetClaudeCodeVersion(t *testing.T) {
ctx := context.Background()
require.Equal(t, "", GetClaudeCodeVersion(ctx), "empty context should return empty string")
ctx = SetClaudeCodeVersion(ctx, "2.1.63")
require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
}