mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
93c1d39df3
commit
77834f0080
2 files changed
+95
-1
No files matched your search
+23
-1
@@ -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 之上,用于更新渠道配置。
|
||||
//
|
||||
// 规则:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user