fix: 请求头覆写审计问题修复(禁止名单缺口/beta 对称性/批量清空防护)

后端:
- 禁止名单补充 content-type(multipart boundary 为每请求随机值,静态覆写必坏
  images 路径)、x-claude-code-session-id / x-client-request-id(会击穿每请求
  会话同步,与已禁的 session_id 等同类)、cookie / x-goog-api-key(与透传路径
  的入站鉴权残留清洗保持一致)
- anthropic-beta 覆写与 body 净化对称:四个 Anthropic 构建器在
  sanitizeAnthropicBodyForBetaTokens 前以覆写值为有效 finalBeta,避免覆写
  丢 token 后 header/body 不对称被上游 400
- 保存/应用两路径共用 normalizeHeaderOverrideEntry,消除双份校验规则漂移
- GetHeaderOverrides 按 modelMappingCache 先例增加热路径缓存(WS 每消息
  重建头场景收益最大);ApplyHeaderOverrides 移除无观测效果的排序

前端:
- 禁止名单镜像同步新增项;值长度改按 UTF-8 字节校验(与后端 len() 对齐)
- Create 弹窗切换平台时重置覆写配置,避免上一平台模板行串台
- BulkEdit:开启覆写但无有效行时拦截保存(防止整键替换静默清空所选账号
  既有配置);开启分支显示整体替换警告;混合平台选择时隐藏模板按钮
- 删除未使用的 isBlockedHeaderOverrideName 导出
This commit is contained in:
shaw
2026-07-06 20:05:50 +08:00
parent ec7b20649e
commit 31b6e0d94a
10 changed files with 201 additions and 68 deletions
@@ -534,6 +534,10 @@
</p>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
</p>
<div v-if="headerOverrideRows.length > 0" class="space-y-2">
<div
v-for="(row, index) in headerOverrideRows"
@@ -585,7 +589,7 @@
{{ t('admin.accounts.headerOverride.addRow') }}
</button>
<div class="flex flex-wrap gap-2">
<div v-if="headerOverrideTemplatePlatform" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg bg-primary-50 px-3 py-1 text-xs text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
@@ -1433,13 +1437,16 @@ const removeHeaderOverrideRow = (index: number) => {
headerOverrideRows.value.splice(index, 1)
}
// 模板按钮:填入标准客户端请求头名称(值留空),跳过已存在的同名行。
// 目标全为 openai 时用 Codex 模板,否则用 Claude Code 模板。
// 模板仅在所选账号平台唯一时可用:混合 anthropic+openai 选择无法确定用哪套模板,
// 误填会把另一平台的专有头写进所有所选账号
const headerOverrideTemplatePlatform = computed(() => {
return targetSelectedPlatforms.value.length === 1 ? targetSelectedPlatforms.value[0] : null
})
// 模板按钮:填入所选平台的标准客户端请求头名称(值留空),跳过已存在的同名行
const fillHeaderOverrideTemplate = () => {
const platform =
targetSelectedPlatforms.value.length === 1 && targetSelectedPlatforms.value[0] === 'openai'
? 'openai'
: 'anthropic'
const platform = headerOverrideTemplatePlatform.value
if (!platform) return
const existing = new Set(
headerOverrideRows.value.map((row) => row.name.trim().toLowerCase()).filter(Boolean)
)
@@ -1853,6 +1860,12 @@ const handleSubmit = async () => {
}
if (enableHeaderOverride.value && headerOverrideEnabled.value) {
// 批量保存对 header_overrides 是整键替换:开启但没有任何有效行会把所选账号的
// 既有覆写配置静默清空,必须显式拦截(清空请走关闭开关的路径,有专门提示)
if (!headerOverrideRows.value.some((row) => row.name.trim())) {
appStore.showError(t('admin.accounts.headerOverride.bulkEmptyRows'))
return
}
const headerError = validateHeaderOverrideRows(headerOverrideRows.value)
if (headerError) {
appStore.showError(t(`admin.accounts.headerOverride.${headerError}`))
@@ -4104,6 +4104,10 @@ watch(
anthropicAPIKeyAuthScheme.value = 'x_api_key'
webSearchEmulationMode.value = 'default'
}
// 请求头覆写为平台相关配置(模板/常用头集合不同),切换平台时清空,
// 避免上一平台的模板行被提交到新平台账号
headerOverrideEnabled.value = false
headerOverrideRows.value = []
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
@@ -128,6 +128,9 @@ describe('validateHeaderOverrideRows', () => {
expect(validateHeaderOverrideRows([{ name: 'X-Api-Key', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'host', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Content-Length', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Content-Type', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'Cookie', value: '' }])).toBe('blockedName')
expect(validateHeaderOverrideRows([{ name: 'x-goog-api-key', value: '' }])).toBe('blockedName')
})
it('rejects duplicate names case-insensitively', () => {
@@ -249,6 +252,14 @@ describe('validateHeaderOverrideRows value/entry limits', () => {
)
})
it('measures value length in UTF-8 bytes to match backend', () => {
// 3000 个 CJK 字符 = 3000 UTF-16 code units,但 9000 UTF-8 字节 > 8192
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(3000) }])).toBe(
'invalidValue'
)
expect(validateHeaderOverrideRows([{ name: 'x-app', value: '测'.repeat(2000) }])).toBeNull()
})
it('rejects too many entries', () => {
const rows = Array.from({ length: 65 }, (_, i) => ({ name: `x-h-${i}`, value: 'v' }))
expect(validateHeaderOverrideRows(rows)).toBe('tooManyEntries')
@@ -262,6 +273,12 @@ describe('validateHeaderOverrideRows session isolation headers', () => {
expect(validateHeaderOverrideRows([{ name: 'x-codex-turn-state', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'X-Claude-Code-Session-Id', value: '' }])).toBe(
'blockedName'
)
expect(validateHeaderOverrideRows([{ name: 'x-client-request-id', value: '' }])).toBe(
'blockedName'
)
})
it('allows tab inside value', () => {
@@ -44,6 +44,7 @@ export function isHeaderOverridePlatform(platform: string): boolean {
const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'host',
'content-length',
'content-type',
'transfer-encoding',
'connection',
'keep-alive',
@@ -55,6 +56,8 @@ const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'upgrade',
'authorization',
'x-api-key',
'x-goog-api-key',
'cookie',
'accept-encoding',
'sec-websocket-key',
'sec-websocket-version',
@@ -65,20 +68,18 @@ const HEADER_OVERRIDE_BLOCKED_NAMES = new Set([
'conversation_id',
'x-codex-turn-state',
'x-codex-turn-metadata',
'chatgpt-account-id'
'chatgpt-account-id',
'x-claude-code-session-id',
'x-client-request-id'
])
/** RFC 7230 token:合法的 HTTP header 名称字符集 */
const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
export function isValidHeaderOverrideName(name: string): boolean {
function isValidHeaderOverrideName(name: string): boolean {
return HEADER_NAME_PATTERN.test(name)
}
export function isBlockedHeaderOverrideName(name: string): boolean {
return HEADER_OVERRIDE_BLOCKED_NAMES.has(name.trim().toLowerCase())
}
/** 模板:Claude Code CLI 标准客户端请求头(值留空由管理员填写) */
const ANTHROPIC_HEADER_OVERRIDE_TEMPLATE = [
'user-agent',
@@ -121,6 +122,12 @@ const HEADER_OVERRIDE_MAX_VALUE_LENGTH = 8192
// eslint-disable-next-line no-control-regex
const HEADER_VALUE_INVALID_PATTERN = /[\x00-\x08\x0a-\x1f\x7f]/
/** 长度限制按 UTF-8 字节计(与后端 Go len() 对齐,避免多字节值前端放行后端 400) */
const HEADER_TEXT_ENCODER = new TextEncoder()
function utf8ByteLength(value: string): number {
return HEADER_TEXT_ENCODER.encode(value).length
}
/**
* 校验请求头覆写行,返回首个错误的 i18n key(无错误返回 null)。
* 名称为空但值非空 → invalidName;名称非法 → invalidName;
@@ -144,7 +151,10 @@ export function validateHeaderOverrideRows(
const lower = name.toLowerCase()
if (HEADER_OVERRIDE_BLOCKED_NAMES.has(lower)) return 'blockedName'
if (seen.has(lower)) return 'duplicateName'
if (HEADER_VALUE_INVALID_PATTERN.test(value) || value.length > HEADER_OVERRIDE_MAX_VALUE_LENGTH) {
if (
HEADER_VALUE_INVALID_PATTERN.test(value) ||
utf8ByteLength(value) > HEADER_OVERRIDE_MAX_VALUE_LENGTH
) {
return 'invalidValue'
}
seen.add(lower)
+2
View File
@@ -3684,6 +3684,8 @@ export default {
fillTemplate: 'Fill Template',
emptyValueHint: 'Rows with an empty value are placeholders and do not override anything.',
bulkDisableHint: 'Saving will disable header override and clear existing configuration on the selected accounts.',
bulkReplaceHint: 'Saving will replace the existing header override configuration on all selected accounts with the rows below.',
bulkEmptyRows: 'Add at least one header row before saving, or turn the toggle off to clear existing configuration.',
invalidName: 'Invalid header name (only letters, digits and !#$%&\'*+-.^_`|~ are allowed)',
blockedName: 'This header cannot be overridden (auth and connection-control headers are managed by the system)',
duplicateName: 'Duplicate header name (matching is case-insensitive)',
+2
View File
@@ -3852,6 +3852,8 @@ export default {
fillTemplate: '填入模板',
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。',
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',