From 0bcb16e0212f957c40180e5131a954fff891e7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:41:42 +0800 Subject: [PATCH] Add files via upload --- internal/config/config.go | 15 +++++ internal/config/robots_validate_test.go | 45 ++++++++++++++ internal/handler/config.go | 4 ++ internal/handler/robot.go | 67 ++++++++++++++------- internal/handler/robot_wecom_test.go | 78 +++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 internal/config/robots_validate_test.go create mode 100644 internal/handler/robot_wecom_test.go diff --git a/internal/config/config.go b/internal/config/config.go index fc8d6137..6de8704d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -503,6 +503,17 @@ type RobotWecomConfig struct { AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId } +// ValidateWecomConfig 校验企业微信机器人配置;启用时必须配置 token,否则回调无法防伪造。 +func ValidateWecomConfig(w RobotWecomConfig) error { + if !w.Enabled { + return nil + } + if strings.TrimSpace(w.Token) == "" { + return fmt.Errorf("robots.wecom.enabled 为 true 时必须配置 robots.wecom.token") + } + return nil +} + // RobotDingtalkConfig 钉钉机器人配置 type RobotDingtalkConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` @@ -887,6 +898,10 @@ func Load(path string) (*Config, error) { } } + if err := ValidateWecomConfig(cfg.Robots.Wecom); err != nil { + return nil, err + } + return &cfg, nil } diff --git a/internal/config/robots_validate_test.go b/internal/config/robots_validate_test.go new file mode 100644 index 00000000..35c83e97 --- /dev/null +++ b/internal/config/robots_validate_test.go @@ -0,0 +1,45 @@ +package config + +import "testing" + +func TestValidateWecomConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg RobotWecomConfig + wantErr bool + }{ + { + name: "disabled without token", + cfg: RobotWecomConfig{Enabled: false, Token: ""}, + wantErr: false, + }, + { + name: "enabled with token", + cfg: RobotWecomConfig{Enabled: true, Token: "secret"}, + wantErr: false, + }, + { + name: "enabled without token", + cfg: RobotWecomConfig{Enabled: true, Token: ""}, + wantErr: true, + }, + { + name: "enabled with whitespace token", + cfg: RobotWecomConfig{Enabled: true, Token: " "}, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateWecomConfig(tt.cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateWecomConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index a0261363..93022bfb 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -798,6 +798,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { // 更新机器人配置 if req.Robots != nil { + if err := config.ValidateWecomConfig(req.Robots.Wecom); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } h.config.Robots = *req.Robots h.logger.Info("更新机器人配置", zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled), diff --git a/internal/handler/robot.go b/internal/handler/robot.go index 0a703367..db4c6dab 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -711,12 +711,27 @@ type wecomReplyXML struct { Content string `xml:"Content"` } +// wecomRequireToken 企业微信回调必须配置 Token;未配置时拒绝请求,防止未授权触发 Agent。 +func (h *RobotHandler) wecomRequireToken(c *gin.Context) (string, bool) { + token := strings.TrimSpace(h.config.Robots.Wecom.Token) + if token == "" { + h.logger.Warn("企业微信已启用但未配置 token,已拒绝回调(请在配置中设置 robots.wecom.token)") + c.String(http.StatusForbidden, "") + return "", false + } + return token, true +} + // HandleWecomGET 企业微信 URL 校验(GET) func (h *RobotHandler) HandleWecomGET(c *gin.Context) { if !h.config.Robots.Wecom.Enabled { c.String(http.StatusNotFound, "") return } + token, ok := h.wecomRequireToken(c) + if !ok { + return + } // Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串 echostr := c.Query("echostr") msgSignature := c.Query("msg_signature") @@ -724,7 +739,7 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) { nonce := c.Query("nonce") // 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1 - signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr) + signature := h.signWecomRequest(token, timestamp, nonce, echostr) if signature != msgSignature { h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature)) c.String(http.StatusBadRequest, "invalid signature") @@ -865,27 +880,28 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { } h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw))) - // 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段 - // 若配置了 Token 则必须校验签名,避免未授权请求触发 Agent(防止平台被接管) - token := h.config.Robots.Wecom.Token - if token != "" { - if msgSignature == "" { - h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需配置 token 并确保回调携带 msg_signature)") - c.String(http.StatusOK, "") - return - } - var tmp wecomXML - if err := xml.Unmarshal(bodyRaw, &tmp); err != nil { - h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err)) - c.String(http.StatusOK, "") - return - } - expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt) - if expected != msgSignature { - h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature)) - c.String(http.StatusOK, "") - return - } + // 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段。 + // 启用企业微信时必须配置 token 并校验签名,避免未授权请求触发 Agent。 + token, ok := h.wecomRequireToken(c) + if !ok { + return + } + if msgSignature == "" { + h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需确保回调携带 msg_signature)") + c.String(http.StatusOK, "") + return + } + var tmp wecomXML + if err := xml.Unmarshal(bodyRaw, &tmp); err != nil { + h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err)) + c.String(http.StatusOK, "") + return + } + expected := h.signWecomRequest(token, timestamp, nonce, tmp.Encrypt) + if expected != msgSignature { + h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature)) + c.String(http.StatusOK, "") + return } var body wecomXML @@ -899,6 +915,13 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) { // 保存企业 ID(用于明文模式回复) enterpriseID := body.ToUserName + // 配置了 EncodingAESKey 时必须走加密消息,拒绝明文 XML 绕过 + if strings.TrimSpace(h.config.Robots.Wecom.EncodingAESKey) != "" && strings.TrimSpace(body.Encrypt) == "" { + h.logger.Warn("企业微信已配置加密模式但收到明文消息,已拒绝") + c.String(http.StatusOK, "") + return + } + // 加密模式:先解密再解析内层 XML if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" { h.logger.Debug("企业微信进入加密模式解密流程") diff --git a/internal/handler/robot_wecom_test.go b/internal/handler/robot_wecom_test.go new file mode 100644 index 00000000..bb4a78ed --- /dev/null +++ b/internal/handler/robot_wecom_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cyberstrike-ai/internal/config" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func newWecomTestHandler(token string, aesKey string) *RobotHandler { + return &RobotHandler{ + config: &config.Config{ + Robots: config.RobotsConfig{ + Wecom: config.RobotWecomConfig{ + Enabled: true, + Token: token, + EncodingAESKey: aesKey, + }, + }, + }, + logger: zap.NewNop(), + } +} + +func TestHandleWecomPOST_rejectsWhenTokenEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := newWecomTestHandler("", "") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `attackertexthi` + c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom", strings.NewReader(body)) + + h.HandleWecomPOST(c) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden) + } + if w.Body.String() == "success" { + t.Fatal("expected rejection, got success") + } +} + +func TestHandleWecomPOST_rejectsPlaintextWhenEncryptionConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := newWecomTestHandler("secret-token", "abcdefghijklmnopqrstuvwxyz0123456789ABCD") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `attackertexthi` + c.Request = httptest.NewRequest(http.MethodPost, "/api/robot/wecom?timestamp=1&nonce=2&msg_signature=fake", strings.NewReader(body)) + + h.HandleWecomPOST(c) + + if w.Body.String() == "success" { + t.Fatal("expected rejection for plaintext in encryption mode, got success") + } +} + +func TestHandleWecomGET_rejectsWhenTokenEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := newWecomTestHandler("", "") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/robot/wecom?msg_signature=x×tamp=1&nonce=2&echostr=abc", nil) + + h.HandleWecomGET(c) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden) + } +}