From 31b6e0d94accdbc9a10e947b3a3ea7479a0bb7e7 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 6 Jul 2026 20:05:50 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=A6=86?=
=?UTF-8?q?=E5=86=99=E5=AE=A1=E8=AE=A1=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=EF=BC=88=E7=A6=81=E6=AD=A2=E5=90=8D=E5=8D=95=E7=BC=BA=E5=8F=A3?=
=?UTF-8?q?/beta=20=E5=AF=B9=E7=A7=B0=E6=80=A7/=E6=89=B9=E9=87=8F=E6=B8=85?=
=?UTF-8?q?=E7=A9=BA=E9=98=B2=E6=8A=A4=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端:
- 禁止名单补充 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 导出
---
backend/internal/service/account.go | 8 +
.../service/account_header_override.go | 156 ++++++++++++------
.../service/account_header_override_test.go | 10 +-
backend/internal/service/gateway_service.go | 19 +++
.../account/BulkEditAccountModal.vue | 27 ++-
.../components/account/CreateAccountModal.vue | 4 +
.../__tests__/credentialsBuilder.spec.ts | 17 ++
.../components/account/credentialsBuilder.ts | 24 ++-
frontend/src/i18n/locales/en.ts | 2 +
frontend/src/i18n/locales/zh.ts | 2 +
10 files changed, 201 insertions(+), 68 deletions(-)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 8db5805e9d..ae25bf387d 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -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
diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go
index 80c32b648d..8882bbef91 100644
--- a/backend/internal/service/account_header_override.go
+++ b/backend/internal/service/account_header_override.go
@@ -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
+}
diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go
index 56c95b3fcb..c89b5e0587 100644
--- a/backend/internal/service/account_header_override_test.go
+++ b/backend/internal/service/account_header_override_test.go
@@ -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"},
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index e683fc5463..dcaf3a645c 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -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
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 7ce016810e..91656b196d 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -534,6 +534,10 @@
+
+ {{ t('admin.accounts.headerOverride.bulkReplaceHint') }}
+
+
-
+