fix: 收敛 Claude Code 探测拦截并补齐回归测试
This commit is contained in:
@@ -56,7 +56,8 @@ func NewClaudeCodeValidator() *ClaudeCodeValidator {
|
||||
//
|
||||
// Step 1: User-Agent 检查 (必需) - 必须是 claude-cli/x.x.x
|
||||
// Step 2: 对于非 messages 路径,只要 UA 匹配就通过
|
||||
// Step 3: 对于 messages 路径,进行严格验证:
|
||||
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过(UA 已验证)
|
||||
// Step 4: 对于 messages 路径,进行严格验证:
|
||||
// - System prompt 相似度检查
|
||||
// - X-App header 检查
|
||||
// - anthropic-beta header 检查
|
||||
@@ -75,14 +76,20 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// Step 3: messages 路径,进行严格验证
|
||||
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过
|
||||
// 这类请求用于 Claude Code 验证 API 连通性,不携带 system prompt
|
||||
if isMaxTokensOneHaiku, ok := r.Context().Value(ctxkey.IsMaxTokensOneHaikuRequest).(bool); ok && isMaxTokensOneHaiku {
|
||||
return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
|
||||
}
|
||||
|
||||
// 3.1 检查 system prompt 相似度
|
||||
// Step 4: messages 路径,进行严格验证
|
||||
|
||||
// 4.1 检查 system prompt 相似度
|
||||
if !v.hasClaudeCodeSystemPrompt(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3.2 检查必需的 headers(值不为空即可)
|
||||
// 4.2 检查必需的 headers(值不为空即可)
|
||||
xApp := r.Header.Get("X-App")
|
||||
if xApp == "" {
|
||||
return false
|
||||
@@ -98,7 +105,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
return false
|
||||
}
|
||||
|
||||
// 3.3 验证 metadata.user_id
|
||||
// 4.3 验证 metadata.user_id
|
||||
if body == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
58
backend/internal/service/claude_code_validator_test.go
Normal file
58
backend/internal/service/claude_code_validator_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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_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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
@@ -29,6 +30,7 @@ type ParsedRequest struct {
|
||||
Messages []any // messages 数组
|
||||
HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入)
|
||||
ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名)
|
||||
MaxTokens int // max_tokens 值(用于探测请求拦截)
|
||||
}
|
||||
|
||||
// ParseGatewayRequest 解析网关请求体并返回结构化结果
|
||||
@@ -79,9 +81,55 @@ func ParseGatewayRequest(body []byte) (*ParsedRequest, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// max_tokens
|
||||
if rawMaxTokens, exists := req["max_tokens"]; exists {
|
||||
if maxTokens, ok := parseIntegralNumber(rawMaxTokens); ok {
|
||||
parsed.MaxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseIntegralNumber 将 JSON 解码后的数字安全转换为 int。
|
||||
// 仅接受“整数值”的输入,小数/NaN/Inf/越界值都会返回 false。
|
||||
func parseIntegralNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
|
||||
return 0, false
|
||||
}
|
||||
if v > float64(math.MaxInt) || v < float64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case int8:
|
||||
return int(v), true
|
||||
case int16:
|
||||
return int(v), true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
if v > int64(math.MaxInt) || v < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
i64, err := v.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if i64 > int64(math.MaxInt) || i64 < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(i64), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// FilterThinkingBlocks removes thinking blocks from request body
|
||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||
// This prevents 400 errors from invalid thinking block signatures
|
||||
|
||||
@@ -28,6 +28,20 @@ func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
|
||||
require.True(t, parsed.ThinkingEnabled)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, parsed.MaxTokens)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_MaxTokensNonIntegralIgnored(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1.5}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, parsed.MaxTokens)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_SystemNull(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-3","system":null}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
|
||||
Reference in New Issue
Block a user