将 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>
112 lines
4.6 KiB
Go
112 lines
4.6 KiB
Go
package openai
|
|
|
|
import "strings"
|
|
|
|
// AllowedClientEntry 描述一个被额外放行的非官方 Codex 客户端签名。
|
|
// Originator 必须精确等值匹配(归一化后)。
|
|
// UAContains 为必填字段:列表为空,或列表中存在任何空白 marker,均视为非法配置,
|
|
// 整体安全失败(return false);每一项都必须出现在 User-Agent 中。
|
|
// 这确保双因子匹配不会因缺失 UA 声明而退化为仅凭可伪造的 originator 单因子放行。
|
|
// SkipEngineFingerprint 仅对白名单条目有意义:命中此条则跳过引擎指纹门(管理员显式承担
|
|
// "纯 UA+originator、无引擎兜底"的后门风险,默认 false)。黑名单忽略此字段。
|
|
type AllowedClientEntry struct {
|
|
Originator string `json:"originator"`
|
|
UAContains []string `json:"ua_contains"`
|
|
SkipEngineFingerprint bool `json:"skip_engine_fingerprint"`
|
|
}
|
|
|
|
// IsWhitelistable 报告该条目作为白名单条目是否「有可能命中」——镜像 IsAllowedClientMatch 的结构性
|
|
// 前置:originator 非空、ua_contains 至少一项、且无任何空白 marker(空白 marker 会让整条永不命中)。
|
|
// 仅供管理端写入校验,避免存入静默失效的白名单规则。黑名单(OR 宽 deny,允许 originator-only)不受此约束。
|
|
func (e AllowedClientEntry) IsWhitelistable() bool {
|
|
if normalizeCodexClientHeader(e.Originator) == "" {
|
|
return false
|
|
}
|
|
if len(e.UAContains) == 0 {
|
|
return false
|
|
}
|
|
for _, marker := range e.UAContains {
|
|
if normalizeCodexClientHeader(marker) == "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// IsAllowedClientMatch 判断请求头是否命中给定的额外客户端签名。
|
|
// originator 必须精确等值(归一化后);UAContains 中每一项都必须出现在 UA 中。
|
|
// UAContains 为必填:列表为空或含任何空白 marker 均视为非法配置,整体安全失败。
|
|
func IsAllowedClientMatch(userAgent, originator string, entry AllowedClientEntry) bool {
|
|
wantOriginator := normalizeCodexClientHeader(entry.Originator)
|
|
if wantOriginator == "" {
|
|
return false
|
|
}
|
|
if normalizeCodexClientHeader(originator) != wantOriginator {
|
|
return false
|
|
}
|
|
// 预设必须声明 UA 特征:否则将退化为仅凭可伪造的 originator 单因子匹配。
|
|
if len(entry.UAContains) == 0 {
|
|
return false
|
|
}
|
|
ua := normalizeCodexClientHeader(userAgent)
|
|
for _, marker := range entry.UAContains {
|
|
normalizedMarker := normalizeCodexClientHeader(marker)
|
|
if normalizedMarker == "" {
|
|
// 空白 marker 让该项失去校验能力,会让双因子退化为仅 originator
|
|
// 单因子;视为非法配置,安全失败。
|
|
return false
|
|
}
|
|
if !strings.Contains(ua, normalizedMarker) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MatchClientEntry 同 MatchClientEntries(双因子 AND,复用 IsAllowedClientMatch),但回传命中的
|
|
// 那条条目,供调用方读取 SkipEngineFingerprint 等条目级配置。未命中返回零值 + false。
|
|
func MatchClientEntry(userAgent, originator string, entries []AllowedClientEntry) (AllowedClientEntry, bool) {
|
|
for _, e := range entries {
|
|
if IsAllowedClientMatch(userAgent, originator, e) {
|
|
return e, true
|
|
}
|
|
}
|
|
return AllowedClientEntry{}, false
|
|
}
|
|
|
|
// MatchClientEntries 判断请求头是否命中任一白名单自由条目(双因子 AND)。薄封装 MatchClientEntry。
|
|
// 用于 codex_cli_only 全局白名单:放行官方集未覆盖的 app-server 新 client。
|
|
func MatchClientEntries(userAgent, originator string, entries []AllowedClientEntry) bool {
|
|
_, ok := MatchClientEntry(userAgent, originator, entries)
|
|
return ok
|
|
}
|
|
|
|
// IsDeniedClientMatch 黑名单单条 OR 语义:已声明字段中任一命中即 deny。
|
|
// originator 精确等值命中,或任一非空 ua_contains marker 出现在 UA 中。
|
|
// 全空字段(originator 与 ua_contains 均空)→ 不 deny(安全忽略)。
|
|
// 与白名单 AND 非对称:deny 应宽(挡可疑),allow 应严(防伪造)。
|
|
func IsDeniedClientMatch(userAgent, originator string, entry AllowedClientEntry) bool {
|
|
if want := normalizeCodexClientHeader(entry.Originator); want != "" {
|
|
if normalizeCodexClientHeader(originator) == want {
|
|
return true
|
|
}
|
|
}
|
|
ua := normalizeCodexClientHeader(userAgent)
|
|
for _, marker := range entry.UAContains {
|
|
if m := normalizeCodexClientHeader(marker); m != "" && strings.Contains(ua, m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MatchDenyEntries 判断请求头是否命中任一黑名单条目(OR)。
|
|
func MatchDenyEntries(userAgent, originator string, entries []AllowedClientEntry) bool {
|
|
for _, e := range entries {
|
|
if IsDeniedClientMatch(userAgent, originator, e) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|