fix(headers): isolate credentials across audit, redirects and probes

This commit is contained in:
Ed1s0nZ committed 2026-11-02 17:18:12 +08:00
1 parent 585c95a0e2
commit 5feea8d3c3
15 files changed
+551 -16

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
### Added
- AI 渠道支持自定义 HTTP 请求头、环境变量和会话 ID 占位符;保存后的鉴权头可安全用于连接测试,独立审计身份隔离主渠道请求头,自定义头不会跟随跨源重定向。
- 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。
- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。
+3
View File
@@ -116,6 +116,9 @@ ai:
# x-opencode-session: "%session_id%" # 自动替换为当前对话 ID
# X-Client-Name: cyberstrike-ai
# HTTP-Referer: https://example.com
# 配置自定义头时,仅允许同源重定向,跨主机、端口或协议的重定向会被拒绝。
# HITL 审计仅在未显式配置 provider/base_url/api_key 时继承主渠道 headers;
# 独立审计身份如需请求头,请在 hitl.audit_model.headers 中单独配置。
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning:
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
+1
View File
@@ -1237,6 +1237,7 @@ func (h HitlConfig) TypeSafeConfigEffective() (baseURL, apiKey, model string) {
func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig {
out := main
am := h.AuditModel
out.Headers = auditModelHeaders(main, am)
if strings.TrimSpace(am.Provider) != "" || strings.TrimSpace(am.BaseURL) != "" || strings.TrimSpace(am.Model) != "" {
out.Reasoning.Profile = ""
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "strings"
// auditModelHeaders treats an explicit audit identity as a separate credential
// boundary, even when its provider or URL happens to equal the main model's.
// A model-name-only override can safely continue using the main identity.
func auditModelHeaders(main, audit OpenAIConfig) map[string]string {
if audit.Headers != nil {
return ExpandHeaderMap(audit.Headers)
}
if strings.TrimSpace(audit.Provider) != "" || strings.TrimSpace(audit.BaseURL) != "" || strings.TrimSpace(audit.APIKey) != "" {
return nil
}
return ExpandHeaderMap(main.Headers)
}
+34
View File
@@ -0,0 +1,34 @@
package config
import "testing"
func TestAuditModelHeaderIdentityBoundary(t *testing.T) {
t.Setenv("CSAI_AUDIT_TEST_HEADER", "audit-secret")
main := OpenAIConfig{Provider: "openai", BaseURL: "https://main.invalid/v1", APIKey: "main-key", Headers: map[string]string{"Authorization": "Bearer main"}}
for _, tc := range []struct {
name string
audit OpenAIConfig
want string
}{
{"inherit", OpenAIConfig{}, "Bearer main"},
{"model only", OpenAIConfig{Model: "small"}, "Bearer main"},
{"provider", OpenAIConfig{Provider: "openai"}, ""},
{"endpoint", OpenAIConfig{BaseURL: "https://audit.invalid/v1"}, ""},
{"key", OpenAIConfig{APIKey: "audit-key"}, ""},
{"explicit", OpenAIConfig{BaseURL: "https://audit.invalid", Headers: map[string]string{"Authorization": "${CSAI_AUDIT_TEST_HEADER}"}}, "audit-secret"},
{"explicit empty", OpenAIConfig{Headers: map[string]string{}}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
got := (HitlConfig{AuditModel: tc.audit}).AuditModelEffective(main)
if got.Headers["Authorization"] != tc.want {
t.Fatalf("headers=%v", got.Headers)
}
if got.Headers != nil {
got.Headers["Authorization"] = "changed"
}
if main.Headers["Authorization"] != "Bearer main" {
t.Fatal("mutated main headers")
}
})
}
}
+14 -8
View File
@@ -351,13 +351,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
subAgentCount = len(agents.MergeYAMLAndMarkdown(h.config.MultiAgent.SubAgents, load.SubAgents))
}
multiPub := config.MultiAgentPublic{
Enabled: h.config.MultiAgent.Enabled,
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
SubAgentCount: subAgentCount,
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
Enabled: h.config.MultiAgent.Enabled,
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
SubAgentCount: subAgentCount,
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
SummarizationUserIntentLedgerEntryMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerEntryMaxRunesEffective(),
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
@@ -1285,6 +1285,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
return
}
req.APIKey = resolvedKey
resolvedHeaders, headerErr := h.resolveProbeHeaders(req)
if headerErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": headerErr.Error()})
return
}
if strings.TrimSpace(req.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
return
@@ -1318,7 +1323,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
BaseURL: baseURL,
APIKey: strings.TrimSpace(req.APIKey),
Model: req.Model,
Headers: config.ExpandHeaderMap(req.Headers),
Headers: resolvedHeaders,
}
client := openai.NewClient(tmpCfg, nil, h.logger)
@@ -2366,6 +2371,7 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL)
setStringInMap(auditModelNode, "api_key", cfg.AuditModel.APIKey)
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
setStringMapInMap(auditModelNode, "headers", cfg.AuditModel.Headers)
setStringInMap(ensureMap(auditModelNode, "reasoning"), "profile", cfg.AuditModel.Reasoning.Profile)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
@@ -0,0 +1,169 @@
package handler
import (
"context"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"encoding/json"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSavedHeaderProbeRestoresScopedSecret(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
}))
defer srv.Close()
cfg := &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"gateway": {APIKey: "dummy", BaseURL: srv.URL, Model: "m", Headers: map[string]string{"X-Api-Key": "review-test-secret"}}}}}
masked, err := maskedConfigResponse(configSecretSnapshot(cfg))
if err != nil {
t.Fatal(err)
}
tree := masked.(map[string]interface{})
hdr := tree["ai"].(map[string]interface{})["channels"].(map[string]interface{})["gateway"].(map[string]interface{})["headers"]
payload, _ := json.Marshal(map[string]interface{}{"channel_id": "gateway", "base_url": srv.URL, "api_key": "********", "model": "m", "headers": hdr})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/api/config/test-openai", strings.NewReader(string(payload)))
c.Request.Header.Set("Content-Type", "application/json")
h := &ConfigHandler{config: cfg, logger: zap.NewNop()}
h.TestOpenAI(c)
if got != "review-test-secret" {
t.Fatalf("expected stored header, got %q", got)
}
t.Log("Saved header restored from matching channel")
}
func TestAuditIndependentProviderDoesNotReceiveMainHeaders(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Authorization")
w.Write([]byte(`{}`))
}))
defer srv.Close()
main := config.OpenAIConfig{BaseURL: "https://main-provider.invalid", APIKey: "main-key", Model: "m", Headers: map[string]string{"Authorization": "Bearer main-header-secret"}}
audit := (config.HitlConfig{AuditModel: config.OpenAIConfig{BaseURL: srv.URL, APIKey: "audit-key", Model: "audit"}}).AuditModelEffective(main)
var out map[string]interface{}
err := openai.NewClient(&audit, nil, zap.NewNop()).ChatCompletion(context.Background(), map[string]string{"model": "audit"}, &out)
if err != nil {
t.Fatal(err)
}
if got != "Bearer audit-key" {
t.Fatalf("unexpected auth %q", got)
}
t.Log("Independent audit identity retained")
}
func TestProbeHeaderScopeAndTargetFailures(t *testing.T) {
stored := config.AIChannelConfig{Provider: "openai", BaseURL: "https://model.invalid/v1", APIKey: "key", Headers: map[string]string{"X-Api-Key": "stored-secret"}}
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"a": stored}}, OpenAI: stored.ToOpenAIConfig()}, logger: zap.NewNop()}
base := TestOpenAIRequest{ChannelID: "a", Provider: "openai", BaseURL: stored.BaseURL, APIKey: "key", Headers: map[string]string{"x-api-key": maskedSecret, "X-Label": "label"}}
got, err := h.resolveProbeHeaders(base)
if err != nil || got["x-api-key"] != "stored-secret" || got["X-Label"] != "label" {
t.Fatalf("restore: %v %v", got, err)
}
for _, tc := range []struct {
name string
edit func(*TestOpenAIRequest)
}{
{"unknown channel", func(r *TestOpenAIRequest) { r.ChannelID = "other" }},
{"unscoped", func(r *TestOpenAIRequest) { r.ChannelID = "" }},
{"different destination", func(r *TestOpenAIRequest) { r.BaseURL = "https://other.invalid/v1" }},
{"different path", func(r *TestOpenAIRequest) { r.BaseURL = "https://model.invalid/another" }},
{"different provider", func(r *TestOpenAIRequest) { r.Provider = "claude" }},
{"different key", func(r *TestOpenAIRequest) { r.APIKey = "another" }},
{"unknown scope", func(r *TestOpenAIRequest) { r.CredentialScope = "other" }},
{"missing saved header", func(r *TestOpenAIRequest) { r.Headers = map[string]string{"Authorization": maskedSecret} }},
{"partial marker", func(r *TestOpenAIRequest) { r.Headers = map[string]string{"X-Api-Key": "Bearer " + maskedSecret} }},
{"duplicate case", func(r *TestOpenAIRequest) {
r.Headers = map[string]string{"X-Api-Key": maskedSecret, "x-api-key": "override"}
}},
} {
t.Run(tc.name, func(t *testing.T) {
r := base
tc.edit(&r)
if _, err := h.resolveProbeHeaders(r); err == nil {
t.Fatal("unsafe marker accepted")
}
})
}
for _, scope := range []string{"openai", "hitlAudit"} {
r := base
r.ChannelID = ""
r.CredentialScope = scope
got, err = h.resolveProbeHeaders(r)
if err != nil || got["x-api-key"] != "stored-secret" {
t.Fatalf("scope %s: %v", scope, err)
}
}
r := base
r.Headers = map[string]string{"X-Api-Key": "fresh"}
r.BaseURL = "https://new.invalid"
got, err = h.resolveProbeHeaders(r)
if err != nil || got["X-Api-Key"] != "fresh" {
t.Fatal("fresh credential rejected")
}
h.config.Hitl.AuditModel = config.OpenAIConfig{BaseURL: "https://audit.invalid", APIKey: "audit-key"}
r = base
r.CredentialScope = "hitlAudit"
r.BaseURL = "https://audit.invalid"
r.APIKey = "audit-key"
if _, err = h.resolveProbeHeaders(r); err == nil {
t.Fatal("main credential restored for independent audit")
}
}
func TestProbeHeaderFailureMakesNoRequest(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ }))
defer srv.Close()
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"a": {APIKey: "key", BaseURL: "https://saved.invalid", Headers: map[string]string{"Authorization": "Bearer secret"}}}}}, logger: zap.NewNop()}
payload, _ := json.Marshal(TestOpenAIRequest{ChannelID: "a", APIKey: "key", BaseURL: srv.URL, Model: "m", Headers: map[string]string{"Authorization": maskedSecret}})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/test", strings.NewReader(string(payload)))
c.Request.Header.Set("Content-Type", "application/json")
h.TestOpenAI(c)
if w.Code != 400 || calls != 0 {
t.Fatalf("status=%d outbound=%d", w.Code, calls)
}
}
func TestAuditHeadersPersistAndRestoreMaskedConfig(t *testing.T) {
cfg := &config.Config{Hitl: config.HitlConfig{AuditModel: config.OpenAIConfig{Headers: map[string]string{"X-Api-Key": "audit-test-secret"}}}}
response, err := maskedConfigResponse(configSecretSnapshot(cfg))
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
var req UpdateConfigRequest
if err = json.Unmarshal(raw, &req); err != nil {
t.Fatal(err)
}
if err = restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
var doc yaml.Node
if err = yaml.Unmarshal([]byte("hitl: {}\n"), &doc); err != nil {
t.Fatal(err)
}
updateHitlConfig(&doc, *req.Hitl)
raw, err = yaml.Marshal(&doc)
if err != nil {
t.Fatal(err)
}
var saved config.Config
if err = yaml.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if saved.Hitl.AuditModel.Headers["X-Api-Key"] != "audit-test-secret" {
t.Fatal("audit header lost on save")
}
}
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"cyberstrike-ai/internal/config"
"fmt"
"strings"
)
// resolveProbeHeaders restores only explicitly scoped, same-target secrets.
// Missing/ambiguous markers fail before any outbound HTTP request is made.
func (h *ConfigHandler) resolveProbeHeaders(req TestOpenAIRequest) (map[string]string, error) {
needsStored := false
seen := map[string]bool{}
for name, value := range req.Headers {
key := strings.ToLower(strings.TrimSpace(name))
if seen[key] {
return nil, fmt.Errorf("重复的请求头名称")
}
seen[key] = true
if strings.Contains(value, maskedSecret) {
needsStored = true
}
}
if !needsStored {
return config.ExpandHeaderMap(req.Headers), nil
}
h.mu.RLock()
defer h.mu.RUnlock()
if h.config == nil {
return nil, fmt.Errorf("没有已保存的请求头凭据")
}
var stored config.OpenAIConfig
switch {
case req.ChannelID != "":
ch, ok := h.config.AI.Channels[req.ChannelID]
if !ok {
return nil, fmt.Errorf("未知通道,无法恢复请求头凭据")
}
stored = ch.ToOpenAIConfig()
if req.CredentialScope == "hitlAudit" {
stored = h.config.Hitl.AuditModelEffective(stored)
} else if req.CredentialScope != "" && req.CredentialScope != "openai" {
return nil, fmt.Errorf("请求头凭据范围不匹配")
}
case req.CredentialScope == "hitlAudit":
stored = h.config.Hitl.AuditModelEffective(h.config.OpenAI)
case req.CredentialScope == "openai":
stored = h.config.OpenAI
default:
return nil, fmt.Errorf("请指定通道或模型凭据范围以恢复请求头")
}
if probeHeaderProvider(req.Provider) != probeHeaderProvider(stored.Provider) || probeHeaderURL(req.Provider, req.BaseURL) != probeHeaderURL(stored.Provider, stored.BaseURL) || strings.TrimSpace(req.APIKey) != strings.TrimSpace(stored.APIKey) {
return nil, fmt.Errorf("模型身份或地址已改变,请重新填写请求头凭据")
}
out := make(map[string]string, len(req.Headers))
for name, value := range req.Headers {
if strings.Contains(value, maskedSecret) {
if value != maskedSecret {
return nil, fmt.Errorf("无效的请求头凭据占位符")
}
found := ""
count := 0
for savedName, savedValue := range stored.Headers {
if strings.EqualFold(strings.TrimSpace(savedName), strings.TrimSpace(name)) {
found = savedValue
count++
}
}
if count != 1 || strings.TrimSpace(found) == "" || strings.Contains(found, maskedSecret) {
return nil, fmt.Errorf("没有唯一的已保存请求头凭据")
}
value = found
}
out[name] = value
}
return config.ExpandHeaderMap(out), nil
}
func probeHeaderProvider(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return "openai"
}
return provider
}
func probeHeaderURL(provider, raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
if probeHeaderProvider(provider) == "claude" {
return "https://api.anthropic.com"
}
return "https://api.openai.com/v1"
}
return raw
}
+1 -1
View File
@@ -24,5 +24,5 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
transport = &einoUsageNormalizingRoundTripper{base: transport}
transport = &headersRoundTripper{base: transport, cfg: cfg}
cloned.Transport = transport
return &cloned
return customHeaderHTTPClient(&cloned, cfg)
}
+57
View File
@@ -0,0 +1,57 @@
package openai
import (
"cyberstrike-ai/internal/config"
"fmt"
"net/http"
"net/url"
"strings"
)
// customHeaderHTTPClient does not mutate shared clients. Custom headers can
// contain credentials under arbitrary names, so none may follow a cross-origin
// redirect (including HTTPS downgrades). Checking only in the transport is too
// late: net/http also copies private headers into redirected requests.
func customHeaderHTTPClient(base *http.Client, cfg *config.OpenAIConfig) *http.Client {
if base == nil {
base = http.DefaultClient
}
if cfg == nil || len(cfg.Headers) == 0 {
return base
}
cloned := *base
previous := base.CheckRedirect
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if previous != nil {
if err := previous(req, via); err != nil {
return err
}
} else if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if len(via) == 0 || !sameHeaderOrigin(req.URL, via[0].URL) {
return fmt.Errorf("cross-origin redirect blocked for model custom headers")
}
return nil
}
return &cloned
}
func sameHeaderOrigin(a, b *url.URL) bool {
if a == nil || b == nil {
return false
}
port := func(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if strings.EqualFold(u.Scheme, "https") {
return "443"
}
if strings.EqualFold(u.Scheme, "http") {
return "80"
}
return ""
}
return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Hostname(), b.Hostname()) && port(a) == port(b)
}
+131
View File
@@ -0,0 +1,131 @@
package openai
import (
"context"
"cyberstrike-ai/internal/config"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestCustomHeadersDoNotCrossRedirectOrigin(t *testing.T) {
for _, kind := range []string{"eino", "builtin"} {
t.Run(kind, func(t *testing.T) {
targetCalls := 0
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetCalls++; w.Write([]byte(`{}`)) }))
defer dest.Close()
for _, mode := range []string{"port", "host", "downgrade"} {
t.Run(mode, func(t *testing.T) {
target := dest.URL
if mode == "host" {
target = strings.Replace(target, "127.0.0.1", "localhost", 1)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target, 302) })
var src *httptest.Server
if mode == "downgrade" {
src = httptest.NewTLSServer(handler)
} else {
src = httptest.NewServer(handler)
}
defer src.Close()
cfg := &config.OpenAIConfig{BaseURL: src.URL, APIKey: "test-key", Headers: map[string]string{"Authorization": "Bearer test-private", "X-Private-Key": "private"}}
var err error
if kind == "eino" {
var resp *http.Response
resp, err = NewEinoHTTPClient(cfg, src.Client()).Get(src.URL)
if resp != nil {
resp.Body.Close()
}
} else {
var out interface{}
err = NewClient(cfg, src.Client(), nil).ChatCompletion(context.Background(), map[string]string{}, &out)
}
if err == nil || !strings.Contains(err.Error(), "cross-origin") {
t.Fatalf("expected blocked redirect: %v", err)
}
if targetCalls != 0 {
t.Fatalf("credentials could reach other origin: %d requests", targetCalls)
}
})
}
})
}
}
func TestCustomHeadersSameOriginRedirectAndClientIsolation(t *testing.T) {
finalCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/final" {
http.Redirect(w, r, "/final", 307)
return
}
finalCalls++
if r.Header.Get("Authorization") != "Bearer custom" || r.Header.Get("X-Private-Key") != "private" {
t.Error("same-origin headers lost")
}
w.Write([]byte(`{}`))
}))
defer srv.Close()
cfg := &config.OpenAIConfig{BaseURL: srv.URL, APIKey: "key", Headers: map[string]string{"Authorization": "Bearer custom", "X-Private-Key": "private"}}
base := srv.Client()
callbacks := 0
base.CheckRedirect = func(req *http.Request, via []*http.Request) error { callbacks++; return nil }
resp, err := NewEinoHTTPClient(cfg, base).Get(srv.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var out interface{}
if err = NewClient(cfg, base, nil).ChatCompletion(context.Background(), map[string]string{}, &out); err != nil {
t.Fatal(err)
}
if finalCalls != 2 || callbacks != 2 {
t.Fatalf("calls=%d callbacks=%d", finalCalls, callbacks)
}
// Existing callbacks are preserved; they can decline a redirect without a request.
base.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err = NewEinoHTTPClient(cfg, base).Get(srv.URL)
if err != nil || resp.StatusCode != 307 {
t.Fatalf("callback not preserved: %v", err)
}
resp.Body.Close()
if customHeaderHTTPClient(base, nil) != base {
t.Fatal("no-header client changed")
}
// RoundTrip must not mutate a request owned by the caller.
req, _ := http.NewRequest("GET", srv.URL+"/final", nil)
resp, err = NewEinoHTTPClient(cfg, base).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if req.Header.Get("Authorization") != "" {
t.Fatal("request mutated")
}
}
func TestHeaderRedirectPolicyLimitsAndCallbackMutation(t *testing.T) {
cfg := &config.OpenAIConfig{Headers: map[string]string{"X-Private": "value"}}
req, _ := http.NewRequest("GET", "https://example.com:443/next", nil)
first, _ := http.NewRequest("GET", "https://example.com/start", nil)
client := customHeaderHTTPClient(nil, cfg)
if err := client.CheckRedirect(req, []*http.Request{first}); err != nil {
t.Fatal(err)
}
if client.CheckRedirect(req, make([]*http.Request, 10)) == nil {
t.Fatal("redirect loop limit lost")
}
base := &http.Client{CheckRedirect: func(r *http.Request, _ []*http.Request) error {
r.URL, _ = url.Parse("https://other.invalid")
return nil
}}
if customHeaderHTTPClient(base, cfg).CheckRedirect(req, []*http.Request{first}) == nil {
t.Fatal("callback rewrote destination across boundary")
}
sentinel := fmt.Errorf("custom decline")
base.CheckRedirect = func(*http.Request, []*http.Request) error { return sentinel }
if customHeaderHTTPClient(base, cfg).CheckRedirect(req, nil) != sentinel {
t.Fatal("custom error lost")
}
}
+4 -4
View File
@@ -118,7 +118,7 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
applyCustomHeaders(req, c.config)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return fmt.Errorf("call openai api: %w", err)
}
@@ -211,7 +211,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
applyCustomHeaders(req, c.config)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return "", fmt.Errorf("call openai api: %w", err)
}
@@ -362,7 +362,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
applyCustomHeaders(req, c.config)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return "", nil, "", fmt.Errorf("call openai api: %w", err)
}
@@ -577,7 +577,7 @@ func (c *Client) ListModels(ctx context.Context) ([]string, error) {
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
applyCustomHeaders(req, c.config)
resp, err := c.httpClient.Do(req)
resp, err := customHeaderHTTPClient(c.httpClient, c.config).Do(req)
if err != nil {
return nil, fmt.Errorf("call openai models api: %w", err)
}
+3 -2
View File
@@ -112,6 +112,7 @@ func (rt *headersRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
if rt == nil || req == nil {
return base.RoundTrip(req)
}
applyCustomHeaders(req, rt.cfg)
return base.RoundTrip(req)
cloned := req.Clone(req.Context())
applyCustomHeaders(cloned, rt.cfg)
return base.RoundTrip(cloned)
}
@@ -0,0 +1,19 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const vm=require('node:vm');
const source=fs.readFileSync('web/static/js/settings.js','utf8');
const code=source.slice(source.indexOf('function collectHitlAuditModelEffectiveConfig()'),source.indexOf('async function testHitlAuditModelConnection()'));
function collect(overrides={},savedHeaders){
const fields={'openai-provider':'openai','openai-api-key':'********','openai-base-url':'https://main.invalid','openai-model':'main',...overrides};
const c={document:{getElementById:id=>({value:fields[id]||''})},selectedAIChannelId:'main',normalizeAIChannelId:x=>x,currentConfig:{ai:{channels:{main:{headers:{Authorization:'********','X-Label':'label'}}}},hitl:{audit_model:{headers:savedHeaders}}}};
vm.createContext(c);vm.runInContext(code,c);return JSON.parse(JSON.stringify(c.collectHitlAuditModelEffectiveConfig()));
}
test('audit shares headers only when it inherits model identity',()=>{
assert.equal(collect().headers.Authorization,'********');
assert.equal(collect({'hitl-audit-model-name':'small'}).headers.Authorization,'********');
for(const [id,value] of [['hitl-audit-model-provider','openai'],['hitl-audit-model-base-url','https://audit.invalid'],['hitl-audit-model-api-key','********']])assert.deepEqual(collect({[id]:value}).headers,{});
});
test('explicit audit headers belong to audit configuration',()=>{
assert.deepEqual(collect({'hitl-audit-model-base-url':'https://audit.invalid'},{'X-Api-Key':'********'}).headers,{'X-Api-Key':'********'});
});
+3 -1
View File
@@ -3799,6 +3799,8 @@ function collectHitlAuditModelEffectiveConfig() {
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
model: document.getElementById('openai-model')?.value.trim() || ''
};
const auditProvider = document.getElementById('hitl-audit-model-provider')?.value || '';
const auditKey = document.getElementById('hitl-audit-model-api-key')?.value.trim() || '';
const overriddenBaseUrl = document.getElementById('hitl-audit-model-base-url')?.value.trim() || '';
const mainChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
const mainHeaders = currentConfig?.ai?.channels?.[mainChannelId]?.headers || {};
@@ -3808,7 +3810,7 @@ function collectHitlAuditModelEffectiveConfig() {
api_key: document.getElementById('hitl-audit-model-api-key')?.value.trim() || main.api_key,
model: document.getElementById('hitl-audit-model-name')?.value.trim() || main.model,
// 审计模型留空即复用主渠道,自定义请求头必须一起跟随,否则私有鉴权头的供应商测试必然失败。
headers: overriddenBaseUrl ? {} : mainHeaders
headers: currentConfig?.hitl?.audit_model?.headers ?? ((overriddenBaseUrl || auditProvider || auditKey) ? {} : mainHeaders)
};
}