fix(notify): 目的地字段留空不应被判成地址变更

Telegram 渠道从第二次保存起会永久失败,用户什么都没改也存不上:

  新建时 base_url 留空(用官方 API 地址),创建路径直接存下 base_url:""
  → 第一次保存,MergeConfig 把空串当显式清空、delete 掉该键
  → 第二次保存,前端照常提交 base_url:"",而 stored 里已经没这个键
  → sameConfigValue("", nil) 比较 `""` 与 `null`,判为不等 → changed=[base_url]
  → bot_token 是掩码回显值 → 400「目标地址已变更,请同时重新填写凭据字段」

此后每次保存都失败,除非重新粘贴一遍 Bot Token,而错误提示还把人
往「凭据有问题」上引。

根因是 MergeConfig 与 sameConfigValue 对「空」的口径不一致:前者认为
空串就是「该删掉这个键」,后者认为 "" 与键不存在是两个不同的值。
现在比较前先归一化,口径与 MergeConfig 的清空判定对齐。

这是上一轮修「改目标地址、保留凭据」那条凭据外发路径时引入的回归——
守卫本身是对的,只是把一个没变的空字段误判成了地址变更。配套用例覆盖了
两个方向的真实变更(空→自建反代、自建反代→空)仍必须要求重填 Token,
确认防护没有被削弱。
This commit is contained in:
Autumn-27 committed 2026-10-01 06:43:48 -04:00
1 parent 93c1d39df3
commit 77834f0080
2 files changed
+95 -1

No files matched your search

+23 -1
View File
@@ -183,8 +183,19 @@ func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) erro
// sameConfigValue 比较两个配置值是否等价。用 JSON 序列化比较是为了顺带处理
// 类型差异——前端提交的端口是 number,而库里读回来的是 float64,直接 == 会误判。
//
// 「空」必须先归一化再比较:空串与「键不存在」在这个配置模型里是同一个状态,
// 因为 MergeConfig 把空串当显式清空、直接 delete 掉该键。不归一化的话,一个
// 始终留空的可选目的地字段(Telegram 的 base_url 是唯一这样的字段:留空即用
// 官方地址)会走成这条路径——
//
// 新建时存下 base_url:"" → 第一次保存被 MergeConfig 删键
// → 第二次保存时 incoming 是 ""、stored 缺键,被判成「地址变了」
// → 凭据是掩码值 → 400「目标地址已变更,请同时重新填写凭据字段」
//
// 此后每次保存都失败,除非用户重新粘贴一遍 Bot Token,而他什么都没改。
func sameConfigValue(a, b any) bool {
if a == nil && b == nil {
if isBlankConfigValue(a) && isBlankConfigValue(b) {
return true
}
ra, errA := json.Marshal(a)
@@ -195,6 +206,17 @@ func sameConfigValue(a, b any) bool {
return string(ra) == string(rb)
}
// isBlankConfigValue 判定一个配置值是否为「空」。
// 口径必须与 MergeConfig 的清空判定一致(strings.TrimSpace(s) == ""),
// 否则会出现「MergeConfig 认为该删、sameConfigValue 认为有值」的夹缝。
func isBlankConfigValue(v any) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && strings.TrimSpace(s) == ""
}
// MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。
//
// 规则:
+72
View File
@@ -277,6 +277,78 @@ func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) {
}
}
// TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination 覆盖「可留空的
// 目的地字段」这条路径:Telegram 的 base_url 留空表示用官方 API 地址。
//
// 曾经这里会让渠道从第二次保存起永久保存失败:
//
// 新建时库里存下 base_url:""(创建路径直接存前端提交的 config,不走 MergeConfig)
// → 第一次保存,MergeConfig 把空串当显式清空、delete 掉该键
// → 第二次保存,incoming 仍是 ""、而 stored 里已经没这个键,被判成「地址变了」
// → bot_token 是掩码回显值 → 400「目标地址已变更,请同时重新填写凭据字段」
//
// 用户什么都没改,却从此再也存不上,除非重新粘贴一遍 Bot Token。
func TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination(t *testing.T) {
stored := map[string]any{"bot_token": "123:ABC", "chat_id": "-100", "base_url": ""}
// 前端 buildConfig() 对该渠道的每个字段定义都提交一个值:凭据回填掩码,
// 空文本框提交空串。这里完整复现它的输出,而不是只提交「改动的键」。
submit := func() map[string]any {
return map[string]any{
"bot_token": MaskedValue("123:ABC"),
"chat_id": "-100",
"base_url": "",
}
}
// 第一次保存:只改了渠道名字,config 原样回传。
merged, err := PrepareConfigUpdate(KindTelegram, stored, submit())
if err != nil {
t.Fatalf("第一次保存被误拦: %v", err)
}
if _, ok := merged["base_url"]; ok {
t.Fatal("前提已变:空串应被 MergeConfig 删除——本用例要覆盖的正是「键消失之后」那一步")
}
// 第二次保存:提交内容与上次完全一致,用户什么都没改。
merged2, err := PrepareConfigUpdate(KindTelegram, merged, submit())
if err != nil {
t.Fatalf("第二次保存被误拦(用户什么都没改): %v", err)
}
// 第三次,确认不是「只错一次」而是稳定可保存。
if _, err := PrepareConfigUpdate(KindTelegram, merged2, submit()); err != nil {
t.Fatalf("第三次保存被误拦: %v", err)
}
// 凭据必须一路保留下来,没有被空串逻辑连带清掉。
if got := merged2["bot_token"]; got != "123:ABC" {
t.Fatalf("Bot Token 应沿用原值,得到 %v", got)
}
}
// TestPrepareConfigUpdateStillGuardsBlankDestinationChanges 是上一条用例的配对
// 断言:把空串与「键不存在」视为等价,**不能**连带放过真正的地址变更。
// 这两个方向都是真实的凭据外发路径——Telegram 的 Bot Token 走在 URL 路径里,
// 换了 base_url 就等于把 Token 送给新地址。
func TestPrepareConfigUpdateStillGuardsBlankDestinationChanges(t *testing.T) {
// 方向一:从「空」(官方地址)换到自建地址。
official := map[string]any{"bot_token": "123:ABC", "chat_id": "-100"}
if _, err := PrepareConfigUpdate(KindTelegram, official, map[string]any{
"bot_token": MaskedValue("123:ABC"),
"base_url": "https://tg-proxy.attacker.tld",
}); err == nil {
t.Fatal("从官方地址换到自建地址必须要求重新填写 Token")
}
// 方向二:把自建地址清空(= 换回官方 API)同样是地址变更。
proxied := map[string]any{"bot_token": "123:ABC", "base_url": "https://proxy.internal/bot"}
if _, err := PrepareConfigUpdate(KindTelegram, proxied, map[string]any{
"bot_token": MaskedValue("123:ABC"),
"base_url": "",
}); err == nil {
t.Fatal("清空自建地址(换回官方 API)同样是地址变更,必须要求重新填写 Token")
}
}
func TestDestinationKeysDeclaredForEveryKind(t *testing.T) {
// 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。
for kind, ch := range registry {