From 7f984b0f4eb5ab3f6344bdc06f4b8af6404d2f34 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:49:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E5=91=8A=E8=AD=A6=E6=B8=A0?= =?UTF-8?q?=E9=81=93=20webhook=20=E4=B8=8E=20Bark=20Device=20Key=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 层必须返回真值」这条关键边界。 --- backend/internal/handler/recharge.go | 6 +- backend/internal/service/recharge.go | 106 +++++++- .../internal/service/recharge_mask_test.go | 229 ++++++++++++++++++ frontend/src/pages/AlertSettings.tsx | 8 +- 4 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 backend/internal/service/recharge_mask_test.go diff --git a/backend/internal/handler/recharge.go b/backend/internal/handler/recharge.go index d332dc0..1eded1b 100644 --- a/backend/internal/handler/recharge.go +++ b/backend/internal/handler/recharge.go @@ -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 diff --git a/backend/internal/service/recharge.go b/backend/internal/service/recharge.go index 8928f49..26f90d7 100644 --- a/backend/internal/service/recharge.go +++ b/backend/internal/service/recharge.go @@ -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/)。 + // 只在末段足够长时遮蔽,避免误伤 /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 diff --git a/backend/internal/service/recharge_mask_test.go b/backend/internal/service/recharge_mask_test.go new file mode 100644 index 0000000..ce232a0 --- /dev/null +++ b/backend/internal/service/recharge_mask_test.go @@ -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) + } +} diff --git a/frontend/src/pages/AlertSettings.tsx b/frontend/src/pages/AlertSettings.tsx index 1eb948a..b747e0d 100644 --- a/frontend/src/pages/AlertSettings.tsx +++ b/frontend/src/pages/AlertSettings.tsx @@ -416,7 +416,12 @@ export default function AlertSettings() { - + @@ -425,6 +430,7 @@ export default function AlertSettings() { name="webhook_url" label="Webhook URL" rules={[{ required: true, message: '请填写 Webhook URL' }]} + extra="出于安全考虑,密钥只显示末 4 位;保持原样即不修改,填入新值则替换。" > item.value === channelType)?.placeholder} />