Merge pull request #922 from samwafgo/feat_notify_webhook

feat:add webhook
This commit is contained in:
samwafgo
2026-08-06 08:27:42 +08:00
committed by GitHub
6 changed files with 871 additions and 3 deletions
+8
View File
@@ -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)
+27
View File
@@ -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
+29 -3
View File
@@ -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 := ""
@@ -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)
}
+546
View File
@@ -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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&apos;",
)
// 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)
}
+256
View File
@@ -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)
}
}