mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 18:21:51 +08:00
fix: honor security.outbound_allowed_hosts for notification channel webhooks
#990
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 的区别:域名解析失败时**放行**。保存配置的时刻网络可能还没通、
|
||||
|
||||
@@ -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("解析结果不在清单内的主机名必须仍被拒")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user