mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-13 15:57:22 +01:00
fix(headers): isolate credentials across audit, redirects and probes
This commit is contained in:
1 parent
585c95a0e2
commit
5feea8d3c3
15 files changed
+551
-16
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- AI 渠道支持自定义 HTTP 请求头、环境变量和会话 ID 占位符;保存后的鉴权头可安全用于连接测试,独立审计身份隔离主渠道请求头,自定义头不会跟随跨源重定向。
|
||||
|
||||
- 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。
|
||||
|
||||
- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。
|
||||
|
||||
@@ -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(其默认开启思考)
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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':'********'});
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user