fix(security): 告警渠道 webhook 与 Bark Device Key 响应脱敏

AlertNotifyChannel.Config 原先以真值返回。钉钉/飞书/企微的 webhook URL 与 Bark
的 device key 本身就是凭据,泄露即可直接向对应群或设备推送消息,而列表接口对
具备 recharge:manage 的商户成员可见。

- 新增 service.MaskAlertChannel(s):在 HTTP 响应层脱敏,只保留密钥末 4 位。
  webhook 保留域名与路径以便辨认是哪一台机器人(钉钉/企微的 token 在查询串,
  飞书的 uuid 在路径末段),Server 属主机地址不遮蔽
- UpdateAlertChannel 识别回传的脱敏占位值并沿用库中真实密钥,使「只改名称/开关」
  不会被掩码覆盖;填写新值则正常替换
- 脱敏刻意不放在 service 层:CheckLowBalanceAndNotify 复用 ListAlertChannels
  的结果真正去发消息,在那一层脱敏会直接打断告警发送
- 前端表单补充说明文案

验证:新增 6 组测试,覆盖脱敏规则、三种机器人 URL 形态、脱敏结果仍能通过校验、
掩码回传不覆盖真实密钥,以及「service 层必须返回真值」这条关键边界。
This commit is contained in:
yml2213
2026-10-06 19:49:11 +08:00
parent 14d297a591
commit 7f984b0f4e
4 changed files with 342 additions and 7 deletions
+3 -3
View File
@@ -147,7 +147,7 @@ func (h *RechargeHandler) ListAlertChannels(c *gin.Context) {
response.ServerError(c, err.Error())
return
}
response.OK(c, list)
response.OK(c, service.MaskAlertChannels(list))
}
type alertChannelReq struct {
@@ -188,7 +188,7 @@ func (h *RechargeHandler) CreateAlertChannel(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
response.OK(c, channel)
response.OK(c, service.MaskAlertChannel(*channel))
}
// UpdateAlertChannel PUT /api/merchant/alert-channels/:id
@@ -208,7 +208,7 @@ func (h *RechargeHandler) UpdateAlertChannel(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
response.OK(c, channel)
response.OK(c, service.MaskAlertChannel(*channel))
}
// DeleteAlertChannel DELETE /api/merchant/alert-channels/:id
+103 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -261,6 +262,97 @@ func ValidateAlertChannelInput(in AlertChannelInput) error {
return nil
}
// 告警渠道密钥脱敏。
//
// 钉钉/飞书/企微的 webhook URL 与 Bark 的 device key 本身就是凭据,泄露即可直接
// 向对应群或设备推送消息。而列表接口会把渠道配置返回给具备 recharge:manage 的
// 商户成员,因此响应里只保留尾部 4 位用于辨认。
//
// 注意:脱敏只发生在 HTTP 响应层。ListAlertChannels 等 service 方法仍返回真实值,
// 因为余额巡检要拿它们真正去发消息——在 service 层脱敏会直接打断告警发送。
const alertSecretMask = "********"
// MaskAlertSecret 保留尾部 4 位,其余用掩码覆盖;空值原样返回。
func MaskAlertSecret(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return ""
}
if len(secret) <= 4 {
return alertSecretMask
}
return alertSecretMask + secret[len(secret)-4:]
}
// IsMaskedAlertSecret 判断前端回传的值是否仍是脱敏占位值。
// 用 Contains 而不是 HasPrefix:webhook 的掩码嵌在 URL 中间
// (https://oapi.dingtalk.com/robot/send?access_token=********1234),只有 Bark 的
// Key 才是整体掩码。真实密钥里出现连续 8 个星号实际上不可能。
func IsMaskedAlertSecret(value string) bool {
return strings.Contains(value, alertSecretMask)
}
// maskAlertWebhookURL 只遮蔽 URL 中的密钥片段,保留域名与路径,让编辑表单仍能看出
// 这是哪一个机器人;整体替换为占位值会让用户失去上下文。
//
// 全程在原始字符串上做替换,不走 url.URL.String():后者会把路径里的 * 转义成 %2A,
// 让掩码变成 https://.../hook/%2A%2A%2A%2A%2A%2A%2A%2A5555 这种没法看的形式。
func maskAlertWebhookURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return MaskAlertSecret(raw)
}
masked := raw
// 查询串里逐个 value 遮蔽(钉钉 access_token、企业微信 key)。
if idx := strings.Index(masked, "?"); idx >= 0 {
head, query := masked[:idx+1], masked[idx+1:]
parts := strings.Split(query, "&")
for i, part := range parts {
if kv := strings.SplitN(part, "=", 2); len(kv) == 2 {
parts[i] = kv[0] + "=" + MaskAlertSecret(kv[1])
}
}
masked = head + strings.Join(parts, "&")
} else {
// 无查询串时遮蔽路径末段(飞书 /open-apis/bot/v2/hook/<uuid>)。
// 只在末段足够长时遮蔽,避免误伤 /robot/send 这类固定路径。
segments := strings.Split(strings.TrimRight(parsed.Path, "/"), "/")
if last := len(segments) - 1; last >= 0 && len(segments[last]) >= 16 {
segment := segments[last]
if at := strings.LastIndex(masked, segment); at >= 0 {
masked = masked[:at] + MaskAlertSecret(segment) + masked[at+len(segment):]
}
}
}
return masked
}
// MaskAlertChannel 返回用于接口响应的脱敏副本。Server 是主机地址而非密钥,保持原样。
func MaskAlertChannel(channel model.AlertNotifyChannel) model.AlertNotifyChannel {
channel.Config.WebhookURL = maskAlertWebhookURL(channel.Config.WebhookURL)
channel.Config.Key = MaskAlertSecret(channel.Config.Key)
return channel
}
// MaskAlertChannels 批量脱敏;nil 保持 nil,便于调用方直接替换响应体。
func MaskAlertChannels(channels []model.AlertNotifyChannel) []model.AlertNotifyChannel {
if channels == nil {
return nil
}
masked := make([]model.AlertNotifyChannel, 0, len(channels))
for _, channel := range channels {
masked = append(masked, MaskAlertChannel(channel))
}
return masked
}
func (s *RechargeService) ListAlertChannels(merchantID uint) ([]model.AlertNotifyChannel, error) {
var list []model.AlertNotifyChannel
err := s.db.Where("merchant_id = ?", merchantID).Order("id ASC").Find(&list).Error
@@ -288,9 +380,6 @@ func (s *RechargeService) CreateAlertChannel(merchantID uint, in AlertChannelInp
}
func (s *RechargeService) UpdateAlertChannel(merchantID, channelID uint, in AlertChannelInput) (*model.AlertNotifyChannel, error) {
if err := ValidateAlertChannelInput(in); err != nil {
return nil, err
}
if len(strings.TrimSpace(in.Name)) > 64 {
return nil, errors.New("渠道名称最长 64 个字符")
}
@@ -298,6 +387,17 @@ func (s *RechargeService) UpdateAlertChannel(merchantID, channelID uint, in Aler
if err := s.db.Where("id = ? AND merchant_id = ?", channelID, merchantID).First(&channel).Error; err != nil {
return nil, errors.New("通知渠道不存在")
}
// 前端拿到的是脱敏值。原样回传说明用户只改了名称或开关,沿用库中真实密钥;
// 这一步必须在校验之前,否则占位值会让「只改名称」的提交直接校验失败。
if IsMaskedAlertSecret(in.Config.WebhookURL) {
in.Config.WebhookURL = channel.Config.WebhookURL
}
if IsMaskedAlertSecret(in.Config.Key) {
in.Config.Key = channel.Config.Key
}
if err := ValidateAlertChannelInput(in); err != nil {
return nil, err
}
channel.ChannelType = strings.TrimSpace(in.ChannelType)
channel.Name = strings.TrimSpace(in.Name)
channel.Config = in.Config
@@ -0,0 +1,229 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestMaskAlertSecret(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"空值原样返回", "", ""},
{"纯空白原样返回", " ", ""},
{"长度不足 4 位全部遮蔽", "abcd", alertSecretMask},
{"正好 5 位保留末 4 位", "abcde", alertSecretMask + "bcde"},
{"长密钥保留末 4 位", "access_token_value_1234", alertSecretMask + "1234"},
{"前后空白被裁剪", " secret1234 ", alertSecretMask + "1234"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MaskAlertSecret(tc.input)
if got != tc.want {
t.Fatalf("期望 %q,实际 %q", tc.want, got)
}
})
}
}
func TestIsMaskedAlertSecret(t *testing.T) {
if !IsMaskedAlertSecret(alertSecretMask + "1234") {
t.Fatal("整体掩码(Bark Key)应被识别")
}
if !IsMaskedAlertSecret("https://oapi.dingtalk.com/robot/send?access_token=" + alertSecretMask + "1234") {
t.Fatal("嵌在 URL 中间的掩码应被识别")
}
if IsMaskedAlertSecret("real-secret-1234") {
t.Fatal("真实密钥不应被误判为脱敏值")
}
if IsMaskedAlertSecret("") {
t.Fatal("空值不应被当作脱敏值")
}
}
// TestMaskAlertWebhookURL 覆盖三种真实机器人的 URL 形态:
// 钉钉/企微把 token 放在查询串,飞书放在路径末段。
func TestMaskAlertWebhookURL(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "钉钉查询串保留域名与路径",
input: "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456",
want: "https://oapi.dingtalk.com/robot/send?access_token=" + alertSecretMask + "3456",
},
{
name: "企业微信查询串",
input: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=aaaa-bbbb-cccc-dddd",
want: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=" + alertSecretMask + "dddd",
},
{
name: "飞书路径末段 uuid 被遮蔽",
input: "https://open.feishu.cn/open-apis/bot/v2/hook/11111111-2222-3333-4444-555555555555",
want: "https://open.feishu.cn/open-apis/bot/v2/hook/" + alertSecretMask + "5555",
},
{
name: "短路径末段不误伤(钉钉 send)",
input: "https://oapi.dingtalk.com/robot/send",
want: "https://oapi.dingtalk.com/robot/send",
},
{
name: "空值原样返回",
input: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := maskAlertWebhookURL(tc.input)
if got != tc.want {
t.Fatalf("期望 %q,实际 %q", tc.want, got)
}
if strings.Contains(got, "%2A") {
t.Fatalf("掩码里的 * 不应被百分号转义:%q", got)
}
})
}
}
// TestMaskAlertWebhookURLKeepsURLValid 脱敏结果必须仍是合法 http(s) URL,
// 否则前端原样回传时会撞上 ValidateAlertChannelInput。
func TestMaskAlertWebhookURLKeepsURLValid(t *testing.T) {
urls := []string{
"https://oapi.dingtalk.com/robot/send?access_token=abcdef123456",
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=aaaa-bbbb-cccc-dddd",
"https://open.feishu.cn/open-apis/bot/v2/hook/11111111-2222-3333-4444-555555555555",
}
for _, raw := range urls {
masked := maskAlertWebhookURL(raw)
if err := ValidateAlertChannelInput(AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Config: model.AlertChannelConfig{WebhookURL: masked},
}); err != nil {
t.Fatalf("脱敏后的 URL 未通过校验:%q -> %q (%v)", raw, masked, err)
}
}
}
func TestMaskAlertChannelKeepsServer(t *testing.T) {
channel := model.AlertNotifyChannel{
ChannelType: model.AlertChannelBark,
Name: "老板手机",
Config: model.AlertChannelConfig{
Server: "https://api.day.app",
Key: "device-key-9876",
},
}
masked := MaskAlertChannel(channel)
if masked.Config.Server != "https://api.day.app" {
t.Fatalf("Server 是主机地址而非密钥,不应被遮蔽,实际 %q", masked.Config.Server)
}
if masked.Config.Key != alertSecretMask+"9876" {
t.Fatalf("Key 应被遮蔽,实际 %q", masked.Config.Key)
}
if channel.Config.Key != "device-key-9876" {
t.Fatal("MaskAlertChannel 不应修改入参")
}
}
func TestMaskAlertChannelsNil(t *testing.T) {
if MaskAlertChannels(nil) != nil {
t.Fatal("nil 输入应返回 nil,便于直接替换响应体")
}
}
// TestUpdateAlertChannelWithMaskedSecret 验证脱敏值回传时不会把真实密钥覆盖成掩码。
func TestUpdateAlertChannelWithMaskedSecret(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-mask", 0, -1, 100)
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
const realWebhook = "https://oapi.dingtalk.com/robot/send?access_token=realtoken1234"
created, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: realWebhook},
})
if err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
// 模拟前端:拿到脱敏值后只改名称,原样回传 webhook。
maskedForm := MaskAlertChannel(*created)
if maskedForm.Config.WebhookURL == realWebhook {
t.Fatal("响应中的 webhook 应已脱敏")
}
updated, err := svc.UpdateAlertChannel(merchantID, created.ID, AlertChannelInput{
ChannelType: maskedForm.ChannelType,
Name: "财务群(改名)",
Enabled: false,
Config: maskedForm.Config,
})
if err != nil {
t.Fatalf("以脱敏值更新应被接受: %v", err)
}
if updated.Name != "财务群(改名)" || updated.Enabled {
t.Fatalf("名称/开关应被更新,实际 %+v", updated)
}
if updated.Config.WebhookURL != realWebhook {
t.Fatalf("真实密钥被掩码覆盖了:%q", updated.Config.WebhookURL)
}
// 重新读取,确认落库值仍是真实密钥(否则告警会发不出去)。
var stored model.AlertNotifyChannel
if err := db.First(&stored, created.ID).Error; err != nil {
t.Fatalf("读取渠道失败: %v", err)
}
if stored.Config.WebhookURL != realWebhook {
t.Fatalf("库中密钥被掩码覆盖:%q", stored.Config.WebhookURL)
}
// 用户主动填写新密钥时,必须真正替换。
const newWebhook = "https://oapi.dingtalk.com/robot/send?access_token=newtoken5678"
replaced, err := svc.UpdateAlertChannel(merchantID, created.ID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群(改名)",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: newWebhook},
})
if err != nil {
t.Fatalf("更新新密钥失败: %v", err)
}
if replaced.Config.WebhookURL != newWebhook {
t.Fatalf("新密钥未被写入:%q", replaced.Config.WebhookURL)
}
}
// TestAlertChannelsServiceStillReturnsRawSecret 卡住一个关键边界:
// service 层不能脱敏,否则余额巡检拿不到真实 webhook 就发不出告警。
func TestAlertChannelsServiceStillReturnsRawSecret(t *testing.T) {
db := newServiceTestDB(t)
merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-raw", 0, -1, 100)
svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir())
const realWebhook = "https://oapi.dingtalk.com/robot/send?access_token=rawtoken9999"
if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{
ChannelType: model.AlertChannelDingtalk,
Name: "财务群",
Enabled: true,
Config: model.AlertChannelConfig{WebhookURL: realWebhook},
}); err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
list, err := svc.ListAlertChannels(merchantID)
if err != nil || len(list) != 1 {
t.Fatalf("期望 1 个渠道,实际 %d,err=%v", len(list), err)
}
if list[0].Config.WebhookURL != realWebhook {
t.Fatalf("service 层必须返回真实密钥(供发送告警使用),实际 %q", list[0].Config.WebhookURL)
}
}
+7 -1
View File
@@ -416,7 +416,12 @@ export default function AlertSettings() {
<Form.Item name="server" label="Bark 服务器地址(可选)">
<Input placeholder="https://api.day.app(默认官方服务器)" />
</Form.Item>
<Form.Item name="key" label="Bark 设备 Key" rules={[{ required: true, message: '请填写 Bark 设备 Key' }]}>
<Form.Item
name="key"
label="Bark 设备 Key"
rules={[{ required: true, message: '请填写 Bark 设备 Key' }]}
extra="出于安全考虑,密钥只显示末 4 位;保持原样即不修改,填入新值则替换。"
>
<Input placeholder="安装 Bark 后生成的设备 Key" />
</Form.Item>
</>
@@ -425,6 +430,7 @@ export default function AlertSettings() {
name="webhook_url"
label="Webhook URL"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
extra="出于安全考虑,密钥只显示末 4 位;保持原样即不修改,填入新值则替换。"
>
<Input placeholder={channelOptions.find((item) => item.value === channelType)?.placeholder} />
</Form.Item>