mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-10 17:02:32 +00:00
- 账号 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 中英文
227 lines
8.0 KiB
Go
227 lines
8.0 KiB
Go
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
|
||
}
|