fix: mask stored credentials and validate agent saves

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:04:14 +08:00
1 parent 4a5b177947
commit 76da98944c
18 files changed
+780 -22

No files matched your search

+60 -13
View File
@@ -372,7 +372,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
),
}
c.JSON(http.StatusOK, GetConfigResponse{
response, err := maskedConfigResponse(GetConfigResponse{
AI: h.config.AI,
OpenAI: h.config.OpenAI,
Vision: h.config.Vision,
@@ -389,6 +389,11 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
Robots: h.config.Robots,
MultiAgent: multiPub,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "配置序列化失败"})
return
}
c.JSON(http.StatusOK, response)
}
// GetToolsResponse 获取工具列表响应(分页)
@@ -796,6 +801,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
if err := restoreConfigRequestSecrets(&req, h.config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新OpenAI配置
if req.AI != nil {
@@ -1252,10 +1261,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
// TestOpenAIRequest 测试OpenAI连接请求
type TestOpenAIRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
CredentialScope string `json:"credential_scope,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
// TestOpenAI 测试OpenAI API连接是否可用
@@ -1266,6 +1277,12 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
return
}
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
req.APIKey = resolvedKey
if strings.TrimSpace(req.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
return
@@ -1383,9 +1400,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
// TestTypeSafeRequest 测试 TypeSafe / Jev 连接。
type TestTypeSafeRequest struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
CredentialScope string `json:"credential_scope,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
// TestTypeSafe 用一条最小 Noul 验证 TypeSafe System One 是否可用。
@@ -1395,6 +1414,12 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
req.APIKey = resolvedKey
if strings.TrimSpace(req.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "TypeSafe API Key 不能为空"})
return
@@ -1438,9 +1463,11 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) {
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
type ListModelsRequest struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
CredentialScope string `json:"credential_scope,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
@@ -1451,6 +1478,12 @@ func (h *ConfigHandler) ListModels(c *gin.Context) {
return
}
resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
req.APIKey = resolvedKey
provider := strings.TrimSpace(req.Provider)
if provider == "" {
provider = "openai"
@@ -1512,8 +1545,9 @@ func (h *ConfigHandler) ListModels(c *gin.Context) {
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
type TestVisionRequest struct {
Vision config.VisionConfig `json:"vision"`
OpenAI config.OpenAIConfig `json:"openai,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Vision config.VisionConfig `json:"vision"`
OpenAI config.OpenAIConfig `json:"openai,omitempty"`
}
// TestVision 测试视觉模型 API 连接(最小 chat completion)。
@@ -1523,6 +1557,19 @@ func (h *ConfigHandler) TestVision(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
mainKey, mainErr := h.resolveProbeSecret(req.OpenAI.APIKey, req.ChannelID, req.OpenAI.BaseURL, "openai")
if mainErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": mainErr.Error()})
return
}
req.OpenAI.APIKey = mainKey
h.mu.RLock()
restoreErr := restoreConfigRequestSecrets(&req, h.config)
h.mu.RUnlock()
if restoreErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": restoreErr.Error()})
return
}
oa := req.Vision.OpenAICfgEffective(req.OpenAI)
if strings.TrimSpace(oa.APIKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空(可填写 vision.api_key 或 openai.api_key)"})
+189
View File
@@ -0,0 +1,189 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"cyberstrike-ai/internal/config"
)
const maskedSecret = "********"
func secretConfigKey(key string) bool {
switch strings.ToLower(key) {
case "api_key", "apikey", "password", "secret", "client_secret", "app_secret", "token", "bot_token", "app_token", "verify_token", "encoding_aes_key", "authorization", "x-api-key":
return true
}
return false
}
func configSecretSnapshot(cfg *config.Config) GetConfigResponse {
return GetConfigResponse{AI: cfg.AI, OpenAI: cfg.OpenAI, Vision: cfg.Vision, FOFA: cfg.FOFA,
ZoomEye: cfg.ZoomEye, Quake: cfg.Quake, Shodan: cfg.Shodan, MCP: cfg.MCP,
Hitl: cfg.Hitl, Knowledge: cfg.Knowledge, Robots: cfg.Robots}
}
func jsonSecretTree(value interface{}) (interface{}, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var tree interface{}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
err = decoder.Decode(&tree)
return tree, err
}
// transformSecretTree masks a detached response or restores a submitted marker
// from the same configuration path. It never changes the live configuration.
func transformSecretTree(tree, current interface{}, restoring bool) error {
switch node := tree.(type) {
case map[string]interface{}:
previous, _ := current.(map[string]interface{})
for key, value := range node {
text, isString := value.(string)
if secretConfigKey(key) && isString {
if !restoring && text != "" {
node[key] = maskedSecret
}
if restoring && text == maskedSecret {
stored, ok := previous[key].(string)
if !ok || stored == "" {
return fmt.Errorf("字段 %s 没有已保存凭据,请填写新的值", key)
}
node[key] = stored
}
continue
}
if err := transformSecretTree(value, previous[key], restoring); err != nil {
return err
}
}
case []interface{}:
previous, _ := current.([]interface{})
for i, value := range node {
var stored interface{}
if i < len(previous) {
stored = previous[i]
}
// Named entries can be reordered without moving secrets between them.
if entry, ok := value.(map[string]interface{}); ok {
for _, identity := range []string{"id", "name"} {
if id, ok := entry[identity].(string); ok && id != "" {
stored = nil
for _, candidate := range previous {
if old, ok := candidate.(map[string]interface{}); ok && old[identity] == id {
stored = old
break
}
}
break
}
}
}
if err := transformSecretTree(value, stored, restoring); err != nil {
return err
}
}
}
return nil
}
func maskedConfigResponse(response GetConfigResponse) (interface{}, error) {
tree, err := jsonSecretTree(response)
if err != nil {
return nil, err
}
err = transformSecretTree(tree, nil, false)
return tree, err
}
func restoreConfigRequestSecrets(request interface{}, cfg *config.Config) error {
update, isUpdate := request.(*UpdateConfigRequest)
mainKeyMasked := isUpdate && update.OpenAI != nil && update.OpenAI.APIKey == maskedSecret
tree, err := jsonSecretTree(request)
if err != nil {
return err
}
previous, err := jsonSecretTree(configSecretSnapshot(cfg))
if err != nil {
return err
}
if err := transformSecretTree(tree, previous, true); err != nil {
return err
}
raw, err := json.Marshal(tree)
if err != nil {
return err
}
if err := json.Unmarshal(raw, request); err != nil {
return err
}
if mainKeyMasked && update.AI != nil {
id := config.NormalizeAIChannelID(update.AI.DefaultChannel)
if channel, ok := update.AI.Channels[id]; ok {
update.OpenAI.APIKey = channel.APIKey
}
}
return nil
}
func (h *ConfigHandler) resolveProbeSecret(key, channelID, baseURL, scope string) (string, error) {
if key != maskedSecret {
return key, nil
}
h.mu.RLock()
defer h.mu.RUnlock()
if channelID != "" {
if ch, ok := h.config.AI.Channels[channelID]; ok && ch.APIKey != "" {
return ch.APIKey, nil
}
return "", fmt.Errorf("通道没有已保存凭据,请填写 API Key")
}
var scoped string
switch scope {
case "vision":
scoped = h.config.Vision.APIKey
case "hitlAudit":
scoped = h.config.Hitl.AuditModel.APIKey
case "knowledgeEmbedding":
scoped = h.config.Knowledge.Embedding.APIKey
case "openai":
scoped = h.config.OpenAI.APIKey
case "":
default:
return "", fmt.Errorf("未知凭据范围")
}
if scope != "" {
if scoped == "" {
scoped = h.config.OpenAI.APIKey
}
if scoped != "" {
return scoped, nil
}
return "", fmt.Errorf("没有已保存的 API Key")
}
keys := map[string]bool{}
add := func(url, secret string) {
if strings.TrimRight(url, "/") == strings.TrimRight(baseURL, "/") && secret != "" {
keys[secret] = true
}
}
add(h.config.OpenAI.BaseURL, h.config.OpenAI.APIKey)
for _, ch := range h.config.AI.Channels {
add(ch.BaseURL, ch.APIKey)
}
add(h.config.Hitl.AuditModel.BaseURL, h.config.Hitl.AuditModel.APIKey)
add(h.config.Vision.BaseURL, h.config.Vision.APIKey)
add(h.config.Knowledge.Embedding.BaseURL, h.config.Knowledge.Embedding.APIKey)
add(h.config.Knowledge.Retrieval.Rerank.BaseURL, h.config.Knowledge.Retrieval.Rerank.APIKey)
if len(keys) == 1 {
for secret := range keys {
return secret, nil
}
}
return "", fmt.Errorf("无法唯一确定已保存凭据,请选择通道或填写 API Key")
}
+123
View File
@@ -0,0 +1,123 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestConfigResponseMasksSecretsAndSavePreservesThem(t *testing.T) {
cfg := &config.Config{
OpenAI: config.OpenAIConfig{APIKey: "test-only-main", BaseURL: "http://127.0.0.1", Model: "test"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"first": {Name: "first", APIKey: "test-only-first", BaseURL: "http://127.0.0.1"},
"second": {Name: "second", APIKey: "test-only-second", BaseURL: "http://127.0.0.1"},
}},
Robots: config.RobotsConfig{Wecom: config.RobotWecomConfig{Token: "test-only-token", Secret: "test-only-secret", EncodingAESKey: "test-only-aes"}},
}
h := &ConfigHandler{config: cfg, logger: zap.NewNop()}
r := gin.New()
r.GET("/config", h.GetConfig)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config", nil))
if w.Code != http.StatusOK {
t.Fatalf("get: %d %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "test-only-") {
t.Fatal("response contains a saved secret")
}
if cfg.OpenAI.APIKey != "test-only-main" || cfg.AI.Channels["first"].APIKey != "test-only-first" {
t.Fatal("GET mutated live secrets")
}
var req UpdateConfigRequest
if err := json.Unmarshal(w.Body.Bytes(), &req); err != nil {
t.Fatal(err)
}
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
if req.OpenAI.APIKey != cfg.OpenAI.APIKey || req.AI.Channels["second"].APIKey != cfg.AI.Channels["second"].APIKey || req.Robots.Wecom.Secret != cfg.Robots.Wecom.Secret {
t.Fatal("unchanged save lost secret")
}
req.OpenAI.APIKey = "test-only-replacement"
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
if req.OpenAI.APIKey != "test-only-replacement" {
t.Fatal("explicit replacement was ignored")
}
req.OpenAI.APIKey = ""
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
if req.OpenAI.APIKey != "" {
t.Fatal("explicit clearing was ignored")
}
// Changing the default channel must not copy the old default's secret into it.
req.OpenAI.APIKey = maskedSecret
req.AI.DefaultChannel = "second"
if err := restoreConfigRequestSecrets(&req, cfg); err != nil {
t.Fatal(err)
}
if req.OpenAI.APIKey != "test-only-second" {
t.Fatal("default channel change selected the old main key")
}
}
func TestModelListUsesSavedChannelSecretWithoutEcho(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.Header.Get("Authorization") != "Bearer test-only-upstream" {
t.Error("masked key reached upstream")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"test-model"}]}`))
}))
defer srv.Close()
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"saved": {APIKey: "test-only-upstream", BaseURL: srv.URL}}}}}
r := gin.New()
r.POST("/models", h.ListModels)
w := httptest.NewRecorder()
raw, _ := json.Marshal(ListModelsRequest{BaseURL: srv.URL, APIKey: maskedSecret, ChannelID: "saved"})
req := httptest.NewRequest(http.MethodPost, "/models", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK || requests != 1 || strings.Contains(w.Body.String(), "test-only-upstream") {
t.Fatalf("model request: %d requests=%d", w.Code, requests)
}
}
func TestMaskedProbeUsesSelectedChannelAndRejectsAmbiguousKey(t *testing.T) {
h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"one": {APIKey: "test-one", BaseURL: "http://127.0.0.1"},
"two": {APIKey: "test-two", BaseURL: "http://127.0.0.1"},
}}}}
if key, err := h.resolveProbeSecret(maskedSecret, "two", "http://127.0.0.1", ""); err != nil || key != "test-two" {
t.Fatalf("selected resolution failed: %v", err)
}
if _, err := h.resolveProbeSecret(maskedSecret, "", "http://127.0.0.1", ""); err == nil {
t.Fatal("ambiguous key was selected")
}
if _, err := h.resolveProbeSecret(maskedSecret, "missing", "http://127.0.0.1", ""); err == nil {
t.Fatal("missing channel accepted")
}
if key, err := h.resolveProbeSecret("test-new", "missing", "http://127.0.0.1", ""); err != nil || key != "test-new" {
t.Fatal("new credential rejected")
}
}
func TestConfigMaskCannotBeSavedAsNewChannelCredential(t *testing.T) {
cfg := &config.Config{}
req := UpdateConfigRequest{AI: &config.AIConfig{Channels: map[string]config.AIChannelConfig{"new": {APIKey: maskedSecret}}}}
if restoreConfigRequestSecrets(&req, cfg) == nil {
t.Fatal("mask accepted for new channel")
}
}
@@ -0,0 +1,56 @@
package handler
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"cyberstrike-ai/internal/agents"
"gopkg.in/yaml.v3"
)
var markdownAgentIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
func (h *MarkdownAgentsHandler) validateMarkdownWrite(filename string, content []byte) error {
front, _, err := agents.SplitFrontMatter(string(content))
if err != nil {
return err
}
var fm agents.FrontMatter
if err := yaml.Unmarshal([]byte(front), &fm); err != nil {
return err
}
if strings.TrimSpace(fm.Name) == "" {
return fmt.Errorf("智能体 name 不能为空")
}
if fm.ID != "" && !markdownAgentIDPattern.MatchString(fm.ID) {
return fmt.Errorf("Agent ID 格式无效")
}
sub, err := agents.ParseMarkdownSubAgent(filename, string(content))
if err != nil {
return err
}
if !markdownAgentIDPattern.MatchString(sub.ID) {
return fmt.Errorf("Agent ID 必须为 1–64 个字母、数字、连字符或下划线,且以字母或数字开头")
}
if err := validateRoleName(sub.Name); err != nil {
return fmt.Errorf("智能体名称无效: %w", err)
}
if strings.TrimSpace(sub.Instruction) == "" {
return fmt.Errorf("智能体指令不能为空")
}
if sub.MaxIterations < 0 {
return fmt.Errorf("最大迭代数不能为负数")
}
files, err := agents.LoadMarkdownAgentFiles(h.dir)
if err != nil {
return err
}
for _, file := range files {
if filepath.Base(file.Filename) != filepath.Base(filename) && file.Config.ID == sub.ID {
return fmt.Errorf("Agent ID 已被文件 %s 使用", file.Filename)
}
}
return nil
}
@@ -0,0 +1,74 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestMarkdownAgentValidationAndIDUniqueness(t *testing.T) {
dir := t.TempDir()
h := NewMarkdownAgentsHandler(dir)
r := gin.New()
r.POST("/agents", h.CreateMarkdownAgent)
r.PUT("/agents/:filename", h.UpdateMarkdownAgent)
send := func(method, path string, body markdownAgentBody) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
return w
}
good := markdownAgentBody{Filename: "first.md", ID: "unique", Name: "valid", Instruction: "Reply OK"}
if w := send(http.MethodPost, "/agents", good); w.Code != http.StatusOK {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
original, _ := os.ReadFile(filepath.Join(dir, good.Filename))
duplicateFile := good
duplicateFile.Instruction = "overwrite"
if w := send(http.MethodPost, "/agents", duplicateFile); w.Code != http.StatusConflict {
t.Fatalf("duplicate file: %d", w.Code)
}
duplicateID := good
duplicateID.Filename = "second.md"
if w := send(http.MethodPost, "/agents", duplicateID); w.Code != http.StatusBadRequest {
t.Fatalf("duplicate ID: %d", w.Code)
}
after, _ := os.ReadFile(filepath.Join(dir, good.Filename))
if !bytes.Equal(original, after) {
t.Fatal("rejected create overwrote existing file")
}
if w := send(http.MethodPut, "/agents/first.md", good); w.Code != http.StatusOK {
t.Fatalf("self update: %d %s", w.Code, w.Body.String())
}
if w := send(http.MethodPut, "/agents/missing.md", good); w.Code != http.StatusNotFound {
t.Fatalf("created missing update: %d", w.Code)
}
for _, body := range []markdownAgentBody{
{Filename: "bad.md", ID: "bad", Name: "bad/name", Instruction: "text"},
{Filename: "bad.md", ID: "bad/id", Name: "valid", Instruction: "text"},
{Filename: "bad.md", ID: "bad", Name: "valid"},
{Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nname: valid\nid: raw/id\n---\ntext"},
{Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nid: raw\n---\ntext"},
} {
if w := send(http.MethodPost, "/agents", body); w.Code != http.StatusBadRequest {
t.Fatalf("invalid %#v: %d %s", body, w.Code, w.Body.String())
}
}
other := good
other.Filename, other.ID = "second.md", "second"
if w := send(http.MethodPost, "/agents", other); w.Code != http.StatusOK {
t.Fatalf("other create: %d %s", w.Code, w.Body.String())
}
other.ID = "unique"
if w := send(http.MethodPut, "/agents/second.md", other); w.Code != http.StatusBadRequest {
t.Fatalf("duplicate update: %d", w.Code)
}
}
+33 -3
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/audit"
@@ -19,8 +20,9 @@ var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.m
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
type MarkdownAgentsHandler struct {
dir string
audit *audit.Service
dir string
audit *audit.Service
writeMu sync.Mutex
}
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
@@ -152,6 +154,8 @@ type markdownAgentBody struct {
// CreateMarkdownAgent POST /api/multi-agent/markdown-agents
func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
h.writeMu.Lock()
defer h.writeMu.Unlock()
if h.dir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置 agents 目录"})
return
@@ -215,6 +219,10 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
return
}
}
if err := h.validateMarkdownWrite(filepath.Base(path), out); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if want := agents.WantsMarkdownOrchestrator(filepath.Base(path), body.Kind, string(out)); want {
other, oerr := existingOtherOrchestrator(h.dir, filepath.Base(path))
if oerr != nil {
@@ -230,10 +238,22 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.WriteFile(path, out, 0644); err != nil {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
if os.IsExist(err) {
c.JSON(http.StatusConflict, gin.H{"error": "文件已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_, writeErr := f.Write(out)
closeErr := f.Close()
if writeErr != nil || closeErr != nil {
_ = os.Remove(path)
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入智能体文件失败"})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "agent", "markdown_create", "创建 Markdown 子代理", "markdown_agent", filepath.Base(path), nil)
}
@@ -242,12 +262,18 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
// UpdateMarkdownAgent PUT /api/multi-agent/markdown-agents/:filename
func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
h.writeMu.Lock()
defer h.writeMu.Unlock()
filename := c.Param("filename")
path, err := h.safeJoin(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(path); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
var body markdownAgentBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -285,6 +311,10 @@ func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
return
}
}
if err := h.validateMarkdownWrite(filename, out); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if want := agents.WantsMarkdownOrchestrator(filename, body.Kind, string(out)); want {
other, oerr := existingOtherOrchestrator(h.dir, filename)
if oerr != nil {
+22 -4
View File
@@ -391,6 +391,11 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) {
if list == nil {
list = []database.WebShellConnection{}
}
for i := range list {
if list[i].Password != "" {
list[i].Password = maskedSecret
}
}
c.JSON(http.StatusOK, list)
}
@@ -457,7 +462,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
"host": host, "type": shellType,
})
}
c.JSON(http.StatusOK, conn)
c.JSON(http.StatusOK, publicWebshellConnection(conn))
}
// UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id)
@@ -498,11 +503,20 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
if shellType == "" {
shellType = "php"
}
password := strings.TrimSpace(req.Password)
if password == maskedSecret {
stored, ok := h.authorizedWebshellConnection(c, id, "")
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该连接"})
return
}
password = stored.Password
}
conn := &database.WebShellConnection{
ID: id,
ProjectID: projectID,
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Password: password,
Type: shellType,
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
@@ -520,9 +534,9 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
}
updated, _ := h.db.GetWebshellConnection(id)
if updated != nil {
c.JSON(http.StatusOK, updated)
c.JSON(http.StatusOK, publicWebshellConnection(updated))
} else {
c.JSON(http.StatusOK, conn)
c.JSON(http.StatusOK, publicWebshellConnection(conn))
}
}
@@ -751,6 +765,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if req.Password == maskedSecret {
c.JSON(http.StatusBadRequest, gin.H{"error": "请使用已保存连接或填写新的连接口令"})
return
}
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+14
View File
@@ -0,0 +1,14 @@
package handler
import "cyberstrike-ai/internal/database"
func publicWebshellConnection(conn *database.WebShellConnection) *database.WebShellConnection {
if conn == nil {
return nil
}
public := *conn
if public.Password != "" {
public.Password = maskedSecret
}
return &public
}
+70
View File
@@ -0,0 +1,70 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestWebshellMasksResponsesAndPreservesStoredPassword(t *testing.T) {
db, user, allowed, _ := setupWebshellRBACTest(t)
allowed.Password = "test-only-webshell-password"
if err := db.UpdateWebshellConnection(allowed); err != nil {
t.Fatal(err)
}
h := NewWebShellHandler(zap.NewNop(), db)
w := performWebshellJSON(user, http.MethodGet, "/connections", nil, h.ListConnections)
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), allowed.Password) || !strings.Contains(w.Body.String(), maskedSecret) {
t.Fatalf("unsafe list: %d", w.Code)
}
update := func(c *gin.Context) { c.Params = gin.Params{{Key: "id", Value: allowed.ID}}; h.UpdateConnection(c) }
for _, password := range []string{maskedSecret, "test-only-new-password", ""} {
w = performWebshellJSON(user, http.MethodPut, "/connections/"+allowed.ID, map[string]interface{}{"url": allowed.URL, "type": "php", "method": "post", "password": password, "remark": "updated"}, update)
if w.Code != http.StatusOK {
t.Fatalf("update: %d %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "test-only-") {
t.Fatal("update returned secret")
}
saved, err := db.GetWebshellConnection(allowed.ID)
if err != nil {
t.Fatal(err)
}
expected := password
if password == maskedSecret {
expected = allowed.Password
}
if saved.Password != expected {
t.Fatal("password was not preserved/replaced/cleared as requested")
}
}
}
func TestWebshellExecUsesStoredPasswordForMaskedConnection(t *testing.T) {
db, user, allowed, _ := setupWebshellRBACTest(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.Form.Get("pass") != "test-only-stored" {
t.Error("executor did not use stored password")
}
_, _ = w.Write([]byte("OK"))
}))
defer srv.Close()
allowed.URL, allowed.Password = srv.URL, "test-only-stored"
if err := db.UpdateWebshellConnection(allowed); err != nil {
t.Fatal(err)
}
h := NewWebShellHandler(zap.NewNop(), db)
w := performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "connection_id": allowed.ID, "password": maskedSecret, "command": "printf OK"}, h.Exec)
if w.Code != http.StatusOK {
t.Fatalf("saved exec: %d %s", w.Code, w.Body.String())
}
w = performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "password": maskedSecret, "command": "printf OK"}, h.Exec)
if w.Code != http.StatusBadRequest {
t.Fatalf("ad hoc marker accepted: %d", w.Code)
}
}