fix(gateway,runtimeconfig): 渠道配置写路径改为 patch 语义,修凭据静默丢失(P0 级数据安全)
- 新问题(本轮前端组发现):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:
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 {
|
||||
|
||||
Reference in new issue
Block a user