diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bed22f..67168d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- AI 渠道支持自定义 HTTP 请求头、环境变量和会话 ID 占位符;保存后的鉴权头可安全用于连接测试,独立审计身份隔离主渠道请求头,自定义头不会跟随跨源重定向。 + - 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。 - 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。 diff --git a/config.example.yaml b/config.example.yaml index 3880e5a2..5b4557b8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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(其默认开启思考) diff --git a/internal/config/config.go b/internal/config/config.go index 700306bd..e5af521a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 = "" } diff --git a/internal/config/model_headers.go b/internal/config/model_headers.go new file mode 100644 index 00000000..92ae8a58 --- /dev/null +++ b/internal/config/model_headers.go @@ -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) +} diff --git a/internal/config/model_headers_test.go b/internal/config/model_headers_test.go new file mode 100644 index 00000000..940697e9 --- /dev/null +++ b/internal/config/model_headers_test.go @@ -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") + } + }) + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index d140353d..d018552e 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -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) diff --git a/internal/handler/config_header_boundaries_test.go b/internal/handler/config_header_boundaries_test.go new file mode 100644 index 00000000..e494ba7f --- /dev/null +++ b/internal/handler/config_header_boundaries_test.go @@ -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") + } +} diff --git a/internal/handler/config_probe_headers.go b/internal/handler/config_probe_headers.go new file mode 100644 index 00000000..1b7f8e28 --- /dev/null +++ b/internal/handler/config_probe_headers.go @@ -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 +} diff --git a/internal/openai/eino_http.go b/internal/openai/eino_http.go index 7b63bdbd..df9c5592 100644 --- a/internal/openai/eino_http.go +++ b/internal/openai/eino_http.go @@ -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) } diff --git a/internal/openai/header_redirect.go b/internal/openai/header_redirect.go new file mode 100644 index 00000000..e7d9400f --- /dev/null +++ b/internal/openai/header_redirect.go @@ -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) +} diff --git a/internal/openai/header_redirect_test.go b/internal/openai/header_redirect_test.go new file mode 100644 index 00000000..91e28584 --- /dev/null +++ b/internal/openai/header_redirect_test.go @@ -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") + } +} diff --git a/internal/openai/openai.go b/internal/openai/openai.go index b6ad250e..7b3aa77b 100644 --- a/internal/openai/openai.go +++ b/internal/openai/openai.go @@ -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) } diff --git a/internal/openai/request_headers.go b/internal/openai/request_headers.go index 4f21129f..9d9c42c8 100644 --- a/internal/openai/request_headers.go +++ b/internal/openai/request_headers.go @@ -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) } diff --git a/web/static/js/settings-header-boundaries.test.cjs b/web/static/js/settings-header-boundaries.test.cjs new file mode 100644 index 00000000..6e038c35 --- /dev/null +++ b/web/static/js/settings-header-boundaries.test.cjs @@ -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':'********'}); +}); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 4fa7b2ab..d9363da9 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -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) }; }