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)
+ }
+}