mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -70,6 +70,14 @@ type Account struct {
|
||||
modelMappingCacheRawPtr uintptr
|
||||
modelMappingCacheRawLen int
|
||||
modelMappingCacheRawSig uint64
|
||||
|
||||
// header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
|
||||
headerOverrideCache map[string]string
|
||||
headerOverrideCacheReady bool
|
||||
headerOverrideCacheCredentialsPtr uintptr
|
||||
headerOverrideCacheRawPtr uintptr
|
||||
headerOverrideCacheRawLen int
|
||||
headerOverrideCacheRawSig uint64
|
||||
}
|
||||
|
||||
type OpenAIEndpointCapability string
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@@ -25,13 +24,15 @@ const (
|
||||
// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
|
||||
// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
|
||||
// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
|
||||
// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
|
||||
// - content-type:承载报文框架信息(multipart boundary 为每请求随机值),静态覆写必然与 body 不匹配;
|
||||
// - authorization/x-api-key/cookie 等:上游认证头由账号凭据统一注入,禁止通过覆写篡改或重新引入;
|
||||
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
|
||||
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
|
||||
// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
|
||||
// - session_id/x-claude-code-session-id 等:逐请求会话隔离头,固定值会造成会话串扰。
|
||||
var headerOverrideBlockedNames = map[string]struct{}{
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
"content-type": {},
|
||||
"transfer-encoding": {},
|
||||
"connection": {},
|
||||
"keep-alive": {},
|
||||
@@ -43,6 +44,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
|
||||
"upgrade": {},
|
||||
"authorization": {},
|
||||
"x-api-key": {},
|
||||
"x-goog-api-key": {},
|
||||
"cookie": {},
|
||||
"accept-encoding": {},
|
||||
"sec-websocket-key": {},
|
||||
"sec-websocket-version": {},
|
||||
@@ -54,6 +57,8 @@ var headerOverrideBlockedNames = map[string]struct{}{
|
||||
"x-codex-turn-state": {},
|
||||
"x-codex-turn-metadata": {},
|
||||
"chatgpt-account-id": {},
|
||||
"x-claude-code-session-id": {},
|
||||
"x-client-request-id": {},
|
||||
}
|
||||
|
||||
func isHeaderOverrideBlockedName(lowerName string) bool {
|
||||
@@ -82,29 +87,59 @@ func (a *Account) IsHeaderOverrideEnabled() bool {
|
||||
// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
|
||||
// 未启用、不符合平台/类型条件或配置为空时返回 nil。
|
||||
// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
|
||||
// 结果带热路径缓存(同 GetModelMapping 先例):同一 credentials 映射在
|
||||
// 一次请求 / 一条 WS 会话内的多次调用只做一次解析与校验。
|
||||
func (a *Account) GetHeaderOverrides() map[string]string {
|
||||
if !a.IsHeaderOverrideEnabled() {
|
||||
return nil
|
||||
}
|
||||
raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
|
||||
rawMapping, rawIsAnyMap := a.Credentials[credKeyHeaderOverrides].(map[string]any)
|
||||
if !rawIsAnyMap {
|
||||
// 非 JSON 反序列化产物(如直接注入的 map[string]string):直接解析,不缓存
|
||||
return resolveHeaderOverrides(stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides]))
|
||||
}
|
||||
|
||||
credentialsPtr := mapPtr(a.Credentials)
|
||||
rawPtr := mapPtr(rawMapping)
|
||||
rawLen := len(rawMapping)
|
||||
rawSig := uint64(0)
|
||||
rawSigReady := false
|
||||
|
||||
if a.headerOverrideCacheReady &&
|
||||
a.headerOverrideCacheCredentialsPtr == credentialsPtr &&
|
||||
a.headerOverrideCacheRawPtr == rawPtr &&
|
||||
a.headerOverrideCacheRawLen == rawLen {
|
||||
rawSig = modelMappingSignature(rawMapping)
|
||||
rawSigReady = true
|
||||
if a.headerOverrideCacheRawSig == rawSig {
|
||||
return a.headerOverrideCache
|
||||
}
|
||||
}
|
||||
|
||||
overrides := resolveHeaderOverrides(stringMappingFromRaw(rawMapping))
|
||||
if !rawSigReady {
|
||||
rawSig = modelMappingSignature(rawMapping)
|
||||
}
|
||||
|
||||
a.headerOverrideCache = overrides
|
||||
a.headerOverrideCacheReady = true
|
||||
a.headerOverrideCacheCredentialsPtr = credentialsPtr
|
||||
a.headerOverrideCacheRawPtr = rawPtr
|
||||
a.headerOverrideCacheRawLen = rawLen
|
||||
a.headerOverrideCacheRawSig = rawSig
|
||||
return overrides
|
||||
}
|
||||
|
||||
// resolveHeaderOverrides 解析并防御性过滤原始覆写表:保存路径已做校验,
|
||||
// 这里兜底未经 Normalize 落库的数据(含名单扩充前保存的旧配置),非法条目直接跳过。
|
||||
func resolveHeaderOverrides(raw map[string]string) map[string]string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(raw))
|
||||
for name, value := range raw {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(name))
|
||||
value = strings.TrimSpace(value)
|
||||
if lowerName == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
// 防御性过滤:保存路径已做校验,这里兜底未经 Normalize 落库的数据
|
||||
if len(lowerName) > maxHeaderOverrideNameLength || len(value) > maxHeaderOverrideValueLength {
|
||||
continue
|
||||
}
|
||||
if isHeaderOverrideBlockedName(lowerName) {
|
||||
continue
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldName(lowerName) || !httpguts.ValidHeaderFieldValue(value) {
|
||||
lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
|
||||
if err != nil || lowerName == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
result[lowerName] = value
|
||||
@@ -115,6 +150,13 @@ func (a *Account) GetHeaderOverrides() map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
// HeaderOverrideValue 返回指定 header(小写名)的生效覆写值。
|
||||
// 供转发链路在 header 写入前感知覆写结果(如 anthropic-beta 需要参与 body 净化)。
|
||||
func (a *Account) HeaderOverrideValue(lowerName string) (string, bool) {
|
||||
value, ok := a.GetHeaderOverrides()[lowerName]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
|
||||
// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
|
||||
// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
|
||||
@@ -127,18 +169,16 @@ func (a *Account) ApplyHeaderOverrides(h http.Header) {
|
||||
if len(overrides) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(overrides))
|
||||
for name := range overrides {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
// 覆写名两两不同(大小写不敏感)且各自只操作同名键,应用顺序不影响结果。
|
||||
// 全量 EqualFold 扫描兜底删除任意 casing 的既有键:透传链路可能保留客户端
|
||||
// 原始 casing,非 canonical/wire casing 的键 deleteHeaderAllForms 覆盖不到。
|
||||
for name, value := range overrides {
|
||||
for existing := range h {
|
||||
if strings.EqualFold(existing, name) {
|
||||
delete(h, existing)
|
||||
}
|
||||
}
|
||||
h[resolveWireCasing(name)] = []string{overrides[name]}
|
||||
h[resolveWireCasing(name)] = []string{value}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,34 +226,12 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q value must be a string", name)
|
||||
}
|
||||
lowerName := strings.ToLower(strings.TrimSpace(name))
|
||||
value = strings.TrimSpace(value)
|
||||
lowerName, value, err := normalizeHeaderOverrideEntry(name, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lowerName == "" {
|
||||
if value == "" {
|
||||
continue // 丢弃完全为空的占位行
|
||||
}
|
||||
return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header name must not be empty")
|
||||
}
|
||||
if len(lowerName) > maxHeaderOverrideNameLength {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldName(lowerName) {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"invalid header name %q", lowerName)
|
||||
}
|
||||
if isHeaderOverrideBlockedName(lowerName) {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q is not allowed to be overridden", lowerName)
|
||||
}
|
||||
if len(value) > maxHeaderOverrideValueLength {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(value) {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q has an invalid value", lowerName)
|
||||
continue // 丢弃完全为空的占位行
|
||||
}
|
||||
if _, dup := normalized[lowerName]; dup {
|
||||
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
@@ -224,3 +242,39 @@ func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
|
||||
credentials[credKeyHeaderOverrides] = normalized
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHeaderOverrideEntry 校验并规范化单个覆写条目,保存路径(Normalize,err → 400)
|
||||
// 与应用路径(resolveHeaderOverrides,err → 跳过)共用同一套规则,避免两处校验漂移。
|
||||
// 名和值均为空表示空占位行,返回 ("", "", nil);空 value 的具名条目合法(模板占位)。
|
||||
func normalizeHeaderOverrideEntry(name, value string) (string, string, error) {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(name))
|
||||
value = strings.TrimSpace(value)
|
||||
if lowerName == "" {
|
||||
if value == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header name must not be empty")
|
||||
}
|
||||
if len(lowerName) > maxHeaderOverrideNameLength {
|
||||
return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header name %q exceeds %d characters", lowerName, maxHeaderOverrideNameLength)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldName(lowerName) {
|
||||
return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"invalid header name %q", lowerName)
|
||||
}
|
||||
if isHeaderOverrideBlockedName(lowerName) {
|
||||
return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q is not allowed to be overridden", lowerName)
|
||||
}
|
||||
if len(value) > maxHeaderOverrideValueLength {
|
||||
return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q value exceeds %d characters", lowerName, maxHeaderOverrideValueLength)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(value) {
|
||||
return "", "", infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
|
||||
"header %q has an invalid value", lowerName)
|
||||
}
|
||||
return lowerName, value, nil
|
||||
}
|
||||
|
||||
@@ -108,9 +108,11 @@ func TestGetHeaderOverrides(t *testing.T) {
|
||||
defensive := headerOverrideTestAccount(PlatformOpenAI, AccountTypeAPIKey, map[string]any{
|
||||
credKeyHeaderOverrideEnabled: true,
|
||||
credKeyHeaderOverrides: map[string]any{
|
||||
"x-big": oversizedValue,
|
||||
"sec-websocket-key": "forged",
|
||||
"x-ok": "ok",
|
||||
"x-big": oversizedValue,
|
||||
"sec-websocket-key": "forged",
|
||||
"content-type": "application/json", // 名单扩充前落库的数据也要被拦截
|
||||
"x-claude-code-session-id": "pinned-session",
|
||||
"x-ok": "ok",
|
||||
},
|
||||
})
|
||||
require.Equal(t, map[string]string{"x-ok": "ok"}, defensive.GetHeaderOverrides())
|
||||
@@ -278,6 +280,8 @@ func TestNormalizeHeaderOverrideCredentials(t *testing.T) {
|
||||
"Authorization", "x-api-key", "Host", "content-length", "Transfer-Encoding",
|
||||
"connection", "accept-encoding", "Sec-WebSocket-Key", "session_id",
|
||||
"conversation_id", "x-codex-turn-state", "chatgpt-account-id",
|
||||
"Content-Type", "Cookie", "x-goog-api-key",
|
||||
"X-Claude-Code-Session-Id", "x-client-request-id",
|
||||
} {
|
||||
err := NormalizeHeaderOverrideCredentials(map[string]any{
|
||||
credKeyHeaderOverrides: map[string]any{name: "v"},
|
||||
|
||||
@@ -5920,6 +5920,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
if c != nil && c.Request != nil {
|
||||
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
|
||||
}
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
clientBeta = beta
|
||||
}
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
|
||||
body = sanitized
|
||||
}
|
||||
@@ -6889,6 +6893,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
|
||||
)
|
||||
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
|
||||
// body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
finalBetaHeader, finalBetaShouldSet = beta, true
|
||||
}
|
||||
|
||||
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
|
||||
body = sanitized
|
||||
@@ -10417,6 +10427,10 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||||
if c != nil && c.Request != nil {
|
||||
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
|
||||
}
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
clientBeta = beta
|
||||
}
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
|
||||
body = sanitized
|
||||
}
|
||||
@@ -10522,6 +10536,11 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet,
|
||||
)
|
||||
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
finalBetaHeader, finalBetaShouldSet = beta, true
|
||||
}
|
||||
|
||||
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
|
||||
body = sanitized
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -3852,6 +3852,8 @@ export default {
|
||||
fillTemplate: '填入模板',
|
||||
emptyValueHint: '值留空的行不会参与覆盖,仅作为待填写的占位。',
|
||||
bulkDisableHint: '保存后将关闭所选账号的请求头覆写并清空已有配置。',
|
||||
bulkReplaceHint: '保存后将用下方配置整体替换所选账号已有的请求头覆写配置。',
|
||||
bulkEmptyRows: '请至少添加一行请求头再保存;如需清空已有配置,请关闭上方开关。',
|
||||
invalidName: '请求头名称格式不正确(仅允许字母、数字和 !#$%&\'*+-.^_`|~ 字符)',
|
||||
blockedName: '该请求头不允许覆写(认证头与连接控制头由系统管理)',
|
||||
duplicateName: '存在重复的请求头名称(匹配不区分大小写)',
|
||||
|
||||
Reference in New Issue
Block a user