对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。 修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被 markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种 语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。 转义必须发生在各自的渲染出口——测试直接抓到了这个回归。 【中】投递地址的 SSRF 面 此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递; 而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读 原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机 重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机 SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost / SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。 【中】汇总消息被截断后整批标记已送达 = 静默丢失 渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批 标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示 成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部 如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。 【中】其余修复 - min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真, 用户以为「仅高危」实际全推)。改为写入时校验取值。 - rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独 写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。 - digest 渠道完全绕过令牌桶(allow 被扣却没人用)。 - 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。 - 汇总批次里快照无法解析的投递被静默标记成功。 - url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧; 且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。 - 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移 断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。 - Telegram 截断可能切断 HTML 实体,导致整条被拒收。 - 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。 - 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。 测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的 输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx 分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。 Co-Authored-By: Claude Code <[email protected]>
211 lines
7.9 KiB
Go
211 lines
7.9 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"mime"
|
||
"net"
|
||
"net/smtp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// emailDialTimeout / emailSessionTimeout 分别约束建连与整段 SMTP 会话。
|
||
// net/smtp 自身没有任何超时机制,不设这两道的话,一个卡住的对端会让
|
||
// 投递 goroutine 永久挂在那里——而 dispatcher 是单 goroutine 串行处理的,
|
||
// 等于整个通知系统停摆。
|
||
const (
|
||
emailDialTimeout = 10 * time.Second
|
||
emailSessionTimeout = 45 * time.Second
|
||
)
|
||
|
||
// emailChannel 实现 SMTP 邮件投递。
|
||
type emailChannel struct{}
|
||
|
||
func (emailChannel) Kind() string { return KindEmail }
|
||
|
||
// 邮件没有平台限流,但不该用它刷屏;给一个宽松的默认值。
|
||
func (emailChannel) DefaultRatePerMin() int { return 60 }
|
||
|
||
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
|
||
func (emailChannel) SecretKeys() []string { return []string{"password"} }
|
||
|
||
// host/port 决定把密码交给哪台服务器;tls 决定是否加密传输。三者任一变化都
|
||
// 要求重新表态密码——顺带让「关掉 TLS」这一步必须显式带上凭据,而不是顺手一改。
|
||
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
|
||
|
||
func (emailChannel) Validate(cfg map[string]any) error {
|
||
if cfgString(cfg, "host") == "" {
|
||
return errors.New("缺少 SMTP 服务器地址")
|
||
}
|
||
port := cfgInt(cfg, "port")
|
||
if port <= 0 || port > 65535 {
|
||
return errors.New("SMTP 端口无效(应为 1-65535)")
|
||
}
|
||
if cfgString(cfg, "from") == "" {
|
||
return errors.New("缺少发件人地址")
|
||
}
|
||
if len(cfgStrings(cfg, "to")) == 0 {
|
||
return errors.New("至少需要一个收件人地址")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||
if err := c.Validate(cfg); err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
host := cfgString(cfg, "host")
|
||
port := cfgInt(cfg, "port")
|
||
from := cfgString(cfg, "from")
|
||
to := cfgStrings(cfg, "to")
|
||
username := cfgString(cfg, "username")
|
||
password := cfgString(cfg, "password")
|
||
implicitTLS := cfgBool(cfg, "tls")
|
||
|
||
msg, err := buildEmailMessage(from, to, m)
|
||
if err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
|
||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||
client, err := emailDial(ctx, addr, host, implicitTLS)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer client.Close()
|
||
|
||
// STARTTLS:对端支持就升级。明文会话下不能发凭据(见下面的 auth 说明)。
|
||
if !implicitTLS {
|
||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||
return 0, fmt.Errorf("STARTTLS 失败: %w", err)
|
||
}
|
||
}
|
||
}
|
||
if username != "" {
|
||
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
|
||
// smtp.PlainAuth 会拒绝在未加密连接上发送凭据(除非目标是 localhost)。
|
||
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
|
||
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
|
||
if strings.Contains(err.Error(), "unencrypted connection") {
|
||
return 0, Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err))
|
||
}
|
||
return 0, Permanent(fmt.Errorf("SMTP 认证失败: %w", err))
|
||
}
|
||
}
|
||
if err := client.Mail(from); err != nil {
|
||
return 0, smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err)
|
||
}
|
||
for _, rcpt := range to {
|
||
if err := client.Rcpt(rcpt); err != nil {
|
||
return 0, smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err)
|
||
}
|
||
}
|
||
w, err := client.Data()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("SMTP DATA 失败: %w", err)
|
||
}
|
||
if _, err := w.Write([]byte(msg)); err != nil {
|
||
return 0, fmt.Errorf("写入邮件正文失败: %w", err)
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
return 0, fmt.Errorf("提交邮件失败: %w", err)
|
||
}
|
||
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
|
||
_ = client.Quit()
|
||
// 邮件没有长度截断(HTML 正文全部发送),整批都算送达。
|
||
return len(m.Items), nil
|
||
}
|
||
|
||
// emailDial 建立 SMTP 连接。
|
||
//
|
||
// implicitTLS=true 走 465 这类「连上即 TLS」的方式;false 走 25/587 明文建连后再
|
||
// STARTTLS。两者不能混:对 465 端口发明文 greeting 会被直接断开。
|
||
//
|
||
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
|
||
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
|
||
// 兜底。这也顺带覆盖了握手阶段的阻塞。
|
||
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
|
||
d := &net.Dialer{Timeout: emailDialTimeout}
|
||
var conn net.Conn
|
||
var err error
|
||
if implicitTLS {
|
||
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||
} else {
|
||
conn, err = d.DialContext(ctx, "tcp", addr)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("连接 SMTP 服务器失败: %w", err)
|
||
}
|
||
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
|
||
client, err := smtp.NewClient(conn, host)
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("SMTP 握手失败: %w", err)
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
// smtpStageError 按 SMTP 应答码把某个阶段的失败分成「可重试」与「永久失败」。
|
||
//
|
||
// 为什么必须区分:SMTP 的 4xx 与 5xx 语义完全不同——
|
||
// - 4xx(450 灰名单、451 本地错误、452 存储不足)是**临时**拒绝,
|
||
// 正规做法是稍后重试;尤其是灰名单,几乎每次首次投递都会遇到。
|
||
// - 5xx(550 用户不存在、553 地址非法)是永久拒绝,重试没有意义。
|
||
//
|
||
// 若一律判永久失败,一个启用灰名单的邮件服务器会让**每一条**推送都在第一次
|
||
// 尝试后落入 failed——而这类失败恰恰是自动重试最该发挥作用的场景。
|
||
// 应答码取错误文本的前三位数字;取不到码时按可重试处理(宁可多试一次,
|
||
// 也不要因为解析不出就把可能的瞬时故障判死)。
|
||
func smtpStageError(what string, err error) error {
|
||
code := smtpReplyCode(err.Error())
|
||
if code >= 500 && code < 600 {
|
||
return Permanent(fmt.Errorf("%s: %w", what, err))
|
||
}
|
||
return fmt.Errorf("%s: %w", what, err)
|
||
}
|
||
|
||
// smtpReplyCode 从 SMTP 错误文本里取前导的三位应答码,取不到返回 0。
|
||
// net/smtp 不导出错误码字段,只能从文本里取;格式为「450 4.7.1 ...」。
|
||
func smtpReplyCode(text string) int {
|
||
if len(text) < 3 {
|
||
return 0
|
||
}
|
||
n, err := strconv.Atoi(text[:3])
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
// buildEmailMessage 组装完整的 RFC 5322 邮件。
|
||
//
|
||
// 正文用 base64 编码有两个原因:一是 SMTP 规定单行不超过 1000 字节,而 HTML
|
||
// 正文(尤其汇总邮件)很容易出现超长行;二是 base64 天然不会出现以 "." 开头
|
||
// 的行,省去 SMTP 点号转义的麻烦。
|
||
func buildEmailMessage(from string, to []string, m Message) (string, error) {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
|
||
// 中文主题必须做 RFC 2047 编码,否则会被客户端显示成乱码。
|
||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
|
||
b.WriteString("MIME-Version: 1.0\r\n")
|
||
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||
// 邮件没有长度硬上限,因此不截断正文。
|
||
b.WriteString("\r\n")
|
||
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
|
||
// base64 按 76 字符折行,符合 RFC 2045。
|
||
for len(encoded) > 76 {
|
||
b.WriteString(encoded[:76] + "\r\n")
|
||
encoded = encoded[76:]
|
||
}
|
||
b.WriteString(encoded + "\r\n")
|
||
return b.String(), nil
|
||
}
|