From a12710b28c4b790665e3b9beef16718cd65d664b Mon Sep 17 00:00:00 2001 From: samwaf Date: Wed, 5 Aug 2026 23:09:22 +0800 Subject: [PATCH] feat:add webhook #693 --- api/waf_notify_channel.go | 8 + service/waf_service/waf_notify_channel.go | 27 + service/waf_service/waf_notify_sender.go | 32 +- service/waf_service/waf_notify_template.go | 5 + wafnotify/webhook/webhook.go | 546 +++++++++++++++++++++ wafnotify/webhook/webhook_test.go | 256 ++++++++++ 6 files changed, 871 insertions(+), 3 deletions(-) create mode 100644 wafnotify/webhook/webhook.go create mode 100644 wafnotify/webhook/webhook_test.go diff --git a/api/waf_notify_channel.go b/api/waf_notify_channel.go index eb1c554..9aeaaec 100644 --- a/api/waf_notify_channel.go +++ b/api/waf_notify_channel.go @@ -27,6 +27,10 @@ func (w *WafNotifyChannelApi) AddApi(c *gin.Context) { var req request.WafNotifyChannelAddReq err := c.ShouldBindJSON(&req) if err == nil { + if cfgErr := waf_service.ValidateChannelConfig(req.Type, req.ConfigJSON); cfgErr != nil { + response.FailWithMessage(cfgErr.Error(), c) + return + } err = wafNotifyChannelService.CheckIsExistApi(req) if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { err = wafNotifyChannelService.AddApi(req) @@ -133,6 +137,10 @@ func (w *WafNotifyChannelApi) ModifyApi(c *gin.Context) { var req request.WafNotifyChannelEditReq err := c.ShouldBindJSON(&req) if err == nil { + if cfgErr := waf_service.ValidateChannelConfig(req.Type, req.ConfigJSON); cfgErr != nil { + response.FailWithMessage(cfgErr.Error(), c) + return + } err = wafNotifyChannelService.ModifyApi(req) if err != nil { response.FailWithMessage("编辑发生错误", c) diff --git a/service/waf_service/waf_notify_channel.go b/service/waf_service/waf_notify_channel.go index db19e6f..eb1ba7f 100644 --- a/service/waf_service/waf_notify_channel.go +++ b/service/waf_service/waf_notify_channel.go @@ -13,6 +13,7 @@ import ( "SamWaf/wafnotify/email" "SamWaf/wafnotify/feishu" "SamWaf/wafnotify/serverchan" + "SamWaf/wafnotify/webhook" "SamWaf/wafnotify/wechatwork" "errors" "time" @@ -168,11 +169,37 @@ func (receiver *WafNotifyChannelService) TestChannelApi(req request.WafNotifyCha return err } return notifier.SendMarkdown(title, content) + case "webhook": + notifier, err := webhook.NewWebhookNotifier(channel.ConfigJSON) + if err != nil { + return err + } + return notifier.Send(webhook.Message{ + Title: title, + Content: content, + Time: time.Now().Format("2006-01-02 15:04:05"), + MessageType: model.MSG_TYPE_OPERATION_NOTICE, + MessageTypeName: GetMessageTypeName(model.MSG_TYPE_OPERATION_NOTICE), + Severity: GetMessageTypeSeverity(model.MSG_TYPE_OPERATION_NOTICE), + ServerName: global.GWAF_CUSTOM_SERVER_NAME, + }) default: return errors.New("不支持的通知类型") } } +// ValidateChannelConfig 渠道配置校验(新增/编辑保存前调用) +// +// webhook 渠道的地址、方法、请求头都是用户自由填写的,保存时就把非法配置挡掉, +// 否则用户要等到真正触发告警、去翻通知日志才知道配错了。 +func ValidateChannelConfig(channelType, configJSON string) error { + if channelType == "webhook" { + _, err := webhook.ParseConfig(configJSON) + return err + } + return nil +} + // GetAllChannels 获取所有启用的通知渠道 func (receiver *WafNotifyChannelService) GetAllChannels() []model.NotifyChannel { var channels []model.NotifyChannel diff --git a/service/waf_service/waf_notify_sender.go b/service/waf_service/waf_notify_sender.go index a134af6..ea3c96a 100644 --- a/service/waf_service/waf_notify_sender.go +++ b/service/waf_service/waf_notify_sender.go @@ -11,12 +11,30 @@ import ( "SamWaf/wafnotify/email" "SamWaf/wafnotify/feishu" "SamWaf/wafnotify/serverchan" + "SamWaf/wafnotify/webhook" "SamWaf/wafnotify/wechatwork" "fmt" "strings" "sync" + "time" ) +// buildWebhookMessage 组装通用 Webhook 的模板变量 +// +// 只给「标题/正文 + 几个公共维度」:更细的字段(域名、攻击IP…)由订阅级模板渲染进正文, +// 两层模板各管一段,不在渠道层重复一遍变量表。 +func buildWebhookMessage(messageType, title, content string) webhook.Message { + return webhook.Message{ + Title: title, + Content: content, + Time: time.Now().Format("2006-01-02 15:04:05"), + MessageType: messageType, + MessageTypeName: GetMessageTypeName(messageType), + Severity: GetMessageTypeSeverity(messageType), + ServerName: global.GWAF_CUSTOM_SERVER_NAME, + } +} + type WafNotifySenderService struct{} var WafNotifySenderServiceApp = new(WafNotifySenderService) @@ -161,7 +179,7 @@ func (receiver *WafNotifySenderService) getChannelById(channelId string, channel // sendToChannel 发送到具体渠道并记录日志 func (receiver *WafNotifySenderService) sendToChannel(channel model.NotifyChannel, subscription model.NotifySubscription, messageType, title, content, templateUsed string) { - recipients, err := receiver.deliverToChannel(channel, subscription, title, content) + recipients, err := receiver.deliverToChannel(channel, subscription, messageType, title, content) status := 1 errorMsg := "" @@ -195,7 +213,7 @@ func (receiver *WafNotifySenderService) sendToChannel(channel model.NotifyChanne // 从 sendToChannel 里拆出来,是为了让「订阅级测试发送」能拿到真实错误反馈给管理端 —— // issue #822 的标题就是"通知管理无法调试",测试发送必须走完全相同的投递链路。 func (receiver *WafNotifySenderService) deliverToChannel(channel model.NotifyChannel, - subscription model.NotifySubscription, title, content string) (recipients string, err error) { + subscription model.NotifySubscription, messageType, title, content string) (recipients string, err error) { switch channel.Type { case "dingtalk": if ok, reason := utils.IsSafeOutboundURL(channel.WebhookURL); !ok { @@ -252,6 +270,14 @@ func (receiver *WafNotifySenderService) deliverToChannel(channel model.NotifyCha } else { err = notifier.SendMarkdown(title, content) } + case "webhook": + // 地址/方法/请求头/报文模板全在 ConfigJSON 里,SSRF 校验在 notifier 内部做(构造与发送各一次) + notifier, notifierErr := webhook.NewWebhookNotifier(channel.ConfigJSON) + if notifierErr != nil { + err = notifierErr + } else { + err = notifier.Send(buildWebhookMessage(messageType, title, content)) + } default: err = fmt.Errorf("不支持的通知类型: %s", channel.Type) } @@ -278,7 +304,7 @@ func (receiver *WafNotifySenderService) SendTestToSubscription(sub model.NotifyS } content += "\n\n> 本条为通知订阅测试消息,不代表真实事件" - recipients, err := receiver.deliverToChannel(channel, testSub, title, content) + recipients, err := receiver.deliverToChannel(channel, testSub, sub.MessageType, title, content) status := 1 errorMsg := "" diff --git a/service/waf_service/waf_notify_template.go b/service/waf_service/waf_notify_template.go index e2e3914..57ebf01 100644 --- a/service/waf_service/waf_notify_template.go +++ b/service/waf_service/waf_notify_template.go @@ -108,6 +108,11 @@ func escapeForChannel(channelType, s string) string { switch channelType { case "email": return html.EscapeString(s) + case "webhook": + // 通用 Webhook 的对端格式未知(可能是 JSON 字段、纯文本、也可能是别的), + // 在这里做 Markdown 转义只会把反斜杠原样发给对方;真正的转义按 Content-Type + // 在 wafnotify/webhook 渲染报文时做。 + return s default: return escapeMarkdown(s) } diff --git a/wafnotify/webhook/webhook.go b/wafnotify/webhook/webhook.go new file mode 100644 index 0000000..d3fcf3d --- /dev/null +++ b/wafnotify/webhook/webhook.go @@ -0,0 +1,546 @@ +package webhook + +import ( + "SamWaf/utils" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "text/template" + "text/template/parse" +) + +/* +* +通用 Webhook 通知渠道(issue #693) + +内置的钉钉/飞书/企业微信只能覆盖固定几家,用户自建的告警平台(Slack、Telegram、Bark、 +ntfy、Gotify、内部工单系统…)报文格式各不相同,所以这里把「地址 + 方法 + 请求头 + 报文模板」 +全部交给用户定义。 + +三条硬约束: + + 1. 用户可控的对外地址一律走 IsSafeOutboundURL + SafeHTTPClient,跳转链上每一跳都重新校验, + 否则这就是一个现成的 SSRF 打内网的入口。 + 2. 自定义请求头是拼进 HTTP 报文的原始数据:头名必须是 RFC token,头值不得含 CR/LF, + 并且禁止覆盖 Host/Content-Length/Transfer-Encoding 这类由传输层决定的头(请求走私)。 + 3. 模板变量(Title/Content)里含攻击者可控内容,塞进 JSON 报文前必须按 Content-Type 转义, + 否则一个带引号的 URL 就能把报文打成非法 JSON,告警直接发不出去。 +*/ + +const ( + // MaxHeaders 自定义请求头数量上限 + MaxHeaders = 20 + // MaxHeaderKeyLen 单个头名长度上限 + MaxHeaderKeyLen = 128 + // MaxHeaderValueLen 单个头值长度上限 + MaxHeaderValueLen = 2048 + // MaxBodyTemplateLen Body 模板长度上限 + MaxBodyTemplateLen = 64 * 1024 + // MaxBodyBytes 渲染后报文体积上限 + MaxBodyBytes = 256 * 1024 + // maxRespSnippet 错误信息里回显的响应片段长度 + maxRespSnippet = 512 + + // DefaultMethod 默认请求方法 + DefaultMethod = "POST" + // DefaultContentType 默认报文类型 + DefaultContentType = "application/json" +) + +// allowedMethods 允许的请求方法白名单 +var allowedMethods = map[string]bool{ + "POST": true, "PUT": true, "PATCH": true, "GET": true, "DELETE": true, +} + +// forbiddenHeaders 禁止用户覆盖的请求头 +// +// 这些头由 http.Transport 自己决定,放开会造成请求走私,或把请求实际打到别的主机上, +// 绕过前面刚做完的 SSRF 校验。 +var forbiddenHeaders = map[string]bool{ + "host": true, + "content-length": true, + "transfer-encoding": true, + "connection": true, + "upgrade": true, + "expect": true, + "proxy-connection": true, + "proxy-authorization": true, + "te": true, + "trailer": true, +} + +// Header 一条自定义请求头 +type Header struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// Config 通用 Webhook 渠道配置(存放于 NotifyChannel.ConfigJSON) +type Config struct { + URL string `json:"url"` + Method string `json:"method"` + ContentType string `json:"content_type"` + Headers []Header `json:"headers"` + BodyTemplate string `json:"body_template"` +} + +// Message 供 Body 模板使用的变量集合 +// +// 字段名即模板变量名:{{.Title}} {{.Content}} …… 与订阅级模板引擎保持同一套语法, +// 用户不用记两种写法。 +// +// 注意与「订阅级消息模板」的分工:订阅模板负责把域名/攻击IP/规则信息这些细节 +// 渲染成一段文案,渲染结果就是这里的 Title / Content;渠道模板只负责报文外壳 +// (对端要什么字段名)。两层不冲突,但订阅模板的变量在这里是不存在的。 +type Message struct { + Title string `json:"title"` + Content string `json:"content"` + Time string `json:"time"` + MessageType string `json:"message_type"` + MessageTypeName string `json:"message_type_name"` + Severity string `json:"severity"` + ServerName string `json:"server_name"` +} + +// VarNames Body 模板可用的变量名(顺序与前端变量标签一致) +func VarNames() []string { + return []string{"Title", "Content", "Time", "MessageType", "MessageTypeName", "Severity", "ServerName"} +} + +// toVars 转成模板数据 +// +// 用 map 而不是结构体:配合 missingkey=zero,写错的变量渲染成空串而不是执行报错。 +// 告警场景下"少一个字段"远好于"整条发不出去",写错变量的兜底在保存时校验(见 Validate)。 +func (m Message) toVars() map[string]string { + return map[string]string{ + "Title": m.Title, + "Content": m.Content, + "Time": m.Time, + "MessageType": m.MessageType, + "MessageTypeName": m.MessageTypeName, + "Severity": m.Severity, + "ServerName": m.ServerName, + } +} + +// sampleMessage 保存时干跑用的样例数据 +func sampleMessage() Message { + return Message{ + Title: "SamWaf 测试通知", + Content: "这是一条用于校验模板的样例正文", + Time: "2026-01-01 00:00:00", + MessageType: "rule_trigger", + MessageTypeName: "规则触发", + Severity: "warn", + ServerName: "SamWaf", + } +} + +// templateFuncs 模板可用函数白名单(与订阅级模板引擎一致,不暴露任何 IO 能力) +var templateFuncs = template.FuncMap{ + "default": func(def, s string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s + }, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "truncate": func(n int, s string) string { + if n <= 0 || len(s) <= n { + return s + } + return s[:n] + "..." + }, + "contains": strings.Contains, +} + +// WebhookNotifier 通用 Webhook 通知器 +type WebhookNotifier struct { + Config Config +} + +// NewWebhookNotifier 从 ConfigJSON 构造通知器(会做完整校验) +func NewWebhookNotifier(configJSON string) (*WebhookNotifier, error) { + cfg, err := ParseConfig(configJSON) + if err != nil { + return nil, err + } + return &WebhookNotifier{Config: *cfg}, nil +} + +// ParseConfig 解析并规范化配置 +func ParseConfig(configJSON string) (*Config, error) { + if strings.TrimSpace(configJSON) == "" { + return nil, errors.New("Webhook 配置不能为空") + } + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return nil, fmt.Errorf("解析 Webhook 配置失败: %v", err) + } + cfg.normalize() + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// normalize 补默认值、去空白 +func (c *Config) normalize() { + c.URL = strings.TrimSpace(c.URL) + c.Method = strings.ToUpper(strings.TrimSpace(c.Method)) + if c.Method == "" { + c.Method = DefaultMethod + } + c.ContentType = strings.TrimSpace(c.ContentType) + if c.ContentType == "" { + c.ContentType = DefaultContentType + } + + headers := make([]Header, 0, len(c.Headers)) + for _, h := range c.Headers { + key := strings.TrimSpace(h.Key) + if key == "" { + continue // 前端"添加一行"留下的空行直接丢掉,不当成错误 + } + headers = append(headers, Header{Key: key, Value: strings.TrimSpace(h.Value)}) + } + c.Headers = headers +} + +// Validate 配置校验(新增/编辑保存时与发送时都会调用) +func (c *Config) Validate() error { + if c.URL == "" { + return errors.New("Webhook 地址不能为空") + } + if ok, reason := utils.IsSafeOutboundURL(c.URL); !ok { + return fmt.Errorf("Webhook 地址不被允许: %s", reason) + } + if !allowedMethods[c.Method] { + return fmt.Errorf("不支持的请求方法: %s", c.Method) + } + if len(c.ContentType) > MaxHeaderValueLen || containsCTL(c.ContentType) { + return errors.New("Content-Type 非法") + } + if len(c.Headers) > MaxHeaders { + return fmt.Errorf("自定义请求头最多 %d 条", MaxHeaders) + } + seen := make(map[string]bool, len(c.Headers)) + for _, h := range c.Headers { + if err := validateHeader(h); err != nil { + return err + } + lower := strings.ToLower(h.Key) + if seen[lower] { + return fmt.Errorf("请求头 %s 重复", h.Key) + } + seen[lower] = true + } + if len(c.BodyTemplate) > MaxBodyTemplateLen { + return fmt.Errorf("Body 模板长度超过 %d 字节上限", MaxBodyTemplateLen) + } + if strings.TrimSpace(c.BodyTemplate) != "" { + tmpl, err := template.New("webhook_body").Funcs(templateFuncs). + Option("missingkey=zero").Parse(c.BodyTemplate) + if err != nil { + return fmt.Errorf("Body 模板语法错误: %v", err) + } + // 把订阅模板的变量({{.Domain}} {{.Ip}} …)误写到这里是最容易犯的错, + // 保存时就点名指出来,别等到真出告警才发现报文里少了东西 + if unknown := collectUnknownFields(tmpl); len(unknown) > 0 { + return fmt.Errorf("Body 模板里的变量 %s 不存在。本处可用变量:%s;"+ + "域名、攻击IP、规则信息等细节属于「通知订阅」的消息模板,会被渲染进 {{.Content}}", + strings.Join(unknown, "、"), strings.Join(VarNames(), " ")) + } + // 干跑一次:括号/引号没配平这类问题当场报出来 + probe := &WebhookNotifier{Config: *c} + if _, err = probe.RenderBody(sampleMessage()); err != nil { + return err + } + } + return nil +} + +// collectUnknownFields 遍历模板语法树,挑出不在 VarNames() 里的变量名 +// +// 走语法树而不是正则:{{ if .Foo }}、{{ .Foo | upper }} 这类写法正则很难覆盖全。 +func collectUnknownFields(tmpl *template.Template) []string { + known := make(map[string]bool, 8) + for _, name := range VarNames() { + known[name] = true + } + + seen := make(map[string]bool) + var unknown []string + var walk func(n parse.Node) + walk = func(n parse.Node) { + switch node := n.(type) { + case *parse.ListNode: + if node == nil { + return + } + for _, child := range node.Nodes { + walk(child) + } + case *parse.ActionNode: + walk(node.Pipe) + case *parse.PipeNode: + if node == nil { + return + } + for _, cmd := range node.Cmds { + walk(cmd) + } + case *parse.CommandNode: + for _, arg := range node.Args { + walk(arg) + } + case *parse.IfNode: + walk(node.Pipe) + walk(node.List) + walk(node.ElseList) + case *parse.RangeNode: + walk(node.Pipe) + walk(node.List) + walk(node.ElseList) + case *parse.WithNode: + walk(node.Pipe) + walk(node.List) + walk(node.ElseList) + case *parse.TemplateNode: + walk(node.Pipe) + case *parse.ChainNode: + walk(node.Node) + case *parse.FieldNode: + name := node.Ident[0] + if !known[name] && !seen[name] { + seen[name] = true + unknown = append(unknown, name) + } + } + } + if tmpl.Tree != nil { + walk(tmpl.Tree.Root) + } + return unknown +} + +// validateHeader 单条请求头校验 +func validateHeader(h Header) error { + if len(h.Key) > MaxHeaderKeyLen { + return fmt.Errorf("请求头名称过长: %s", truncateForErr(h.Key)) + } + if !isValidHeaderToken(h.Key) { + return fmt.Errorf("请求头名称非法: %s", truncateForErr(h.Key)) + } + if forbiddenHeaders[strings.ToLower(h.Key)] { + return fmt.Errorf("请求头 %s 由系统控制,不允许自定义", h.Key) + } + if len(h.Value) > MaxHeaderValueLen { + return fmt.Errorf("请求头 %s 的值过长", h.Key) + } + if containsCTL(h.Value) { + // 头值里的 CR/LF 是最典型的 HTTP 响应/请求拆分手法,必须在入口就挡掉 + return fmt.Errorf("请求头 %s 的值包含非法控制字符", h.Key) + } + return nil +} + +// isValidHeaderToken 头名是否为 RFC 7230 token +func isValidHeaderToken(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + return true +} + +// containsCTL 是否含控制字符(含 CR/LF/NUL) +func containsCTL(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +func truncateForErr(s string) string { + if len(s) > 32 { + return s[:32] + "..." + } + return s +} + +// SendMarkdown 与其他渠道保持一致的调用形态(只有标题正文时使用) +func (w *WebhookNotifier) SendMarkdown(title, content string) error { + return w.Send(Message{Title: title, Content: content}) +} + +// Send 渲染报文并投递 +func (w *WebhookNotifier) Send(msg Message) error { + // 配置可能是很久以前存下的(甚至被直接改过库),发送前重新校验一次目标地址 + if ok, reason := utils.IsSafeOutboundURL(w.Config.URL); !ok { + return fmt.Errorf("Webhook 地址不被允许: %s", reason) + } + + body, err := w.RenderBody(msg) + if err != nil { + return err + } + + var reader io.Reader + if w.Config.Method != http.MethodGet && len(body) > 0 { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest(w.Config.Method, w.Config.URL, reader) + if err != nil { + return fmt.Errorf("构造请求失败: %v", err) + } + if reader != nil { + req.Header.Set("Content-Type", w.Config.ContentType) + } + req.Header.Set("User-Agent", "SamWaf-Notify") + // 用户自定义头放最后,允许覆盖上面两个默认头(Content-Type / User-Agent) + for _, h := range w.Config.Headers { + req.Header.Set(h.Key, h.Value) + } + + resp, err := utils.SafeHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("发送HTTP请求失败: %v", err) + } + defer resp.Body.Close() + + // 响应体只读一小段用于报错,避免对端返回巨量内容把内存吃满 + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespSnippet)) + if resp.StatusCode < 200 || resp.StatusCode > 299 { + snippet := strings.TrimSpace(string(respBody)) + if snippet == "" { + return fmt.Errorf("Webhook 返回状态码 %d", resp.StatusCode) + } + return fmt.Errorf("Webhook 返回状态码 %d: %s", resp.StatusCode, snippet) + } + return nil +} + +// RenderBody 按 Content-Type 转义变量后渲染 Body 模板 +func (w *WebhookNotifier) RenderBody(msg Message) ([]byte, error) { + tpl := strings.TrimSpace(w.Config.BodyTemplate) + if tpl == "" { + return w.defaultBody(msg) + } + + tmpl, err := template.New("webhook_body").Funcs(templateFuncs). + Option("missingkey=zero").Parse(w.Config.BodyTemplate) + if err != nil { + return nil, fmt.Errorf("Body 模板语法错误: %v", err) + } + + var buf bytes.Buffer + lw := &limitedWriter{buf: &buf, limit: MaxBodyBytes} + if err = tmpl.Execute(lw, escapeMessage(w.Config.ContentType, msg).toVars()); err != nil { + return nil, fmt.Errorf("Body 模板渲染失败: %v", err) + } + + out := buf.Bytes() + if isJSONContentType(w.Config.ContentType) && !json.Valid(out) { + // 这里报错而不是硬发出去:对端收到非法 JSON 只会回 400,用户对着日志更难定位 + return nil, errors.New("Body 模板渲染后不是合法 JSON,请检查括号与引号") + } + return out, nil +} + +// defaultBody 未配置模板时的默认报文 +func (w *WebhookNotifier) defaultBody(msg Message) ([]byte, error) { + if isJSONContentType(w.Config.ContentType) { + b, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("序列化默认报文失败: %v", err) + } + return b, nil + } + return []byte(msg.Title + "\n\n" + msg.Content), nil +} + +// escapeMessage 按报文类型逐字段转义 +// +// 绝大多数场景是「把变量塞进 JSON 字符串里」,正文里的换行和引号不转义就会打碎报文。 +func escapeMessage(contentType string, msg Message) Message { + esc := func(s string) string { return escapeValue(contentType, s) } + return Message{ + Title: esc(msg.Title), + Content: esc(msg.Content), + Time: esc(msg.Time), + MessageType: esc(msg.MessageType), + MessageTypeName: esc(msg.MessageTypeName), + Severity: esc(msg.Severity), + ServerName: esc(msg.ServerName), + } +} + +func escapeValue(contentType, s string) string { + if s == "" { + return s + } + ct := strings.ToLower(contentType) + switch { + case strings.Contains(ct, "json"): + return jsonStringInner(s) + case strings.Contains(ct, "x-www-form-urlencoded"): + return url.QueryEscape(s) + case strings.Contains(ct, "xml"), strings.Contains(ct, "html"): + return xmlEscaper.Replace(s) + default: + return s + } +} + +var xmlEscaper = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + "\"", """, + "'", "'", +) + +// jsonStringInner 取 JSON 字符串字面量去掉首尾引号后的内容 +func jsonStringInner(s string) string { + b, err := json.Marshal(s) + if err != nil || len(b) < 2 { + return "" + } + return string(b[1 : len(b)-1]) +} + +func isJSONContentType(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "json") +} + +// limitedWriter 输出超过上限即报错,防止模板产出巨量报文 +type limitedWriter struct { + buf *bytes.Buffer + limit int + written int +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + if w.written+len(p) > w.limit { + return 0, errors.New("渲染报文超出体积上限") + } + w.written += len(p) + return w.buf.Write(p) +} diff --git a/wafnotify/webhook/webhook_test.go b/wafnotify/webhook/webhook_test.go new file mode 100644 index 0000000..f2be438 --- /dev/null +++ b/wafnotify/webhook/webhook_test.go @@ -0,0 +1,256 @@ +package webhook + +import ( + "encoding/json" + "strings" + "testing" +) + +// 说明:Send() 会强制走 IsSafeOutboundURL,httptest 起在 127.0.0.1 上必然被拒, +// 所以这里只测「配置校验」和「报文渲染」两段纯逻辑,真实投递用管理端的"测试"按钮验证。 + +func mustConfig(t *testing.T, cfg Config) *WebhookNotifier { + t.Helper() + return &WebhookNotifier{Config: cfg} +} + +func TestValidateRejectsUnsafeConfig(t *testing.T) { + cases := []struct { + name string + cfg Config + wantSub string + }{ + {"空地址", Config{Method: "POST", ContentType: "application/json"}, "地址不能为空"}, + {"内网地址", Config{URL: "http://127.0.0.1/hook", Method: "POST"}, "不被允许"}, + {"非http协议", Config{URL: "file:///etc/passwd", Method: "POST"}, "不被允许"}, + {"方法不在白名单", Config{URL: "https://203.0.113.10/hook", Method: "TRACE"}, "不支持的请求方法"}, + {"头名非法", Config{URL: "https://203.0.113.10/hook", Method: "POST", + Headers: []Header{{Key: "X Bad Header", Value: "v"}}}, "名称非法"}, + {"头名被系统占用", Config{URL: "https://203.0.113.10/hook", Method: "POST", + Headers: []Header{{Key: "Host", Value: "evil.com"}}}, "由系统控制"}, + {"头值含CRLF", Config{URL: "https://203.0.113.10/hook", Method: "POST", + Headers: []Header{{Key: "X-Token", Value: "a\r\nX-Injected: 1"}}}, "非法控制字符"}, + {"头重复", Config{URL: "https://203.0.113.10/hook", Method: "POST", + Headers: []Header{{Key: "X-Token", Value: "a"}, {Key: "x-token", Value: "b"}}}, "重复"}, + {"模板语法错误", Config{URL: "https://203.0.113.10/hook", Method: "POST", + BodyTemplate: "{{.Title"}, "语法错误"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.cfg + cfg.normalize() + err := cfg.Validate() + if err == nil { + t.Fatalf("期望校验失败,实际通过") + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("错误信息不含 %q,实际: %v", tc.wantSub, err) + } + }) + } +} + +func TestNormalizeFillsDefaultsAndDropsEmptyHeaders(t *testing.T) { + cfg := Config{ + URL: " https://203.0.113.10/hook ", + Headers: []Header{{Key: " ", Value: "x"}, {Key: " X-Token ", Value: " abc "}}, + } + cfg.normalize() + + if cfg.URL != "https://203.0.113.10/hook" { + t.Fatalf("URL 未去空白: %q", cfg.URL) + } + if cfg.Method != DefaultMethod || cfg.ContentType != DefaultContentType { + t.Fatalf("默认值未填充: %s %s", cfg.Method, cfg.ContentType) + } + if len(cfg.Headers) != 1 || cfg.Headers[0].Key != "X-Token" || cfg.Headers[0].Value != "abc" { + t.Fatalf("空行未丢弃或未去空白: %+v", cfg.Headers) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("规范化后应校验通过: %v", err) + } +} + +// 正文里带引号和换行是常态(攻击URL、UA),不转义就会把 JSON 报文打碎 +func TestRenderBodyEscapesJSON(t *testing.T) { + n := mustConfig(t, Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/json", + BodyTemplate: `{"title":"{{.Title}}","text":"{{.Content}}"}`, + }) + + body, err := n.RenderBody(Message{ + Title: `告警"紧急"`, + Content: "第一行\n第二行\\结束", + }) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + + var got map[string]string + if err = json.Unmarshal(body, &got); err != nil { + t.Fatalf("渲染结果不是合法 JSON: %s (%v)", body, err) + } + if got["title"] != `告警"紧急"` { + t.Fatalf("标题被改动: %q", got["title"]) + } + if got["text"] != "第一行\n第二行\\结束" { + t.Fatalf("正文被改动: %q", got["text"]) + } +} + +func TestRenderBodyRejectsInvalidJSON(t *testing.T) { + n := mustConfig(t, Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/json", + BodyTemplate: `{"title":"{{.Title}}"`, // 少一个右括号 + }) + if _, err := n.RenderBody(Message{Title: "x"}); err == nil || + !strings.Contains(err.Error(), "不是合法 JSON") { + t.Fatalf("期望报非法 JSON,实际: %v", err) + } +} + +func TestRenderBodyFormURLEncoded(t *testing.T) { + n := mustConfig(t, Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/x-www-form-urlencoded", + BodyTemplate: `title={{.Title}}&body={{.Content}}`, + }) + body, err := n.RenderBody(Message{Title: "a b&c", Content: "d=e"}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if string(body) != "title=a+b%26c&body=d%3De" { + t.Fatalf("表单转义不正确: %s", body) + } +} + +func TestDefaultBody(t *testing.T) { + jsonNotifier := mustConfig(t, Config{URL: "https://203.0.113.10/hook", Method: "POST", ContentType: "application/json"}) + body, err := jsonNotifier.RenderBody(Message{Title: "标题", Content: "正文", Severity: "warn"}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + var got Message + if err = json.Unmarshal(body, &got); err != nil { + t.Fatalf("默认报文不是合法 JSON: %s", body) + } + if got.Title != "标题" || got.Content != "正文" || got.Severity != "warn" { + t.Fatalf("默认报文字段丢失: %+v", got) + } + + textNotifier := mustConfig(t, Config{URL: "https://203.0.113.10/hook", Method: "POST", ContentType: "text/plain"}) + body, err = textNotifier.RenderBody(Message{Title: "标题", Content: "正文"}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + if string(body) != "标题\n\n正文" { + t.Fatalf("纯文本默认报文不正确: %q", body) + } +} + +func TestRenderBodyRejectsOversizeOutput(t *testing.T) { + n := mustConfig(t, Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "text/plain", + BodyTemplate: "{{.Content}}", + }) + if _, err := n.RenderBody(Message{Content: strings.Repeat("A", MaxBodyBytes+1)}); err == nil { + t.Fatalf("超长报文应被拒绝") + } +} + +func TestParseConfigFromJSON(t *testing.T) { + raw := `{"url":"https://203.0.113.10/hook","method":"post","headers":[{"key":"Authorization","value":"Bearer x"}],"body_template":"{\"t\":\"{{.Title}}\"}"}` + cfg, err := ParseConfig(raw) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if cfg.Method != "POST" { + t.Fatalf("方法未大写: %s", cfg.Method) + } + if cfg.ContentType != DefaultContentType { + t.Fatalf("Content-Type 默认值不对: %s", cfg.ContentType) + } + if len(cfg.Headers) != 1 || cfg.Headers[0].Value != "Bearer x" { + t.Fatalf("请求头解析不对: %+v", cfg.Headers) + } +} + +// 把订阅模板的变量误写到渠道模板里,是最容易犯的错:保存时就要点名, +// 而不是等真出告警时整条发不出去。 +func TestValidateRejectsUnknownVars(t *testing.T) { + cfg := Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/json", + BodyTemplate: `{"a":"{{.Title}}","b":"{{.Domain}}","c":"{{ .Ip | upper }}"}`, + } + cfg.normalize() + err := cfg.Validate() + if err == nil { + t.Fatalf("期望校验失败,实际通过") + } + for _, want := range []string{"Domain", "Ip", "不存在"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("错误信息缺少 %q,实际: %v", want, err) + } + } +} + +// if / range / 管道里的变量也要能被识别出来 +func TestCollectUnknownFieldsWalksBranches(t *testing.T) { + cfg := Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "text/plain", + BodyTemplate: `{{ if .Severity }}{{ .Title }}{{ else }}{{ .NotExist }}{{ end }}` + + `{{ with .Content }}{{ . }}{{ end }}`, + } + cfg.normalize() + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "NotExist") { + t.Fatalf("期望识别出 NotExist,实际: %v", err) + } +} + +// 已入库的老配置(或直接改库)带未知变量时不能让告警消失:渲染成空串照发 +func TestRenderBodyTreatsUnknownVarAsEmpty(t *testing.T) { + n := mustConfig(t, Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/json", + BodyTemplate: `{"a":"{{.Title}}","b":"{{.Domain}}"}`, + }) + body, err := n.RenderBody(Message{Title: "标题"}) + if err != nil { + t.Fatalf("不应渲染失败: %v", err) + } + var got map[string]string + if err = json.Unmarshal(body, &got); err != nil { + t.Fatalf("渲染结果不是合法 JSON: %s", body) + } + if got["a"] != "标题" || got["b"] != "" { + t.Fatalf("未知变量应渲染为空串: %+v", got) + } +} + +// 保存时就该发现括号没配平,不用等到发送 +func TestValidateCatchesInvalidJSONAtSaveTime(t *testing.T) { + cfg := Config{ + URL: "https://203.0.113.10/hook", + Method: "POST", + ContentType: "application/json", + BodyTemplate: `{"title":"{{.Title}}"`, + } + cfg.normalize() + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "不是合法 JSON") { + t.Fatalf("期望保存时就报非法 JSON,实际: %v", err) + } +}