diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index 0f02aca769..d54177d132 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -222,6 +222,65 @@ func TestClaudeCodeValidator_BillingBlockStillRequiresClaudeCodeUA(t *testing.T) 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) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3be2df7eae..3908c00abd 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -421,7 +421,9 @@ const ( SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification" // SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false) SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" - // SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false) + // SettingKeyEnableCCHSigning 已废弃(no-op):新版 Claude Code CLI 已取消 cch 签名字段, + // 网关随之不再注入/签名 cch(见 buildBillingAttributionText)。保留该 key 仅为向后兼容, + // 开关不再产生任何效果。 SettingKeyEnableCCHSigning = "enable_cch_signing" // SettingKeyEnableClaudeOAuthSystemPromptInjection 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) SettingKeyEnableClaudeOAuthSystemPromptInjection = "enable_claude_oauth_system_prompt_injection" diff --git a/backend/internal/service/gateway_anthropic_vertex_beta_filter_test.go b/backend/internal/service/gateway_anthropic_vertex_beta_filter_test.go new file mode 100644 index 0000000000..01670e61a6 --- /dev/null +++ b/backend/internal/service/gateway_anthropic_vertex_beta_filter_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func newVertexBetaTestContext(t *testing.T, anthropicBeta string) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + if anthropicBeta != "" { + c.Request.Header.Set("Anthropic-Beta", anthropicBeta) + } + return c +} + +func newVertexServiceAccount(id int64) *Account { + return &Account{ + ID: id, + Platform: PlatformAnthropic, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{ + "project_id": "vertex-proj", + "location": "us-east5", + }, + } +} + +// 复刻线上 400:近期 Claude Code CLI 透传的整份 anthropic-beta header 里含 Vertex +// 不接受的 token(advisor-tool / prompt-caching-scope / redact-thinking / +// thinking-token-count)。Vertex builder 必须剥掉它们,否则上游 HTTP 400(issue #3358)。 +// 本用例在 Commit 1 之前 FAIL、之后 PASS。 +func TestVertexBetaFilter_StripsUnsupportedClaudeCodeTokens(t *testing.T) { + c := newVertexBetaTestContext(t, + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,"+ + "advisor-tool-2026-03-01,prompt-caching-scope-2026-01-05,"+ + "redact-thinking-2026-02-12,thinking-token-count-2026-05-13,"+ + "context-management-2025-06-27") + + body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`) + + svc := &GatewayService{} + req, _, err := svc.buildUpstreamRequest( + context.Background(), c, newVertexServiceAccount(401), body, + "vertex-token", "service_account", "claude-opus-4-7@20260417", false, false, + ) + require.NoError(t, err) + + outBeta := getHeaderRaw(req.Header, "anthropic-beta") + + // Vertex 拒绝的 token 必须全部剥掉。 + for _, bad := range []string{ + "advisor-tool-2026-03-01", + "prompt-caching-scope-2026-01-05", + "redact-thinking-2026-02-12", + "thinking-token-count-2026-05-13", + // 客户端身份 beta:Vertex service_account 不需要,亦不在白名单。 + "claude-code-20250219", + "oauth-2025-04-20", + } { + require.False(t, anthropicBetaTokensContains(outBeta, bad), + "token %q 必须被剥离;实际 outgoing beta=%q", bad, outBeta) + } + + // 白名单内的 token 必须保留。 + for _, keep := range []string{ + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + } { + require.True(t, anthropicBetaTokensContains(outBeta, keep), + "token %q 应保留;实际 outgoing beta=%q", keep, outBeta) + } +} + +// 全部 token 都不受 Vertex 支持时,outgoing header 不应下发 anthropic-beta。 +func TestVertexBetaFilter_DropsHeaderWhenAllUnsupported(t *testing.T) { + c := newVertexBetaTestContext(t, + "prompt-caching-scope-2026-01-05,redact-thinking-2026-02-12") + + body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`) + + svc := &GatewayService{} + req, _, err := svc.buildUpstreamRequest( + context.Background(), c, newVertexServiceAccount(402), body, + "vertex-token", "service_account", "claude-opus-4-7@20260417", false, false, + ) + require.NoError(t, err) + + require.Empty(t, getHeaderRaw(req.Header, "anthropic-beta"), + "所有 token 被剥离后不应残留 anthropic-beta header") +} + +// 能力维度 sanitize 以「最终 beta」为准:客户端只带不支持的 prompt-caching-scope(会被剥光), +// body 又带 context_management → 因最终 header 不含 context-management beta,body 字段必须 strip。 +// 证明 sanitize 不再以原始 client 值为准(修复前用 clientBeta,会错误保留 context_management)。 +func TestVertexBetaFilter_BodySanitizeKeysOnFinalBeta(t *testing.T) { + c := newVertexBetaTestContext(t, "prompt-caching-scope-2026-01-05") + + body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[{"role":"user","content":"hi"}]}`) + + svc := &GatewayService{} + req, _, err := svc.buildUpstreamRequest( + context.Background(), c, newVertexServiceAccount(403), body, + "vertex-token", "service_account", "claude-opus-4-7@20260417", false, false, + ) + require.NoError(t, err) + + got := readRequestBodyForTest(t, req) + require.False(t, gjson.GetBytes(got, "context_management").Exists(), + "最终 beta 不含 context-management 时 body.context_management 必须被 strip") + require.Empty(t, getHeaderRaw(req.Header, "anthropic-beta")) +} + +// BetaPolicy block 规则在 Vertex 路径同样生效:管理员 block 某 token,客户端带它 → 直接报错。 +func TestVertexBetaFilter_BlocksViaBetaPolicy(t *testing.T) { + settings := &BetaPolicySettings{ + Rules: []BetaPolicyRule{ + { + BetaToken: "context-management-2025-06-27", + Action: BetaPolicyActionBlock, + Scope: BetaPolicyScopeAll, + ErrorMessage: "context management is blocked", + }, + }, + } + raw, err := json.Marshal(settings) + require.NoError(t, err) + + svc := &GatewayService{ + settingService: NewSettingService( + &betaPolicySettingRepoStub{values: map[string]string{ + SettingKeyBetaPolicySettings: string(raw), + }}, + &config.Config{}, + ), + } + + c := newVertexBetaTestContext(t, + "interleaved-thinking-2025-05-14,context-management-2025-06-27") + body := []byte(`{"model":"claude-opus-4-7","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`) + + _, _, err = svc.buildUpstreamRequest( + context.Background(), c, newVertexServiceAccount(404), body, + "vertex-token", "service_account", "claude-opus-4-7@20260417", false, false, + ) + require.Error(t, err) + var blocked *BetaBlockedError + require.True(t, errors.As(err, &blocked), "expected *BetaBlockedError, got %T", err) + require.Equal(t, "context management is blocked", err.Error()) +} + +// filterVertexBetaTokens 单元测试:白名单过滤 + drop 集合 + 去重 + 空输入。 +func TestFilterVertexBetaTokens(t *testing.T) { + t.Run("whitelist filters unsupported", func(t *testing.T) { + out := filterVertexBetaTokens( + "interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,context-management-2025-06-27", + nil, + ) + require.Equal(t, "interleaved-thinking-2025-05-14,context-management-2025-06-27", out) + }) + + t.Run("drop set strips before whitelist", func(t *testing.T) { + out := filterVertexBetaTokens( + "interleaved-thinking-2025-05-14,context-management-2025-06-27", + map[string]struct{}{"context-management-2025-06-27": {}}, + ) + require.Equal(t, "interleaved-thinking-2025-05-14", out) + }) + + t.Run("dedupe", func(t *testing.T) { + out := filterVertexBetaTokens( + "context-1m-2025-08-07,context-1m-2025-08-07", + nil, + ) + require.Equal(t, "context-1m-2025-08-07", out) + }) + + t.Run("empty input", func(t *testing.T) { + require.Empty(t, filterVertexBetaTokens("", nil)) + require.Empty(t, filterVertexBetaTokens("prompt-caching-scope-2026-01-05", nil)) + }) +} diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go index 0a5128835a..98f98bc044 100644 --- a/backend/internal/service/gateway_billing_block.go +++ b/backend/internal/service/gateway_billing_block.go @@ -72,12 +72,14 @@ func extractFirstUserText(body []byte) string { // buildBillingAttributionText 构造 system 数组的 billing attribution 文本。 // -// 形态严格对齐真实 Claude Code CLI: +// 形态对齐真实 Claude Code CLI: // -// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000; +// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; // -// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段 -// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。 +// 注意:新版 Claude Code CLI 已不再发送 cch=... 签名字段(见 issue #3358)。我们 +// 随之去掉了 cch 段——继续注入它反而会让伪装请求偏离真实 CLI 流量。cc_version + +// cc_entrypoint=cli 仍保留:它们是客户端识别(claude_code_validator)与 Anthropic +// 第一方判定都依赖的稳定信号。 // // 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的 // Claude Code prompt block 承担)。 @@ -87,7 +89,7 @@ func buildBillingAttributionText(body []byte, cliVersion string) (string, error) } fp := computeClaudeCodeFingerprint(body, cliVersion) return fmt.Sprintf( - "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", + "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli;", cliVersion, fp, ), nil } diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 91fbfd8fdd..5dd0474780 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/cespare/xxhash/v2" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -14,12 +13,6 @@ import ( // the trailing message-derived suffix (e.g. ".c02") if present. var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) -// cchPlaceholderRe matches the cch=00000 placeholder in billing header text, -// scoped to x-anthropic-billing-header to avoid touching user content. -var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`) - -const cchSeed uint64 = 0x6E52736AC806831E - // syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header // system text blocks to match the version extracted from userAgent. // Only touches system array blocks whose text starts with "x-anthropic-billing-header". @@ -53,21 +46,3 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte { return body } - -// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request -// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash. -// The body must contain the placeholder when this function is called. -func signBillingHeaderCCH(body []byte) []byte { - if !cchPlaceholderRe.Match(body) { - return body - } - cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) - return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}")) -} - -// xxHash64Seeded computes xxHash64 of data with a custom seed. -func xxHash64Seeded(data []byte, seed uint64) uint64 { - d := xxhash.NewWithSeed(seed) - _, _ = d.Write(data) - return d.Sum64() -} diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index ffc4091c63..0d5930996a 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -1,13 +1,9 @@ package service import ( - "fmt" "testing" - "github.com/cespare/xxhash/v2" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" ) func TestSyncBillingHeaderVersion(t *testing.T) { @@ -69,97 +65,3 @@ func TestSyncBillingHeaderVersion(t *testing.T) { }) } } - -func TestSignBillingHeaderCCH(t *testing.T) { - t.Run("replaces placeholder with hash", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) - result := signBillingHeaderCCH(body) - - // Should not have the placeholder anymore - assert.NotContains(t, string(result), "cch=00000") - - // Should have a 5 hex-char cch value - billingText := gjson.GetBytes(result, "system.0.text").String() - require.Contains(t, billingText, "cch=") - assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText) - }) - - t.Run("no placeholder - body unchanged", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`) - result := signBillingHeaderCCH(body) - assert.Equal(t, string(body), string(result)) - }) - - t.Run("no billing header - body unchanged", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`) - result := signBillingHeaderCCH(body) - assert.Equal(t, string(body), string(result)) - }) - - t.Run("cch=00000 in user content is not touched", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`) - result := signBillingHeaderCCH(body) - - // Billing header should be signed - billingText := gjson.GetBytes(result, "system.0.text").String() - assert.NotContains(t, billingText, "cch=00000") - - // User message should keep its literal cch=00000 - userText := gjson.GetBytes(result, "messages.0.content.0.text").String() - assert.Contains(t, userText, "cch=00000") - }) - - t.Run("signing is deterministic", func(t *testing.T) { - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) - r1 := signBillingHeaderCCH(body) - body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) - r2 := signBillingHeaderCCH(body2) - assert.Equal(t, string(r1), string(r2)) - }) - - t.Run("matches reference algorithm", func(t *testing.T) { - // Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF - body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) - expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) - - result := signBillingHeaderCCH(body) - billingText := gjson.GetBytes(result, "system.0.text").String() - assert.Contains(t, billingText, "cch="+expectedCCH+";") - }) -} - -func TestXXHash64Seeded(t *testing.T) { - t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) { - inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"} - for _, s := range inputs { - data := []byte(s) - expected := xxhash.Sum64(data) - got := xxHash64Seeded(data, 0) - assert.Equal(t, expected, got, "mismatch for input %q", s) - } - }) - - t.Run("large input matches cespare", func(t *testing.T) { - data := make([]byte, 256) - for i := range data { - data[i] = byte(i) - } - expected := xxhash.Sum64(data) - got := xxHash64Seeded(data, 0) - assert.Equal(t, expected, got) - }) - - t.Run("deterministic with custom seed", func(t *testing.T) { - data := []byte("hello world") - h1 := xxHash64Seeded(data, cchSeed) - h2 := xxHash64Seeded(data, cchSeed) - assert.Equal(t, h1, h2) - }) - - t.Run("different seeds produce different results", func(t *testing.T) { - data := []byte("test data for hashing") - h1 := xxHash64Seeded(data, 0) - h2 := xxHash64Seeded(data, cchSeed) - assert.NotEqual(t, h1, h2) - }) -} diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 51b12809e7..df23974ee0 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "regexp" "strings" "testing" @@ -503,68 +502,6 @@ func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField "回归保护:真 CC + haiku + 客户端带 beta token 时,clear_thinking_20251015 功能不能静默失效") } -// CCH 顺序语义测试:sanitize 必须在 signBillingHeaderCCH 之前, -// 否则签名的 hash 与最终发送的 body 不一致,被 Anthropic 判 third-party。 -// -// 该测试不走 buildUpstreamRequest 完整路径(需要 mock SettingService 成本高), -// 而是直接验证两个顺序产生的 cch 不同,证明二者不可交换。 -// 测试名本身是语义约束的文档化 marker。 -func TestSanitizeMustBeBeforeCCHSigning_HashConsistency(t *testing.T) { - // 构造 body:含 context_management + cch=00000 占位符 - body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cch=00000;"}],"messages":[]}`) - - // 最终发送场景:final beta 不含 context-management beta → sanitize 会 strip - finalBeta := "oauth-2025-04-20,interleaved-thinking-2025-05-14" - - extractCCH := func(t *testing.T, b []byte) string { - t.Helper() - m := regexp.MustCompile(`\bcch=([0-9a-fA-F]{5})\b`).FindSubmatch(b) - require.NotNil(t, m, "body 里找不到 cch=<5hex> :%s", string(b)) - return string(m[1]) - } - - // === 正确顺序:sanitize → signBillingHeaderCCH === - // 1. strip context_management - sanitizedFirst, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBeta) - require.True(t, changed) - require.False(t, gjson.GetBytes(sanitizedFirst, "context_management").Exists()) - // 2. 基于“strip 后的 body”算 hash - correctFinal := signBillingHeaderCCH(sanitizedFirst) - correctCCH := extractCCH(t, correctFinal) - require.NotEqual(t, "00000", correctCCH, "placeholder 应被替换") - - // === 错误顺序:signBillingHeaderCCH → sanitize(未来 regression 场景)=== - // 1. 先基于“含 context_management 的 body”算 hash → cch=H_with - signedFirst := signBillingHeaderCCH(body) - wrongCCH := extractCCH(t, signedFirst) - require.NotEqual(t, "00000", wrongCCH) - // 2. 后 strip context_management → body 变化但 cch 仍是 H_with - wrongFinal, _ := sanitizeAnthropicBodyForBetaTokens(signedFirst, finalBeta) - wrongFinalCCH := extractCCH(t, wrongFinal) - - // === 关键断言 === - // 上游验证逻辑:将 outgoing body 的 cch 还原为 00000、重算 hash、与 cch 字段比较。 - // 模拟上游验证:用发送 body 算出“期望的 cch”,与发送 body 里的 cch 字段比。 - recomputeExpected := func(b []byte, currentCCH string) string { - t.Helper() - // 把 cch= 还原为 cch=00000 - re := regexp.MustCompile(`(\bcch=)` + currentCCH + `(\b)`) - restored := re.ReplaceAll(b, []byte("${1}00000${2}")) - return extractCCH(t, signBillingHeaderCCH(restored)) - } - - // 正确顺序:发送 body 的 cch == 重算 hash → 上游验证过 - require.Equal(t, correctCCH, recomputeExpected(correctFinal, correctCCH), - "正确顺序:final body 里的 cch 与重算 hash 一致 → 上游验证通过") - - // 错误顺序:发送 body 的 cch 是“含 ctx 算的”,但最终 body 不含 ctx → 重算 hash 不同 - require.NotEqual(t, wrongFinalCCH, recomputeExpected(wrongFinal, wrongFinalCCH), - "错误顺序:final body 里的 cch 是基于含 ctx 的 body 算的,"+ - "但发送 body 已 strip ctx → 上游重算 hash 与 cch 不一致 → 被判 third-party。"+ - "这是 buildUpstreamRequest / buildCountTokensRequest 里 sanitize 必须在 "+ - "signBillingHeaderCCH 之前的原因。") -} - // count_tokens 主路径 E2E 集成测试 func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { // count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index ed702e3d06..bbdb8a1458 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -417,7 +417,8 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:") require.Contains(t, billingBlock["text"], "cc_version=") require.Contains(t, billingBlock["text"], "cc_entrypoint=cli") - require.Contains(t, billingBlock["text"], "cch=00000") + // 新版 CLI 已取消 cch=... 签名字段,注入的 billing block 不应再带 cch。 + require.NotContains(t, billingBlock["text"], "cch=") systemBlock, ok := systemArr[1].(map[string]any) require.True(t, ok) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 5a9a33880e..eea5bfaa2e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4444,7 +4444,7 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa } // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: - // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;) + // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli;) // [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control) // [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点) // @@ -4452,9 +4452,9 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa // 区别于真实 CLI。这里注入 claudeCodeSystemPromptExpansion(中性段落)把形态做到 // 接近真实,同时不注入会污染被代理用户行为的工具专属指令。 // - // billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的 - // signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload - // 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。 + // 缺失 billing block 的系统 payload 是 Anthropic 判定第三方的关键信号之一 + // (真实 CLI 每个请求都带)。新版 CLI 已取消 cch=... 签名字段,故 block 不再注入 + // cch(见 buildBillingAttributionText)。 systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig) if blockErr != nil { logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr) @@ -6679,9 +6679,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) var fingerprint *Fingerprint - enableFP, enableMPT, enableCCH := true, false, false + enableFP, enableMPT := true, false if s.settingService != nil { - enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx) + enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) } if account.IsOAuth() && s.identityService != nil { // 1. 获取或创建指纹(包含随机生成的ClientID) @@ -6735,11 +6735,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex body = sanitized } - // CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后) - if enableCCH { - body = signBillingHeaderCCH(body) - } - req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err @@ -6830,6 +6825,48 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex return req, body, nil } +// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta +// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 +// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未 +// 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。 +// +// 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、 +// prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、 +// thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等 +// 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。 +var vertexSupportedBetaTokens = map[string]bool{ + "context-1m-2025-08-07": true, + "context-management-2025-06-27": true, + "fine-grained-tool-streaming-2025-05-14": true, + "interleaved-thinking-2025-05-14": true, +} + +// filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的 +// token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。 +// 返回最终 header(可能为空字符串)。 +func filterVertexBetaTokens(header string, drop map[string]struct{}) string { + tokens := parseAnthropicBetaHeader(header) + if len(tokens) == 0 { + return "" + } + out := make([]string, 0, len(tokens)) + seen := make(map[string]bool, len(tokens)) + for _, t := range tokens { + if _, dropped := drop[t]; dropped { + continue + } + if !vertexSupportedBetaTokens[t] { + continue + } + if seen[t] { + continue + } + seen[t] = true + out = append(out, t) + } + return strings.Join(out, ",") +} + func (s *GatewayService) buildUpstreamRequestAnthropicVertex( ctx context.Context, c *gin.Context, @@ -6844,14 +6881,27 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex( return nil, err } - // 能力维度 sanitize:Vertex 路径上 anthropic-beta header 原样透传客户端值 - // (下面白名单跳过 anthropic-version 但保留 anthropic-beta),依此决定是否 - // 保留 body 中的 context_management,与 Anthropic 直连 / Bedrock 路径对称。 + // 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮 + // beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的 + // advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 / + // redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受 + // (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的 + // resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单 + // 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。 + clientBeta := "" if c != nil && c.Request != nil { - clientBeta := getHeaderRaw(c.Request.Header, "anthropic-beta") - if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, clientBeta); changed { - vertexBody = sanitized - } + clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta") + } + policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID) + if policy.blockErr != nil { + return nil, policy.blockErr + } + finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet)) + + // 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的 + // context_management,与 Anthropic 直连 / Bedrock 路径对称。 + if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed { + vertexBody = sanitized } fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream) if err != nil { @@ -6883,6 +6933,13 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex( setHeaderRaw(req.Header, "authorization", "Bearer "+token) setHeaderRaw(req.Header, "content-type", "application/json") + // 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。 + // finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。 + deleteHeaderAllForms(req.Header, "anthropic-beta") + if finalBeta != "" { + setHeaderRaw(req.Header, "anthropic-beta", finalBeta) + } + s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{ "url": req.URL.String(), "token_type": "service_account", @@ -10181,9 +10238,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false + ctEnableFP, ctEnableMPT := true, false if s.settingService != nil { - ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx) + ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx) } var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { @@ -10218,9 +10275,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con body = sanitized } - if ctEnableCCH { - body = signBillingHeaderCCH(body) - } body = sanitizeCountTokensRequestBody(body) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fc4f27b281..932647dd54 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -192,7 +192,7 @@ type SystemSettings struct { // Gateway forwarding behavior EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) - EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) + EnableCCHSigning bool // 已废弃 no-op:新版 CLI 取消 cch 签名后网关不再注入/签名 cch,开关无效果 EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认 ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认