gcli2api / internal /upstream /sanitize.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
9.36 kB
// sanitize.go 出站请求体脱敏:剥离上游内容审核黑名单指纹。
//
// 背景:客户端(Claude Code 类 CLI)在 system prompt 注入若干固定模板句,
// 上游内容审核按逐字精确匹配拦截(非语义审核),一字改动即可绕过。
// 策略:键值/header 型指纹整段剥离;承载语义的模板句最小改写(换一词),语义不变。
package upstream
import (
"regexp"
"strings"
)
// sanitizeFeatures 特征预检:任一命中才进入净化(strings.Contains 快速路径,
// 普通请求全不中 → 原样返回,零分配)。
var sanitizeFeatures = []string{
"x-anthropic-billing-header", // header 键值段键名
"cc_entrypoint=", // 尾随裸键值(截断前缀即可命中)
"You are Claude Code", // 身份句(截断前缀即可命中)
"Main branch (", // 注入指令句(截断前缀即可命中)
"You are a coding agent running in the Codex CLI", // Codex instructions 首段(截断前缀即可命中)
"github.com/anthropics/", // 反馈句里的 Anthropic 仓库链接
"11128", // 上游反探测:裸数字错误码
}
// sanitizeHdrRe 剥离层:header 键名即触发(与值无关),整段删除。
var sanitizeHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header:[^;\n]*;?\s*`)
// sanitizeBareHdrRe 兜底层:裸键名(无冒号无值)同样是指纹——2026-09-13 实验 F4
// 证实 assistant 消息里反引号引用裸键名即触发 11128,而剥离层要求冒号、对裸串无效。
// 键值形态被整段删除后,残留的裸键名做最小缩写(header→hdr):破坏逐字匹配、
// 语义不变、保留可读性。大小写不敏感,覆盖 X-Anthropic-... 变体。
//
// 注意该正则不要求冒号,是 sanitizeHdrRe 的超集——hasFingerprint 与 sanitizeText
// 中两者并用:先删键值形态(sanitizeHdrRe),再缩写残留裸键名(本正则),
// 替换语义不同(整段删除 vs 最小缩写),不可合并为一个正则。
var sanitizeBareHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-header`)
// sanitizeKvRe 剥离层:尾随裸键值(cc_xxx=...;)循环清理。
var sanitizeKvRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\n]*;?\s*`)
// sanitizeRewrites 改写层:全模板句逐字替换(每句只改一个词,语义不变)。
//
// 身份句的匹配串**不带结尾标点**(只到 "…for Claude" 为止):
// CLI 版这句以句号收尾("…for Claude."),桌面版(claude-desktop-3p / Agent SDK)
// 以逗号接后继内容("…for Claude, running within the Claude Agent SDK.")。
// 带句号的整句只匹配前者,桌面版会漏网、指纹原样发上游 → 400 code=11128。
// 去掉结尾标点后两种形态一并覆盖(替换串同样不带标点,让原有标点原样保留)。
// 注意仍要求 "You are Claude Code, " 前缀,不做更宽的子串替换,
// 以免误伤 TestExactMatchOnlyVariantNotTouched 所保护的零散文本。
var sanitizeRewrites = [][2]string{
{
"You are Claude Code, Anthropic's official CLI for Claude",
"You are Claude Code, Anthropic's official CLI tool for Claude",
},
{
"Main branch (you will usually use this for PRs)",
"Default branch (you will usually use this for PRs)",
},
{
"You are a coding agent running in the Codex CLI, a terminal-based coding assistant.",
"You are a coding agent running in the Codex CLI tool, a terminal-based coding assistant.",
},
{
// 反馈句:整句带 Anthropic 仓库链接,上游按整句拦截(只留链接或只留半边均不拦,
// 实测需整句同时出现)。give→provide 一词之差即可绕过,语义不变。
"To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues",
"To provide feedback, users should report the issue at https://github.com/anthropics/claude-code/issues",
},
{
// 上游反探测:只要请求体里出现裸数字 11128 就整单拦截(与该数字的上下文无关——
// "code=11128" / 裸 "11128" / "错误码 11128" / "Code=11128" 全部命中;
// 相邻的 11148 / 11101 / 11115 / 99999 均放行)。11128 正是本类拦截自身的错误码,
// 上游据此识别"在讨论/回显其内部错误码"的请求。
// 代价:用户对话中任何 11128 都会被改写——但这串数字出现在请求里本身就是拦截条件,
// 不改写必然失败。插入连字符保留可读性与指代(零宽空格无效,实测上游会归一化)。
"11128",
"11-128",
},
}
// sanitizeText 单段文本净化:预检不中 → 返回原串(零分配)。
func sanitizeText(text string) string {
if !hasFingerprint(text) {
return text
}
for _, rw := range sanitizeRewrites {
text = strings.ReplaceAll(text, rw[0], rw[1])
}
if sanitizeHdrRe.MatchString(text) {
text = sanitizeHdrRe.ReplaceAllString(text, "")
}
if strings.Contains(text, "cc_") {
prev := ""
for prev != text { // 清尾随裸 kv(cc_version=...; cc_entrypoint=...;)
prev = text
text = sanitizeKvRe.ReplaceAllString(text, "")
}
}
// 兜底:键值形态已在上面整段删除,这里只剩裸键名(引用/示例文本形态)。
text = sanitizeBareHdrRe.ReplaceAllString(text, "x-anthropic-billing-hdr")
return strings.TrimSpace(text)
}
// hasFingerprint 特征预检:先走 strings.Contains 快速路径(零分配);
// header 键名有大小写变体(X-Anthropic-...)且可能以裸键名形态出现(无冒号),
// Contains 大小写敏感、sanitizeHdrRe 要求冒号——两者都会漏掉「混合大小写 + 裸键名」,
// 必须再用不要求冒号的 (?i) 正则兜底(sanitizeBareHdrRe),否则整条净化被跳过。
// sanitizeBareHdrRe 不要求冒号,是 sanitizeHdrRe 的超集,故无需再单独匹配后者。
func hasFingerprint(text string) bool {
for _, f := range sanitizeFeatures {
if strings.Contains(text, f) {
return true
}
}
return sanitizeBareHdrRe.MatchString(text)
}
// sanitizeContent 兼容字符串与多模态数组;只动 text part,image 等 part 不动。
// 返回净化后的值及是否发生变化。
func sanitizeContent(v any) (any, bool) {
switch c := v.(type) {
case string:
s := sanitizeText(c)
return s, s != c
case []any:
changed := false
for _, p := range c {
m, ok := p.(map[string]any)
if !ok {
continue
}
text, ok := m["text"].(string)
if !ok {
continue
}
if s := sanitizeText(text); s != text {
m["text"] = s
changed = true
}
}
return c, changed
}
return v, false
}
// sanitizeToolCalls 净化 assistant.tool_calls[].function.arguments。
//
// arguments 是**字符串化的 JSON**(不是对象),因此按文本走 sanitizeText 即可。
// 这块长期是盲区:工具调用消息的 content 通常是 null,而旧版 sanitizeMessages
// 在 content 缺失时直接 continue,整条消息连 tool_calls 一起被跳过——
// 于是历史里任何写进工具参数的被拦字符串(文件名、命令、写入内容)都会原样漏出。
func sanitizeToolCalls(v any) bool {
callList, ok := v.([]any)
if !ok {
return false
}
changed := false
for _, c := range callList {
call, ok := c.(map[string]any)
if !ok {
continue
}
fn, ok := call["function"].(map[string]any)
if !ok {
continue
}
args, ok := fn["arguments"].(string)
if !ok {
continue
}
if s := sanitizeText(args); s != args {
fn["arguments"] = s
changed = true
}
}
return changed
}
// sanitizeMessages 净化 messages 中的 content 与 tool_calls;任一命中返回 true。
func sanitizeMessages(messages []any) bool {
changed := false
for _, msg := range messages {
m, ok := msg.(map[string]any)
if !ok {
continue
}
// content 与 tool_calls 各自独立判断:content 可以为 null(工具调用轮),
// 早期版本在此 continue,导致这类消息的 tool_calls 完全不被净化。
if c, ok := m["content"]; ok {
if nc, ch := sanitizeContent(c); ch {
m["content"] = nc
changed = true
}
}
// reasoning_content(思维链回填字段,见 thinking.go/sse.go)实测同样
// 携带指纹,与 content 同等净化。string 形态直接走 sanitizeText。
if rc, ok := m["reasoning_content"].(string); ok {
if s := sanitizeText(rc); s != rc {
m["reasoning_content"] = s
changed = true
}
}
// reasoning:另一形态的思维链字段。thinking.go 的回填会把客户端送来的
// reasoning_content 镜像进 reasoning(反之亦然),而此前只洗 content /
// reasoning_content / tool_calls —— 镜像进 reasoning 的指纹原样出站,
// 裸 "11128" 这种反探测串同样致命。
if r, ok := m["reasoning"].(string); ok {
if s := sanitizeText(r); s != r {
m["reasoning"] = s
changed = true
}
}
if tc, ok := m["tool_calls"]; ok {
if sanitizeToolCalls(tc) {
changed = true
}
}
}
return changed
}