From dbe30c74cfbbbe0142f6dfffa09fd95b5697a209 Mon Sep 17 00:00:00 2001 From: samwaf Date: Fri, 4 Sep 2026 13:49:04 +0800 Subject: [PATCH] fix: honor security.outbound_allowed_hosts for notification channel webhooks #990 --- service/waf_service/waf_notify_channel.go | 2 +- service/waf_service/waf_notify_sender.go | 6 ++-- utils/common.go | 3 +- utils/outbound.go | 21 ++++++++++++ utils/outbound_test.go | 21 ++++++++++++ wafconfig/wafconfig.go | 4 +-- wafnotify/dingtalk/dingtalk.go | 2 +- wafnotify/feishu/feishu.go | 2 +- wafnotify/webhook/webhook.go | 17 +++++++--- wafnotify/webhook/webhook_test.go | 39 ++++++++++++++++++++++- wafnotify/wechatwork/wechatwork.go | 3 +- wafupgradenotice/upgrade_notes.yaml | 15 +++++++++ 12 files changed, 119 insertions(+), 16 deletions(-) diff --git a/service/waf_service/waf_notify_channel.go b/service/waf_service/waf_notify_channel.go index 0bbc2922..983e5c50 100644 --- a/service/waf_service/waf_notify_channel.go +++ b/service/waf_service/waf_notify_channel.go @@ -155,7 +155,7 @@ func (receiver *WafNotifyChannelService) TestChannelApi(req request.WafNotifyCha // N5 if channel.Type == "dingtalk" || channel.Type == "feishu" || channel.Type == "wechatwork" { - if ok, reason := utils.IsSafeOutboundURL(channel.WebhookURL); !ok { + if ok, reason := utils.IsAllowedOutboundURL(channel.WebhookURL); !ok { return errors.New("WebhookURL 目标不被允许:" + reason) } } diff --git a/service/waf_service/waf_notify_sender.go b/service/waf_service/waf_notify_sender.go index 47b3448f..0a6d4478 100644 --- a/service/waf_service/waf_notify_sender.go +++ b/service/waf_service/waf_notify_sender.go @@ -216,21 +216,21 @@ func (receiver *WafNotifySenderService) deliverToChannel(channel model.NotifyCha subscription model.NotifySubscription, messageType, title, content string) (recipients string, err error) { switch channel.Type { case "dingtalk": - if ok, reason := utils.IsSafeOutboundURL(channel.WebhookURL); !ok { + if ok, reason := utils.IsAllowedOutboundURL(channel.WebhookURL); !ok { err = fmt.Errorf("WebhookURL 目标不被允许: %s", reason) } else { notifier := dingtalk.NewDingTalkNotifier(channel.WebhookURL, channel.Secret) err = notifier.SendMarkdown(title, content) } case "feishu": - if ok, reason := utils.IsSafeOutboundURL(channel.WebhookURL); !ok { + if ok, reason := utils.IsAllowedOutboundURL(channel.WebhookURL); !ok { err = fmt.Errorf("WebhookURL 目标不被允许: %s", reason) } else { notifier := feishu.NewFeishuNotifier(channel.WebhookURL, channel.Secret) err = notifier.SendMarkdown(title, content) } case "wechatwork": - if ok, reason := utils.IsSafeOutboundURL(channel.WebhookURL); !ok { + if ok, reason := utils.IsAllowedOutboundURL(channel.WebhookURL); !ok { err = fmt.Errorf("WebhookURL 目标不被允许: %s", reason) } else { notifier := wechatwork.NewWechatWorkNotifier(channel.WebhookURL) diff --git a/utils/common.go b/utils/common.go index f6b3c9a4..553fbe44 100644 --- a/utils/common.go +++ b/utils/common.go @@ -137,7 +137,8 @@ func IsSafeOutboundHost(host string) (bool, string) { } // IsSafeOutboundURL 校验对外请求 URL 是否安全(防 SSRF):仅允许 http/https, -// 且主机(域名解析后的所有IP)必须为公网。用于通知渠道 WebhookURL 等用户可控的对外地址。 +// 且主机(域名解析后的所有IP)必须为公网。只用于程序自身的固定对外地址(升级检测); +// 用户可配的对外地址一律走 IsAllowedOutboundURL(带 config.yml 允许清单逃生门)。 func IsSafeOutboundURL(rawURL string) (bool, string) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { diff --git a/utils/outbound.go b/utils/outbound.go index 178a6f5e..31b005af 100644 --- a/utils/outbound.go +++ b/utils/outbound.go @@ -128,11 +128,32 @@ func IsAllowedOutboundURL(rawURL string) (bool, string) { } ok, reason := IsSafeOutboundHost(host) if !ok { + // 主机名解析到内网时,若所有非公网解析结果都落在清单的 IP/CIDR 条目内,同样放行。 + // 连接期 safeOutboundDialContext 对每个真实拨号 IP 做同一判定,两层口径一致。 + if isHostCoveredByAllowlist(host) { + return true, "" + } return false, reason + "(确需访问内网源请在 config.yml 的 security.outbound_allowed_hosts 中声明该主机)" } return true, "" } +// isHostCoveredByAllowlist 主机名的每条解析结果要么是公网、要么落在清单 IP/CIDR 条目内时才放行。 +// 解析失败/无结果一律 false(fail-closed,与 IsSafeOutboundHost 同口径)。 +func isHostCoveredByAllowlist(host string) bool { + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return false + } + for _, ip := range ips { + if isPublicIP(ip) || isOutboundIPAllowlisted(ip) { + continue + } + return false + } + return true +} + // PrecheckOutboundURL 保存配置时的对外地址预检(协议 / 主机字面量),供 api·service 层早报错用。 // // 与 IsAllowedOutboundURL 的区别:域名解析失败时**放行**。保存配置的时刻网络可能还没通、 diff --git a/utils/outbound_test.go b/utils/outbound_test.go index 57dcb993..d5fb659d 100644 --- a/utils/outbound_test.go +++ b/utils/outbound_test.go @@ -190,3 +190,24 @@ func TestPrecheckOutboundURL_保存阶段的判定(t *testing.T) { t.Fatalf("已带外声明的内网地址应可保存,实际被拒: %s", reason) } } + +// 清单只写 CIDR、URL 用主机名(解析结果落在该 CIDR 内)时也必须放行—— +// 与连接期 safeOutboundDialContext 的按 IP 判定保持同口径(issue #990 评审发现的两层口径差)。 +func TestIsAllowedOutboundURL_主机名解析进清单CIDR(t *testing.T) { + // localhost 经 hosts 文件解析到 127.0.0.1,不依赖外部 DNS + setOutboundAllowHosts(t, "") + if ok, _ := IsAllowedOutboundURL("http://localhost/x"); ok { + t.Fatal("未声明时 localhost 必须被拒") + } + // localhost 通常同时解析出 127.0.0.1 与 ::1,与连接期"混进一个未声明内网地址就整体拒绝" + // 同口径:两条解析结果都必须被清单覆盖 + setOutboundAllowHosts(t, "127.0.0.0/8,::1/128") + if ok, reason := IsAllowedOutboundURL("http://localhost/x"); !ok { + t.Fatalf("解析结果落在清单 CIDR 内的主机名应放行,实际被拒: %s", reason) + } + // 清单之外的网段仍拒 + setOutboundAllowHosts(t, "10.0.0.0/8") + if ok, _ := IsAllowedOutboundURL("http://localhost/x"); ok { + t.Fatal("解析结果不在清单内的主机名必须仍被拒") + } +} diff --git a/wafconfig/wafconfig.go b/wafconfig/wafconfig.go index 8b32c8a5..49edae23 100644 --- a/wafconfig/wafconfig.go +++ b/wafconfig/wafconfig.go @@ -297,8 +297,8 @@ func LoadAndInitConfig() { configChanged = true } - //用户可配的对外拉取地址允许清单(主机名/IP/CIDR,逗号分隔)。覆盖:批量任务远端来源、 - //威胁情报订阅、CDN 回源段拉取。默认只允许公网目标;确有内网镜像源的部署, + //用户可配的对外地址允许清单(主机名/IP/CIDR,逗号分隔)。覆盖:通知渠道 Webhook、 + //批量任务远端来源、威胁情报订阅、CDN 回源段拉取。默认只允许公网目标;确有内网目标的部署, if config.IsSet("security.outbound_allowed_hosts") { global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = config.GetString("security.outbound_allowed_hosts") } else { diff --git a/wafnotify/dingtalk/dingtalk.go b/wafnotify/dingtalk/dingtalk.go index bc8249ee..8a701447 100644 --- a/wafnotify/dingtalk/dingtalk.go +++ b/wafnotify/dingtalk/dingtalk.go @@ -74,7 +74,7 @@ func (d *DingTalkNotifier) send(message DingTalkMessage) error { // 发送HTTP请求 // N5:经带跳转校验的安全客户端发送,防止 302 跳转到内网/云元数据(SSRF) - resp, err := utils.SafeHTTPClient().Post(urlWithSign, "application/json", bytes.NewBuffer(payload)) + resp, err := utils.SafeOutboundHTTPClient(30 * time.Second).Post(urlWithSign, "application/json", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("发送HTTP请求失败: %v", err) } diff --git a/wafnotify/feishu/feishu.go b/wafnotify/feishu/feishu.go index c41cf97a..14496096 100644 --- a/wafnotify/feishu/feishu.go +++ b/wafnotify/feishu/feishu.go @@ -117,7 +117,7 @@ func (f *FeishuNotifier) send(message FeishuMessage) error { // 发送HTTP请求 // N5:经带跳转校验的安全客户端发送,防 302 跳转到内网/云元数据(SSRF) - resp, err := utils.SafeHTTPClient().Post(f.WebhookURL, "application/json", bytes.NewBuffer(payload)) + resp, err := utils.SafeOutboundHTTPClient(30 * time.Second).Post(f.WebhookURL, "application/json", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("发送HTTP请求失败: %v", err) } diff --git a/wafnotify/webhook/webhook.go b/wafnotify/webhook/webhook.go index d3fcf3d6..8e79bbbf 100644 --- a/wafnotify/webhook/webhook.go +++ b/wafnotify/webhook/webhook.go @@ -12,6 +12,7 @@ import ( "strings" "text/template" "text/template/parse" + "time" ) /* @@ -24,8 +25,10 @@ ntfy、Gotify、内部工单系统…)报文格式各不相同,所以这里 三条硬约束: - 1. 用户可控的对外地址一律走 IsSafeOutboundURL + SafeHTTPClient,跳转链上每一跳都重新校验, - 否则这就是一个现成的 SSRF 打内网的入口。 + 1. 用户可控的对外地址一律走 IsAllowedOutboundURL + SafeOutboundHTTPClient,跳转链上每一跳 + 都重新校验、连接期按真实解析 IP 再判一次,否则这就是一个现成的 SSRF 打内网的入口。 + 默认只允许公网目标;内网告警平台(自建 ntfy/Gotify 等)须由运营方在 config.yml 的 + security.outbound_allowed_hosts 带外声明(issue #990)。 2. 自定义请求头是拼进 HTTP 报文的原始数据:头名必须是 RFC token,头值不得含 CR/LF, 并且禁止覆盖 Host/Content-Length/Transfer-Encoding 这类由传输层决定的头(请求走私)。 3. 模板变量(Title/Content)里含攻击者可控内容,塞进 JSON 报文前必须按 Content-Type 转义, @@ -214,11 +217,15 @@ func (c *Config) normalize() { } // Validate 配置校验(新增/编辑保存时与发送时都会调用) +// +// 地址用 PrecheckOutboundURL 而非严格版:保存配置的时刻内网 DNS 可能还没通, +// 因一次临时解析失败就不让人保存是过度拦截;真正的边界在 Send() 里的 +// IsAllowedOutboundURL + SafeOutboundHTTPClient(fail-closed)。 func (c *Config) Validate() error { if c.URL == "" { return errors.New("Webhook 地址不能为空") } - if ok, reason := utils.IsSafeOutboundURL(c.URL); !ok { + if ok, reason := utils.PrecheckOutboundURL(c.URL); !ok { return fmt.Errorf("Webhook 地址不被允许: %s", reason) } if !allowedMethods[c.Method] { @@ -393,7 +400,7 @@ func (w *WebhookNotifier) SendMarkdown(title, content string) error { // Send 渲染报文并投递 func (w *WebhookNotifier) Send(msg Message) error { // 配置可能是很久以前存下的(甚至被直接改过库),发送前重新校验一次目标地址 - if ok, reason := utils.IsSafeOutboundURL(w.Config.URL); !ok { + if ok, reason := utils.IsAllowedOutboundURL(w.Config.URL); !ok { return fmt.Errorf("Webhook 地址不被允许: %s", reason) } @@ -419,7 +426,7 @@ func (w *WebhookNotifier) Send(msg Message) error { req.Header.Set(h.Key, h.Value) } - resp, err := utils.SafeHTTPClient().Do(req) + resp, err := utils.SafeOutboundHTTPClient(30 * time.Second).Do(req) if err != nil { return fmt.Errorf("发送HTTP请求失败: %v", err) } diff --git a/wafnotify/webhook/webhook_test.go b/wafnotify/webhook/webhook_test.go index f2be438a..cd8914b7 100644 --- a/wafnotify/webhook/webhook_test.go +++ b/wafnotify/webhook/webhook_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "strings" "testing" + + "SamWaf/global" ) -// 说明:Send() 会强制走 IsSafeOutboundURL,httptest 起在 127.0.0.1 上必然被拒, +// 说明:Send() 会强制走 IsAllowedOutboundURL,httptest 起在 127.0.0.1 上必然被拒, // 所以这里只测「配置校验」和「报文渲染」两段纯逻辑,真实投递用管理端的"测试"按钮验证。 func mustConfig(t *testing.T, cfg Config) *WebhookNotifier { @@ -254,3 +256,38 @@ func TestValidateCatchesInvalidJSONAtSaveTime(t *testing.T) { t.Fatalf("期望保存时就报非法 JSON,实际: %v", err) } } + +// issue #990:内网告警平台(自建 ntfy/Gotify 等)经 config.yml 的 +// security.outbound_allowed_hosts 带外声明后必须能保存;未声明的内网字面量仍拒。 +func TestValidateAllowlistedIntranetHost(t *testing.T) { + old := global.GCONFIG_OUTBOUND_ALLOWED_HOSTS + t.Cleanup(func() { global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = old }) + + cfg := Config{URL: "http://192.168.10.8:8080/hook", Method: "POST"} + cfg.normalize() + + global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = "" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "不被允许") { + t.Fatalf("未声明时内网地址应被拒,实际: %v", err) + } + + global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = "192.168.10.0/24" + if err := cfg.Validate(); err != nil { + t.Fatalf("已声明网段内的地址应放行,实际被拒: %v", err) + } + + // 声明的网段之外仍拒(清单不泛化) + cfg2 := Config{URL: "http://192.168.20.8/hook", Method: "POST"} + cfg2.normalize() + if err := cfg2.Validate(); err == nil { + t.Fatal("清单外内网地址必须仍被拒") + } + + // 主机名精确匹配 + global.GCONFIG_OUTBOUND_ALLOWED_HOSTS = "notify.intranet.lan" + cfg3 := Config{URL: "http://notify.intranet.lan/hook", Method: "POST"} + cfg3.normalize() + if err := cfg3.Validate(); err != nil { + t.Fatalf("已声明主机名应放行(保存侧不做解析),实际被拒: %v", err) + } +} diff --git a/wafnotify/wechatwork/wechatwork.go b/wafnotify/wechatwork/wechatwork.go index 4916cd38..927b728f 100644 --- a/wafnotify/wechatwork/wechatwork.go +++ b/wafnotify/wechatwork/wechatwork.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "time" ) // WechatWorkNotifier 企业微信通知器 @@ -59,7 +60,7 @@ func (w *WechatWorkNotifier) send(message WechatWorkMessage) error { // 发送HTTP请求 // N5 - resp, err := utils.SafeHTTPClient().Post(w.WebhookURL, "application/json", bytes.NewBuffer(payload)) + resp, err := utils.SafeOutboundHTTPClient(30 * time.Second).Post(w.WebhookURL, "application/json", bytes.NewBuffer(payload)) if err != nil { return fmt.Errorf("发送HTTP请求失败: %v", err) } diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index a761b1ae..e96fec05 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -467,3 +467,18 @@ notes: effect_on: Before you need it, open the website editor - Human verification and make sure "Never challenge these paths" already lists your App/API endpoints, so hitting the button does not lock those clients out. When enabling it, pick an auto turn-off time - forgetting about it costs your real visitors. effect_off: Leaving it alone has no effect at all. It is off by default and only applies when you turn it on yourself. revert: Turn it off in the same dialog; the website immediately returns to its previous behaviour. + + - id: v1_3_25_notify_webhook_intranet + version: v1.3.25 + kind: notice + level: normal + page: /notify/channel + doc: https://doc.samwaf.com/guide/NotifyChannel.html + apply: + type: navigate + zh: + title: 通知渠道 Webhook 支持内网告警平台,需在 config.yml 声明放行 + detail: 通知渠道(钉钉/飞书/企业微信/自定义 Webhook)的目标地址默认仍只允许公网;自建在内网的告警平台(如 ntfy、Gotify、内部工单系统)此前会被直接拒绝,现在可在 conf/config.yml 的 security.outbound_allowed_hosts 里按主机名、IP 或网段(逗号分隔)声明放行,保存并重启后该目标即可保存与发送。该文件只能由能改服务器配置的人维护,管理端界面与 API 不提供修改入口。不配置则行为与之前完全一致。 + en: + title: Notification channel Webhooks can now reach intranet alerting platforms when declared in config.yml + detail: Notification channel targets (DingTalk, Feishu, WeCom, custom Webhook) still default to public addresses only. Self-hosted alerting platforms on the intranet (such as ntfy, Gotify or an internal ticketing system) were previously rejected outright; they can now be allowed by declaring the host name, IP or CIDR (comma separated) in security.outbound_allowed_hosts in conf/config.yml, then saving and restarting. That file can only be maintained by someone with access to the server configuration - the console UI and API offer no way to change it. Without this setting, behavior is exactly as before.