mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
将 codex_cli_only 客户端识别从「单一 strict 开关 + 固定 OR 头集合」重构为 可逐项管理的引擎指纹信号列表,加固整条判定链,并补齐账号级 app-server 控制、 对齐前端设置文案。 判定链(每步可短路): - 账号未开 codex_cli_only → 不限制;gateway.force_codex_cli → 旁路放行 - 全局黑名单命中(OR 宽 deny)→ 立即拒 - 身份候选:官方 UA(strict,仅前缀)/ 官方 originator(OR)/ 全局白名单(双因子 AND) / 全局 app-server 开关 OR 账号 app-server 开关;均不命中 → 拒 - 版本门(仅官方候选):UA 须可解析引擎版本,再校验 [min,max] 区间 - 引擎指纹 AND 硬门:按信号列表逐条勾选 AND、每条行内变体 OR;无 Required 信号 → 放行 引擎指纹信号列表(唯一真源) - 新增 openai.EngineFingerprintSignal 类型 + EvaluateEngineFingerprint 求值器 (勾选 AND / 行内变体 OR / 无勾选 → 放行) - CodexRestrictionPolicy 增 EngineFingerprintSignals;信号列表单一决定是否启用指纹门, 不再保留独立「要求引擎指纹」总开关(与「信号全不选」语义重复) - 新设置键 codex_cli_only_engine_fingerprint_signals(默认只勾 x-codex- 前缀); 旧 body 指纹开关幂等迁移并入信号列表;wire 接线 - 黑/白名单自由条目、命名预设、版本区间 全局设置管线 - gateway 缺 settingService(仅测试/误配可达)时指纹门回退默认种子信号、失败关闭, 不再因零值 policy(nil 信号)失败开放 账号级 Codex app-server(替换已失效的 ClaudeCode 放行机制) - account.IsCodexCLIOnlyAppServerAllowed() 读 extra.codex_cli_only_allow_app_server, 仅在 codex_cli_only 开启时生效;候选身份门「全局 OR 账号」,与旧系统双层控制对齐 - 移除已无入口的 claude_code 预设机制(allowedClientRegistry / MatchAllowedClients / 账号 GetCodexCLIOnlyAllowedClients / reason);白名单 AllowedClientEntry / IsAllowedClientMatch 保留 门加固(反伪 + 写入校验) - 官方 UA 访问门改 strict:IsCodexOfficialClientRequestStrict 仅前缀匹配,收窄「浏览器前缀 + 中段 codex token」伪造面(strict 仍保留 Codex 家族前缀与 UA 尾部兜底,故对「任意前缀 + 官方尾部 (name;ver)」仍放行——与 UA 可伪造、真正反伪靠引擎指纹门的设计一致) - 官方客户端识别扩展:新增 codex-tui/、codex_vscode_copilot/ 前缀 + UA 尾部 (name;ver) 兜底 (恢复 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 的真实 client,如 cccc→codex-tui),originator 改 精确集。该识别经 IsCodexOfficialClientByHeaders 被 passthrough 复用,故透传的官方判定一并 修正(codex-tui 等不再被误改写 UA)——非「行为不变」,属有意修正 - 白名单写入校验 ValidateCodexWhitelistEntriesJSON + AllowedClientEntry.IsWhitelistable: 双因子 AND 条目须可命中(非空 originator + 非空 ua_contains),拒绝写入会静默失效的死规则; 黑名单(OR 宽 deny,允许 originator-only)不受约束 管理端 / 前端 - handler / DTO / settings_view / 契约测试;gateway 接入判定链 - 信号列表编辑器(替换 body 开关)、api 类型、SettingsView;无勾选给常驻警告 - Create/Edit/Bulk 三弹窗「Codex Only」下新增 app-server 开关(OR 合并全局) - 文案:UA/Originator → User-Agent/Originator;黑/白名单重命名为 User-Agent/Originator 黑/白名单; 「允许 App Server 第三方客户端」→「Codex app-server」+ 简介示例;i18n zh/en 同步 - 移除死代码 HasCodex*Fingerprint helper 测试:引擎指纹求值器 / 账号 app-server(OR 语义)/ detector(含 N1 strict、失败关闭)/ 白名单写入校验 / BulkEdit spec 等;后端 build + service/openai/admin 单测全绿,前端 vue-tsc + vitest 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
9.6 KiB
Go
224 lines
9.6 KiB
Go
package openai
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
|
||
// Examples: "codex_vscode/1.0.0", "codex_cli_rs/0.1.2"
|
||
var CodexCLIUserAgentPrefixes = []string{
|
||
"codex_vscode/",
|
||
"codex_cli_rs/",
|
||
}
|
||
|
||
// codexOfficialClientUAPrefixes:Codex 官方客户端家族 User-Agent 前缀(均含下划线/连字符,
|
||
// 每项都是确定字面量;不含会被 TrimSpace 退化成裸 "codex" 的空格前缀)。
|
||
// 用途:OpenAI OAuth `codex_cli_only` 访问限制判定 + passthrough 的「非官方 UA 安全兜底」
|
||
// (IsCodexOfficialClientRequest 命中即视为官方真实 UA,逐字透传、不改写)。
|
||
//
|
||
// Cursor/VSCode 扩展两种 UA:默认 `codex_vscode/`、GitHub Copilot 集成模式 `codex_vscode_copilot/`
|
||
// (取证 extension.js `IS="codex_vscode_copilot"` 经 env 注入);交互式 TUI 自报 `codex-tui/`
|
||
// (连字符,2026-06-23 审计抽样约占真实流量 35%,必须显式列出)。`Codex Desktop/` 等 `Codex `
|
||
// 前缀家族由 codexOfficialClientFamilyPrefix 单独处理(保留空格,避免退化为裸 codex 的宽松兜底)。
|
||
var codexOfficialClientUAPrefixes = []string{
|
||
"codex_cli_rs/",
|
||
"codex-tui/",
|
||
"codex_vscode/",
|
||
"codex_vscode_copilot/",
|
||
"codex_app/",
|
||
"codex_chatgpt_desktop/",
|
||
"codex_atlas/",
|
||
"codex_exec/",
|
||
"codex_sdk_ts/",
|
||
}
|
||
|
||
// codexOfficialClientFamilyPrefix 覆盖 `Codex ` 前缀家族(Codex Desktop 等),对应 codex-rs
|
||
// is_first_party_originator 的 starts_with("Codex ")。**保留尾随空格**,并以 HasPrefix 直接比对
|
||
// 已归一化(小写 + 去首尾空格)的值——绝不能再经 normalizeCodexClientHeader 处理本前缀,否则
|
||
// 空格被 TrimSpace 去掉、退化成裸 "codex" 而把任何含 codex 的串都放行。
|
||
const codexOfficialClientFamilyPrefix = "codex "
|
||
|
||
// codexOfficialClientOriginators:Codex 官方客户端家族 originator 精确集合。
|
||
// app-server `initialize` 把 originator 设为 clientInfo.name 逐字值(codex-rs default_client.rs),
|
||
// 故官方集合是这些确定字面量;镜像 is_first_party_originator / is_first_party_chat_originator
|
||
// 并叠加 sub2api 已取证变体。用精确匹配而非「含 codex_/codex」的宽松兜底,避免 evil-codex_ 之类
|
||
// 伪造绕过(gate 仍需 UA 双因子佐证)。新官方/合作客户端经 allowed_client.go 命名预设放行,
|
||
// 或在 bump context/codex 时同步补入本集合。
|
||
var codexOfficialClientOriginators = map[string]bool{
|
||
"codex_cli_rs": true, // CLI 默认 DEFAULT_ORIGINATOR
|
||
"codex-tui": true, // 交互式 TUI(连字符,真实流量占比最高)
|
||
"codex_vscode": true, // VSCode/Cursor 扩展
|
||
"codex_vscode_copilot": true, // 扩展 GitHub Copilot 集成模式
|
||
"codex_app": true, // 历史保留
|
||
"codex_chatgpt_desktop": true, // is_first_party_chat_originator
|
||
"codex_atlas": true, // is_first_party_chat_originator
|
||
"codex_exec": true, // codex exec 非交互
|
||
"codex_sdk_ts": true, // TypeScript SDK
|
||
}
|
||
|
||
// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。
|
||
// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。
|
||
// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。
|
||
func IsBrowserUserAgent(userAgent string) bool {
|
||
ua := strings.TrimSpace(userAgent)
|
||
if ua == "" {
|
||
return false
|
||
}
|
||
return strings.HasPrefix(strings.ToLower(ua), "mozilla/")
|
||
}
|
||
|
||
// IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
|
||
func IsCodexCLIRequest(userAgent string) bool {
|
||
ua := normalizeCodexClientHeader(userAgent)
|
||
if ua == "" {
|
||
return false
|
||
}
|
||
return matchCodexClientHeaderPrefixes(ua, CodexCLIUserAgentPrefixes)
|
||
}
|
||
|
||
// IsCodexOfficialClientRequest checks if the User-Agent indicates a Codex 官方客户端请求。
|
||
// 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。宽松版:官方 UA 前缀集允许 Contains 子串兜底,
|
||
// 供 passthrough(IsCodexOfficialClientByHeaders)等历史路径使用,行为不变。
|
||
func IsCodexOfficialClientRequest(userAgent string) bool {
|
||
return isCodexOfficialClientRequest(userAgent, false)
|
||
}
|
||
|
||
// IsCodexOfficialClientRequestStrict 同 IsCodexOfficialClientRequest,但官方 UA 前缀集只做前缀
|
||
// 匹配(HasPrefix),不退化为 Contains 子串兜底——专供 codex_cli_only 访问门,收窄「浏览器前缀 +
|
||
// 中段 codex token」之类的伪造面。`Codex ` 家族前缀与 UA 尾部兜底保持一致;passthrough 仍用宽松版。
|
||
func IsCodexOfficialClientRequestStrict(userAgent string) bool {
|
||
return isCodexOfficialClientRequest(userAgent, true)
|
||
}
|
||
|
||
// isCodexOfficialClientRequest 匹配层级(优先级由高到低):
|
||
// 1. UA 前缀集 codexOfficialClientUAPrefixes(strict=仅 HasPrefix;否则含 Contains 子串兜底)
|
||
// 2. `Codex ` 家族前缀(保留空格,避免退化为裸 codex)
|
||
// 3. UA 尾部兜底:codex-rs 把 clientInfo.name 写入 UA 末尾括号组 `(name; version)`。
|
||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 只改前缀,不改尾部——可借此恢复被 override 的真实 client。
|
||
// 生产审计(10GB / 23 天)显示,originator=cccc 的真实 codex-tui 占全 openai 流量 5.3%,
|
||
// 若无此兜底则全部误拒。非官方尾部(如 evil/bash)仍被精确集拒绝。
|
||
func isCodexOfficialClientRequest(userAgent string, strict bool) bool {
|
||
ua := normalizeCodexClientHeader(userAgent)
|
||
if ua == "" {
|
||
return false
|
||
}
|
||
if strict {
|
||
if matchCodexClientHeaderStrictPrefixes(ua, codexOfficialClientUAPrefixes) {
|
||
return true
|
||
}
|
||
} else if matchCodexClientHeaderPrefixes(ua, codexOfficialClientUAPrefixes) {
|
||
return true
|
||
}
|
||
if strings.HasPrefix(ua, codexOfficialClientFamilyPrefix) {
|
||
return true
|
||
}
|
||
// UA 尾部兜底:提取最后一个括号组里的 name 段,用官方 originator 检测器判定。
|
||
if name := codexUATrailerName(ua); name != "" {
|
||
return IsCodexOfficialClientOriginator(name)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// codexUATrailerName extracts the clientInfo.name from the last parenthesized group
|
||
// of a codex-rs formatted User-Agent: `{orig}/{ver} ({os}; {arch}) {term} ({name}; {ver})`.
|
||
//
|
||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 修改 UA 前缀(originator 段),但不修改尾部的
|
||
// `(name; version)` 括号组——该组由 codex-rs engine 写入,保留真实 clientInfo.name。
|
||
// 故从尾部提取 name 可以恢复被 override 的真实客户端标识(例如 cccc → codex-tui)。
|
||
//
|
||
// input 应为已归一化(小写 + 去首尾空格)的 UA。
|
||
// 若无法解析则返回空字符串。
|
||
func codexUATrailerName(ua string) string {
|
||
last := strings.LastIndex(ua, "(")
|
||
if last < 0 {
|
||
return ""
|
||
}
|
||
rest := ua[last+1:]
|
||
closeIdx := strings.Index(rest, ")")
|
||
if closeIdx < 0 {
|
||
return ""
|
||
}
|
||
inner := strings.TrimSpace(rest[:closeIdx])
|
||
if semi := strings.Index(inner, ";"); semi >= 0 {
|
||
inner = strings.TrimSpace(inner[:semi])
|
||
}
|
||
return inner
|
||
}
|
||
|
||
// IsCodexOfficialClientOriginator checks if originator indicates a Codex 官方客户端请求。
|
||
// 精确集合匹配 + `Codex ` 家族前缀;不再用「含 codex」宽松兜底(避免伪造绕过)。
|
||
func IsCodexOfficialClientOriginator(originator string) bool {
|
||
v := normalizeCodexClientHeader(originator)
|
||
if v == "" {
|
||
return false
|
||
}
|
||
if codexOfficialClientOriginators[v] {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(v, codexOfficialClientFamilyPrefix)
|
||
}
|
||
|
||
// IsCodexOfficialClientByHeaders checks whether the request headers indicate an
|
||
// official Codex client family request.
|
||
func IsCodexOfficialClientByHeaders(userAgent, originator string) bool {
|
||
return IsCodexOfficialClientRequest(userAgent) || IsCodexOfficialClientOriginator(originator)
|
||
}
|
||
|
||
func normalizeCodexClientHeader(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
|
||
func matchCodexClientHeaderPrefixes(value string, prefixes []string) bool {
|
||
for _, prefix := range prefixes {
|
||
normalizedPrefix := normalizeCodexClientHeader(prefix)
|
||
if normalizedPrefix == "" {
|
||
continue
|
||
}
|
||
// 优先前缀匹配;若 UA/Originator 被网关拼接为复合字符串时,退化为包含匹配。
|
||
if strings.HasPrefix(value, normalizedPrefix) || strings.Contains(value, normalizedPrefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// matchCodexClientHeaderStrictPrefixes 仅前缀匹配(HasPrefix),不含 matchCodexClientHeaderPrefixes
|
||
// 的 Contains 子串兜底。用于 codex_cli_only 官方门收窄伪造面;passthrough 历史路径仍用宽松版。
|
||
// value 应为已归一化(小写 + 去首尾空格)的值。
|
||
func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool {
|
||
for _, prefix := range prefixes {
|
||
if p := normalizeCodexClientHeader(prefix); p != "" && strings.HasPrefix(value, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
|
||
var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
|
||
|
||
// ParseCodexEngineVersion 从 codex-rs 形态 UA 取引擎版本:
|
||
// `{originator}/{X.Y.Z} (...)`,第一个 '/' 后、首个空格或 '(' 前的三段版本。
|
||
// 该版本是 codex-rs CARGO_PKG_VERSION(引擎版本,CLI/app-server 一致)。
|
||
func ParseCodexEngineVersion(ua string) (string, bool) {
|
||
ua = strings.TrimSpace(ua)
|
||
slash := strings.IndexByte(ua, '/')
|
||
if slash < 0 {
|
||
return "", false
|
||
}
|
||
rest := ua[slash+1:]
|
||
end := len(rest)
|
||
for i := 0; i < len(rest); i++ {
|
||
if rest[i] == ' ' || rest[i] == '(' {
|
||
end = i
|
||
break
|
||
}
|
||
}
|
||
m := codexEngineVersionPattern.FindString(strings.TrimSpace(rest[:end]))
|
||
if m == "" {
|
||
return "", false
|
||
}
|
||
return m, true
|
||
}
|