Files
sub2api/backend/internal/service/account_header_override.go
T
shaw ec7b20649e feat: apikey 账号支持请求头覆写(Anthropic/OpenAI)
- 账号 credentials 新增 header_override_enabled / header_overrides,
  仅对 anthropic/openai 平台的 api_key 账号生效
- 转发前对同名请求头做大小写不敏感覆盖(EqualFold 全量删除后按
  wire casing 写入单值),值为空的条目视为占位不参与覆盖
- 覆盖全部出站路径:/v1/messages(标准+透传)、count_tokens、
  /v1/responses、chat completions、embeddings、images、全部 WS 模式、
  账号测试/探测、上游模型列表
- 创建/编辑/批量更新统一校验:RFC 7230 名称格式、去重、长度/条目上限、
  24 个禁止覆写头(认证/连接控制/accept-encoding/sec-websocket-*/
  会话隔离头),应用时二次防御过滤
- 前端三个账号弹窗新增开关+键值行编辑器+模板按钮(Claude Code CLI /
  Codex CLI 标准头,值为空),本地校验与后端规则对齐,i18n 中英文
2026-07-06 19:11:08 +08:00

227 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"net/http"
"sort"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"golang.org/x/net/http/httpguts"
)
// 请求头覆写(header override):仅对 Anthropic / OpenAI 平台的 api_key 账号生效。
// 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头
// (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。
const (
credKeyHeaderOverrideEnabled = "header_override_enabled"
credKeyHeaderOverrides = "header_overrides"
maxHeaderOverrideEntries = 64
maxHeaderOverrideNameLength = 200
maxHeaderOverrideValueLength = 8192
)
// headerOverrideBlockedNames 禁止覆写的请求头(小写)。
// - 连接控制/逐跳头:由 HTTP 栈管理,覆写会破坏请求传输;
// - host/content-length:由 Go 的 Request.Host / ContentLength 字段管理,header 覆写不生效或产生冲突;
// - authorization/x-api-key:上游认证头由账号凭据统一注入,禁止通过覆写篡改;
// - accept-encoding:强制压缩会破坏网关对上游流式响应(SSE/usage)的解析;
// - sec-websocket-*:WebSocket 握手头由拨号器管理(OpenAI WS 模式);
// - session_id/conversation_id 等:逐请求会话隔离头,固定值会造成会话串扰。
var headerOverrideBlockedNames = map[string]struct{}{
"host": {},
"content-length": {},
"transfer-encoding": {},
"connection": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"proxy-connection": {},
"te": {},
"trailer": {},
"upgrade": {},
"authorization": {},
"x-api-key": {},
"accept-encoding": {},
"sec-websocket-key": {},
"sec-websocket-version": {},
"sec-websocket-extensions": {},
"sec-websocket-protocol": {},
"sec-websocket-accept": {},
"session_id": {},
"conversation_id": {},
"x-codex-turn-state": {},
"x-codex-turn-metadata": {},
"chatgpt-account-id": {},
}
func isHeaderOverrideBlockedName(lowerName string) bool {
_, blocked := headerOverrideBlockedNames[lowerName]
return blocked
}
// IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。
// 目前仅开放 Anthropic / OpenAI 两个平台的 api_key 账号。
func (a *Account) IsHeaderOverrideEligible() bool {
if a == nil || a.Type != AccountTypeAPIKey {
return false
}
return a.Platform == PlatformAnthropic || a.Platform == PlatformOpenAI
}
// IsHeaderOverrideEnabled 报告账号是否启用了请求头覆写。
func (a *Account) IsHeaderOverrideEnabled() bool {
if !a.IsHeaderOverrideEligible() || a.Credentials == nil {
return false
}
enabled, ok := a.Credentials[credKeyHeaderOverrideEnabled].(bool)
return ok && enabled
}
// GetHeaderOverrides 返回生效的请求头覆写表(key 统一小写)。
// 未启用、不符合平台/类型条件或配置为空时返回 nil。
// 空 value 的条目(模板占位)与非法/禁止的 header 名会被跳过。
func (a *Account) GetHeaderOverrides() map[string]string {
if !a.IsHeaderOverrideEnabled() {
return nil
}
raw := stringMappingFromRaw(a.Credentials[credKeyHeaderOverrides])
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) {
continue
}
result[lowerName] = value
}
if len(result) == 0 {
return nil
}
return result
}
// ApplyHeaderOverrides 将账号配置的请求头覆写应用到出站请求头。
// 对每个覆写条目:先删除所有大小写变体(转发链路会以 wire casing 直接写入 map,
// 可能存在非 canonical key),再按已知 wire casing 写入,避免产生重复头。
// 账号未启用或不符合条件时为 no-op,可安全地在 OAuth/api_key 共用的构建器中调用。
func (a *Account) ApplyHeaderOverrides(h http.Header) {
if h == nil {
return
}
overrides := a.GetHeaderOverrides()
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 {
for existing := range h {
if strings.EqualFold(existing, name) {
delete(h, existing)
}
}
h[resolveWireCasing(name)] = []string{overrides[name]}
}
}
// NormalizeHeaderOverrideCredentials 校验并原地规范化 credentials 中的请求头覆写字段。
// 供账号创建/更新/批量更新的保存路径调用;credentials 未携带相关字段时为 no-op。
// 规范化内容:header 名转小写并去除首尾空白,value 去除首尾空白,丢弃名和值均为空的条目。
func NormalizeHeaderOverrideCredentials(credentials map[string]any) error {
if credentials == nil {
return nil
}
if raw, ok := credentials[credKeyHeaderOverrideEnabled]; ok && raw != nil {
if _, isBool := raw.(bool); !isBool {
return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"header_override_enabled must be a boolean")
}
}
raw, ok := credentials[credKeyHeaderOverrides]
if !ok || raw == nil {
return nil
}
var entries map[string]any
switch m := raw.(type) {
case map[string]any:
entries = m
case map[string]string:
entries = make(map[string]any, len(m))
for k, v := range m {
entries[k] = v
}
default:
return infraerrors.New(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"header_overrides must be an object of header name to string value")
}
if len(entries) > maxHeaderOverrideEntries {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"header_overrides supports at most %d entries", maxHeaderOverrideEntries)
}
normalized := make(map[string]any, len(entries))
for name, rawValue := range entries {
value, isString := rawValue.(string)
if !isString {
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)
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)
}
if _, dup := normalized[lowerName]; dup {
return infraerrors.Newf(http.StatusBadRequest, "INVALID_HEADER_OVERRIDE",
"duplicate header name %q (matching is case-insensitive)", lowerName)
}
normalized[lowerName] = value
}
credentials[credKeyHeaderOverrides] = normalized
return nil
}