fix(gateway,runtimeconfig): 渠道配置写路径改为 patch 语义,修凭据静默丢失(P0 级数据安全)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒
  ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把
  bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据);
  ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉
- 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构)
  · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉)
  · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空)
  · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变)
- 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在;
  Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入;
  既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿
- 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原
- 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义);
  「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽)
This commit is contained in:
AskaEth committed 2026-10-04 13:42:37 +08:00
1 parent 8d70f6d331
commit 4ae714883d
4 files changed
+446 -9

No files matched your search

@@ -11,6 +11,12 @@ package gateway
// bot_token / base_url / remote_url / endpointPath),外加针对常见
// token/地址字段的顶层便捷字段,因此面板既可发送
// config 对象,也可发送快捷字段。
//
// config 的线上契约是 **patch 语义**(三态,与 personaId 同构),不是整条替换:
// 面板只携带用户本次碰过的平台字段,因此未被携带的键必须保留已存值 ——
// 否则打开一张账号卡直接保存,就会把面板不渲染的凭据(weixin-ilink 的
// bot_token/bot_id/owner_id)静默抹掉。判定见 runtimeconfig.PatchChannel:
// 键缺席 = 保留、给出非空值 = 覆盖、给出 null/"" = 清除。
import (
"encoding/json"
@@ -54,8 +60,16 @@ type AdminChannelAccount struct {
// channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。
// Config 保存平台特有键(bot_token、base_url、remote_url、
// endpointPath……);顶层便捷字段会为常见 token/地址字段
// 合并进去。AllowGroups/DenyGroups/AllowUsers/DenyUsers 是
// endpointPath……),**按 patch 语义解释**(见 PatchChannel):
// - 键缺席(本 map 里没有该键,或整个 config 缺席 / 为 null)→ 保留该账号已存值;
// - 键给出非空值 → 覆盖该键;
// - 键显式给出 null 或 "" → 清除该键。
//
// 顶层便捷字段(token/baseURL/remoteURL/listenAddr/mode/accessToken/selfID)会为
// 常见 token/地址字段合并进去,且只在**非空**时才合并 —— 它们是普通 string,
// 分不出「缺席」与「显式空串」,因此线上一律以「非空 = 给出」为准;要清除
// bot_token 之类的键请走 config 里的显式 null/""。
// AllowGroups/DenyGroups/AllowUsers/DenyUsers 是
// 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是
// 每账号的流量限制(design/12 §6 策略三/四)。
type channelConfigRequest struct {
@@ -84,13 +98,14 @@ type channelConfigRequest struct {
}
// adminSaveChannelConfig 把某平台一个账号的配置持久化进
// channels.json(按 id Upsert)并热重载该平台的适配器。
// channels.json(按 id 打补丁)并热重载该平台的适配器。
// 响应携带 channels.json 路径与账号 id,使面板能
// 定位已保存的账号(供之后编辑/删除)。
//
// personaId 是三态的(见 channelPersonaBinding):面板仅在主人
// 触碰过角色选择时才发送该键,因此键缺失必须保留
// 已存绑定,而不是静默解绑该账号。
// config 与 personaId 一样是三态的(见 runtimeconfig.PatchChannel 与
// channelPersonaBinding):面板只发送用户本次碰过的键,
// 因此键缺失必须保留已存值,而不是静默丢弃已存凭据或
// 解绑该账号。
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
@@ -120,8 +135,9 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
}
}
cfg := channelConfigFromRequest(req)
// weixin-ilink 可以带空配置保存:面板先创建账号,
// 随后用户扫码(get_bot_qrcode),它经 login-status 填入
// cfg 是**补丁**(只含本次请求显式给出的键),因此这道门禁读作
// 「本次请求一个平台特有配置键都没给出」。weixin-ilink 可以带空配置保存:
// 面板先创建账号,随后用户扫码(get_bot_qrcode),它经 login-status 填入
// bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该
// 流程,因此把它从「至少一个字段」的门禁中豁免。
if len(cfg) == 0 && platform != "weixin-ilink" {
@@ -130,7 +146,9 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
return
}
accounts := s.channels.Load()
updated := runtimeconfig.UpsertChannel(accounts, platform, config.ChannelAccount{
// patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为
// null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。
updated := runtimeconfig.PatchChannel(accounts, platform, config.ChannelAccount{
ID: strings.TrimSpace(req.ID),
Config: cfg,
AllowGroups: req.AllowGroups,
@@ -210,6 +228,11 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques
// map,优先采用面板已显式设置的键。它绝不注入面板
// 未给出的键(空 token 保持缺位,这样用的是
// 适配器的零值,而不是空字符串 token)。
//
// 返回的是**补丁**:只含本次请求给出的键,调用方必须
// 交给 runtimeconfig.PatchChannel,不得当作完整配置整条替换 ——
// 后者的三态(缺席 / 给出 / null 或 "")正是「未被面板携带的
// 键不会被抹掉」所依赖的。
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
cfg := make(map[string]any, len(req.Config)+8)
for k, v := range req.Config {