Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion service/waf_service/waf_notify_channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
6 changes: 3 additions & 3 deletions service/waf_service/waf_notify_sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion utils/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions utils/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 的区别:域名解析失败时**放行**。保存配置的时刻网络可能还没通、
Expand Down
21 changes: 21 additions & 0 deletions utils/outbound_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("解析结果不在清单内的主机名必须仍被拒")
}
}
4 changes: 2 additions & 2 deletions wafconfig/wafconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion wafnotify/dingtalk/dingtalk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion wafnotify/feishu/feishu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 12 additions & 5 deletions wafnotify/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"text/template"
"text/template/parse"
"time"
)

/*
Expand All @@ -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 转义,
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}
Expand Down
39 changes: 38 additions & 1 deletion wafnotify/webhook/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
3 changes: 2 additions & 1 deletion wafnotify/wechatwork/wechatwork.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"time"
)

// WechatWorkNotifier 企业微信通知器
Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions wafupgradenotice/upgrade_notes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading