fix: honor security.outbound_allowed_hosts for notification channel webhooks

#990
This commit is contained in:
samwaf
2026-09-04 13:49:04 +08:00
parent 0c2cb122e4
commit dbe30c74cf
12 changed files with 119 additions and 16 deletions
+1 -1
View File
@@ -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)
}
}
+3 -3
View File
@@ -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)
+2 -1
View File
@@ -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 {
+21
View File
@@ -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 的区别:域名解析失败时**放行**。保存配置的时刻网络可能还没通、
+21
View File
@@ -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("解析结果不在清单内的主机名必须仍被拒")
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+12 -5
View File
@@ -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)
}
+38 -1
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
}
+15
View File
@@ -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.