| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- package service
- import (
- "context"
- "net/http"
- "regexp"
- "strconv"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
- )
- // ClaudeCodeValidator 验证请求是否来自 Claude Code 客户端
- // 完全学习自 claude-relay-service 项目的验证逻辑
- type ClaudeCodeValidator struct{}
- var (
- // User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI,大小写不敏感)
- claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
- // 带捕获组的版本提取正则
- claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`)
- // System prompt 相似度阈值(默认 0.5,和 claude-relay-service 一致)
- systemPromptThreshold = 0.5
- )
- // Claude Code 官方 System Prompt 模板
- // 从 claude-relay-service/src/utils/contents.js 提取
- var claudeCodeSystemPrompts = []string{
- // claudeOtherSystemPrompt1 - Primary
- "You are Claude Code, Anthropic's official CLI for Claude.",
- // claudeOtherSystemPrompt3 - Agent SDK
- "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
- // claudeOtherSystemPrompt4 - Compact Agent SDK
- "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.",
- // exploreAgentSystemPrompt
- "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude.",
- // claudeOtherSystemPromptCompact - Compact (用于对话摘要)
- "You are a helpful AI assistant tasked with summarizing conversations.",
- // claudeOtherSystemPrompt2 - Secondary (长提示词的关键部分)
- "You are an interactive CLI tool that helps users",
- }
- const (
- // These markers identify Claude Code's official security-monitor classifier
- // request without coupling validation to every wording change in the prompt.
- claudeCodeSecurityMonitorPromptPrefix = "You are a security monitor for autonomous AI coding agents."
- claudeCodeSecurityMonitorPromptMinLen = 10_000
- // claudeCodeBillingHeaderPrefix 是 Claude Code 在 system 数组首块注入的计费归因块前缀。
- // 大多数真实 CLI 请求(含部分无身份 prose 的子请求)会携带该块;不携带该块的
- // 固定官方辅助请求由独立规则识别。该格式比身份 prose 更稳定。
- // 生成见 gateway_billing_block.go;同类识别见 pkg/apicompat/anthropic_to_responses.go。
- claudeCodeBillingHeaderPrefix = "x-anthropic-billing-header"
- // claudeCodeEntrypointMarker 标识计费块携带入口归因字段。不绑定具体入口值
- // (cli / claude-vscode / jetbrains / sdk 等都是真实入口):入口值会随新增 IDE 漂移,
- // 且伪造者同样可填任意值、不构成防伪边界,故仅要求该字段存在即可。
- claudeCodeEntrypointMarker = "cc_entrypoint="
- )
- // NewClaudeCodeValidator 创建验证器实例
- func NewClaudeCodeValidator() *ClaudeCodeValidator {
- return &ClaudeCodeValidator{}
- }
- // Validate 验证请求是否来自 Claude Code CLI
- // 采用与 claude-relay-service 完全一致的验证策略:
- //
- // Step 1: User-Agent 检查 (必需) - 必须是 claude-cli/x.x.x
- // Step 2: 对于非 messages 路径和 /messages/count_tokens,只要 UA 匹配就通过
- // Step 3: 检查 max_tokens=1 + haiku 探测请求绕过(UA 已验证)
- // Step 4: 对于 messages 路径,进行严格验证:
- // - System prompt 相似度检查
- // - X-App header 检查
- // - anthropic-beta header 检查
- // - anthropic-version header 检查
- // - metadata.user_id 格式验证
- func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) bool {
- // Step 1: User-Agent 检查
- ua := r.Header.Get("User-Agent")
- if !claudeCodeUAPattern.MatchString(ua) {
- return false
- }
- // Step 2: 非 messages 路径只要 UA 匹配就通过
- path := r.URL.Path
- if !strings.Contains(path, "messages") {
- return true
- }
- // count_tokens 是 Claude Code 官方辅助请求,通常不携带完整 messages system prompt。
- if isMessagesCountTokensPath(path) {
- return true
- }
- // Step 3: 检查 max_tokens=1 + haiku 探测请求绕过
- // 这类请求用于 Claude Code 验证 API 连通性,不携带 system prompt
- if isMaxTokensOneHaiku, ok := IsMaxTokensOneHaikuRequestFromContext(r.Context()); ok && isMaxTokensOneHaiku {
- return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
- }
- // 探测请求并不总是打到 haiku:CLI 切换模型、刷新上下文用量时会向当前模型发
- // max_tokens=1 的轻量请求,同样不携带 system。UA 已过 Step 1,且 1 个输出 token
- // 对滥用者没有价值,故按请求体放行,不再限定模型名。
- if isMaxTokensOneBody(body) {
- return true
- }
- // Step 4: messages 路径,进行严格验证
- // 4.1 检查 system prompt 相似度
- if !v.hasClaudeCodeSystemPrompt(body) {
- return false
- }
- // 4.2 检查必需的 headers(值不为空即可)
- xApp := r.Header.Get("X-App")
- if xApp == "" {
- return false
- }
- anthropicBeta := r.Header.Get("anthropic-beta")
- if anthropicBeta == "" {
- return false
- }
- anthropicVersion := r.Header.Get("anthropic-version")
- if anthropicVersion == "" {
- return false
- }
- // 4.3 验证 metadata.user_id
- if body == nil {
- return false
- }
- metadata, ok := body["metadata"].(map[string]any)
- if !ok {
- return false
- }
- userID, ok := metadata["user_id"].(string)
- if !ok || userID == "" {
- return false
- }
- if ParseMetadataUserID(userID) == nil {
- return false
- }
- return true
- }
- func isMessagesCountTokensPath(path string) bool {
- return strings.HasSuffix(path, "/messages/count_tokens")
- }
- // isMaxTokensOneBody 判断请求体是否显式声明 max_tokens=1。
- // 兼容 JSON 反序列化出的 float64 与 ParsedRequest 复用时的 int。
- func isMaxTokensOneBody(body map[string]any) bool {
- if body == nil {
- return false
- }
- switch v := body["max_tokens"].(type) {
- case float64:
- return v == 1
- case int:
- return v == 1
- case int64:
- return v == 1
- default:
- return false
- }
- }
- // hasClaudeCodeSystemPrompt 检查请求是否包含 Claude Code 系统提示词
- // 使用字符串相似度匹配(Dice coefficient)
- func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) bool {
- if body == nil {
- return false
- }
- // 检查 model 字段
- if _, ok := body["model"].(string); !ok {
- return false
- }
- // 获取 system 字段
- systemEntries, ok := body["system"].([]any)
- if !ok {
- return false
- }
- if isClaudeCodeSecurityMonitorPrompt(systemEntries) {
- return true
- }
- // 检查每个 system entry
- for _, entry := range systemEntries {
- entryMap, ok := entry.(map[string]any)
- if !ok {
- continue
- }
- text, ok := entryMap["text"].(string)
- if !ok || text == "" {
- continue
- }
- // 计费归因块识别(WHY 见 claudeCodeBillingHeaderPrefix 注释)。先于 Dice 检查,
- // 大小写敏感:该块由 gateway_billing_block.go 固定小写生成。
- if strings.HasPrefix(text, claudeCodeBillingHeaderPrefix) &&
- strings.Contains(text, claudeCodeEntrypointMarker) {
- return true
- }
- // 计算与所有模板的最佳相似度
- bestScore := v.bestSimilarityScore(text)
- if bestScore >= systemPromptThreshold {
- return true
- }
- }
- return false
- }
- // claudeCodeSecurityMonitorMarkers 与固定前缀、长度下限共同构成分类器提示词的
- // 判别条件,须全部命中。
- var claudeCodeSecurityMonitorMarkers = []string{
- "## Threat Model",
- "- `<transcript>`:",
- "## HARD BLOCK",
- "## SOFT BLOCK",
- "## Classification Process",
- "## Output Format",
- "<block>yes</block>",
- "<block>no</block>",
- }
- // isClaudeCodeSecurityMonitorPrompt 识别 Claude Code auto 模式安全监视器分类器请求。
- // 真实 CLI(实测 2.1.220)会在监视器提示词之外追加独立的会话上下文 system 块,
- // entry 数量不受服务端控制,故逐 entry 查找匹配项而非限定恰好一个 entry。
- func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool {
- for _, raw := range systemEntries {
- entry, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- entryType, ok := entry["type"].(string)
- if !ok || entryType != "text" {
- continue
- }
- text, ok := entry["text"].(string)
- if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen ||
- !strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) {
- continue
- }
- if hasAllClaudeCodeSecurityMonitorMarkers(text) {
- return true
- }
- }
- return false
- }
- func hasAllClaudeCodeSecurityMonitorMarkers(text string) bool {
- for _, marker := range claudeCodeSecurityMonitorMarkers {
- if !strings.Contains(text, marker) {
- return false
- }
- }
- return true
- }
- // bestSimilarityScore 计算文本与所有 Claude Code 模板的最佳相似度
- func (v *ClaudeCodeValidator) bestSimilarityScore(text string) float64 {
- normalizedText := normalizePrompt(text)
- bestScore := 0.0
- for _, template := range claudeCodeSystemPrompts {
- normalizedTemplate := normalizePrompt(template)
- score := diceCoefficient(normalizedText, normalizedTemplate)
- if score > bestScore {
- bestScore = score
- }
- }
- return bestScore
- }
- // normalizePrompt 标准化提示词文本(去除多余空白)
- func normalizePrompt(text string) string {
- // 将所有空白字符替换为单个空格,并去除首尾空白
- return strings.Join(strings.Fields(text), " ")
- }
- // diceCoefficient 计算两个字符串的 Dice 系数(Sørensen–Dice coefficient)
- // 这是 string-similarity 库使用的算法
- // 公式: 2 * |intersection| / (|bigrams(a)| + |bigrams(b)|)
- func diceCoefficient(a, b string) float64 {
- if a == b {
- return 1.0
- }
- if len(a) < 2 || len(b) < 2 {
- return 0.0
- }
- // 生成 bigrams
- bigramsA := getBigrams(a)
- bigramsB := getBigrams(b)
- if len(bigramsA) == 0 || len(bigramsB) == 0 {
- return 0.0
- }
- // 计算交集大小
- intersection := 0
- for bigram, countA := range bigramsA {
- if countB, exists := bigramsB[bigram]; exists {
- if countA < countB {
- intersection += countA
- } else {
- intersection += countB
- }
- }
- }
- // 计算总 bigram 数量
- totalA := 0
- for _, count := range bigramsA {
- totalA += count
- }
- totalB := 0
- for _, count := range bigramsB {
- totalB += count
- }
- return float64(2*intersection) / float64(totalA+totalB)
- }
- // getBigrams 获取字符串的所有 bigrams(相邻字符对)
- func getBigrams(s string) map[string]int {
- bigrams := make(map[string]int)
- runes := []rune(strings.ToLower(s))
- for i := 0; i < len(runes)-1; i++ {
- bigram := string(runes[i : i+2])
- bigrams[bigram]++
- }
- return bigrams
- }
- // ValidateUserAgent 仅验证 User-Agent(用于不需要解析请求体的场景)
- func (v *ClaudeCodeValidator) ValidateUserAgent(ua string) bool {
- return claudeCodeUAPattern.MatchString(ua)
- }
- // IncludesClaudeCodeSystemPrompt 检查请求体是否包含 Claude Code 系统提示词
- // 只要存在匹配的系统提示词就返回 true(用于宽松检测)
- func (v *ClaudeCodeValidator) IncludesClaudeCodeSystemPrompt(body map[string]any) bool {
- return v.hasClaudeCodeSystemPrompt(body)
- }
- // IsClaudeCodeClient 从 context 中获取 Claude Code 客户端标识
- func IsClaudeCodeClient(ctx context.Context) bool {
- if v, ok := ctx.Value(ctxkey.IsClaudeCodeClient).(bool); ok {
- return v
- }
- return false
- }
- // SetClaudeCodeClient 将 Claude Code 客户端标识设置到 context 中
- func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context {
- return context.WithValue(ctx, ctxkey.IsClaudeCodeClient, isClaudeCode)
- }
- // ExtractVersion 从 User-Agent 中提取 Claude Code 版本号
- // 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串
- func (v *ClaudeCodeValidator) ExtractVersion(ua string) string {
- return ExtractCLIVersion(ua)
- }
- // SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中
- func SetClaudeCodeVersion(ctx context.Context, version string) context.Context {
- return context.WithValue(ctx, ctxkey.ClaudeCodeVersion, version)
- }
- // GetClaudeCodeVersion 从 context 中获取 Claude Code 版本号
- func GetClaudeCodeVersion(ctx context.Context) string {
- if v, ok := ctx.Value(ctxkey.ClaudeCodeVersion).(string); ok {
- return v
- }
- return ""
- }
- // CompareVersions 比较两个 semver 版本号
- // 返回: -1 (a < b), 0 (a == b), 1 (a > b)
- func CompareVersions(a, b string) int {
- aParts := parseSemver(a)
- bParts := parseSemver(b)
- for i := 0; i < 3; i++ {
- if aParts[i] < bParts[i] {
- return -1
- }
- if aParts[i] > bParts[i] {
- return 1
- }
- }
- return 0
- }
- // parseSemver 解析 semver 版本号为 [major, minor, patch]
- func parseSemver(v string) [3]int {
- v = strings.TrimPrefix(v, "v")
- parts := strings.Split(v, ".")
- result := [3]int{0, 0, 0}
- for i := 0; i < len(parts) && i < 3; i++ {
- if parsed, err := strconv.Atoi(parts[i]); err == nil {
- result[i] = parsed
- }
- }
- return result
- }
|