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') }} +
+