SN-2026-019:通知子系统配置键注册缺失与校验报错脱敏
SN-2026-019 涵盖了 Server 20260804(提交 162ded343 与 672c07a0f,pgsty/silo#39)修复的一类通知子系统配置缺陷:通知解析器与旧版配置迁移代码读写了未在 Default*KVS 中注册的配置键,CheckValidKeys 在校验失败时将包含敏感凭据的原始 key=value 拼入错误消息,且 FetchEnabledTargets 在遇到首个校验错误时会直接中止全部通知目标的加载。随后在 Server 20260903(f1ba68358)中,由本次 AST 审计顺带揪出的 PostgreSQL 与 MySQL 离散连接字段被正式废弃,统一收敛为 connection_string 与 dsn_string。
结论先行
- 四处独立维护的代码面发生漂移:在通知配置子系统中,
Default*KVS(合法键集合)、Help*(帮助文档与敏感级别分类)、GetNotify*(配置解析)与SetNotify*(旧配置迁移)长期靠手工同步,缺乏一致性校验。 - 单个目标报错会拖垮全局通知目标:
FetchEnabledTargets按顺序校验所有通知子系统,一旦遇到首个错误便直接返回,导致全局通知目标列表保持为nil,使正常的通知目标一并失效。 - 配置校验报错回显了敏感值:当
CheckValidKeys拒绝未知或拼写错误的键时,旧代码使用kv.String()格式化错误,将完整的key=value(含密码或凭据内容)直接输出到日志和客户端终端。 - 通过 AST 审计测试锁死不变量:Server
20260804补齐了 NATS 与 AMQP 缺失的配置键、在CheckValidKeys报错中剥离键值、扩展了HasSensitiveKeys脱敏范围,并新增编译期 AST 审计测试,强制要求cmd/config/notify读写的所有配置键必须在Default*KVS中注册。
一条令人困惑的故障报告
本次排查始于社区用户 kuldeep-link11 报告的一个离奇现象:运维人员执行 mc admin config set us notify_nats:FITCHECK address="..." subject="..." 时,服务端却报错指向了另一个完全不相干的目标 notify_nats:ONE,并且报出了两个当前命令根本没有传入的键(nkey_seed= tls_handshake_first=off):
为什么修改 FITCHECK 会报出 ONE 上从未手敲过的两个键?排查发现这是两个机制叠加的结果:
mc admin config set的分词器吞字效应:internal/config/parser.go中的kvFields仅根据DefaultKVS中已注册的键名进行分词。当用户在命令行传入未注册的键时,kvFields不会将其识别为新键的起点,而是将"nkey_seed=..."静默吞入前一个合法键(如subject)的值中。- 旧配置迁移逻辑向
notify_nats:ONE写入了未注册键:在此前版本升级或配置持久化的过程中,旧版配置迁移函数SetNotifyNATS(cmd/config/notify/legacy.go)直接构造了 KVS 结构体(绕过了kvFields分词),将nkey_seed和tls_handshake_first作为独立键写入了notify_nats:ONE。当运维人员随后修改notify_nats:FITCHECK时,validateConfig重新校验集群中存储的所有目标,结果在notify_nats:ONE上撞上了CheckValidKeys报错。
同一个常量混用在了两个命名空间
检查 internal/config/notify/target/nats.go 时,我们在 nkey_seed 旁边立刻发现了第二个隐患:
2024 年 2 月 PR minio/minio#19139 为 NATS 引入用户凭据文件支持时,作者将常量 NATSUserCredentials 放在了 Env* 常量块末尾,赋值为 "MINIO_NOTIFY_NATS_USER_CREDENTIALS",随后将其同时用作环境变量名和 DefaultNATSKVS 中的 KVS 配置键名。结果,运维人员通过 mc admin config set notify_nats user_credentials=/path/to/creds 配置时会因键名未知而被拒绝;反而是字面量 MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds 成了 DefaultNATSKVS 在持久化配置中期待的键名。
四个缺乏协同的配置面
梳理 cmd/config/notify/ 下全部十个通知目标可以发现,每个目标都分散在四个平行的代码面上:
Default*KVS(cmd/config/notify/config.go):由CheckValidKeys强制校验的合法键清单;Help*(cmd/config/notify/help.go):面向用户的帮助说明及敏感类型标注(Sensitive/Secret);GetNotify*(cmd/config/notify/parse.go):读取 KVS 配置键与MINIO_NOTIFY_*环境变量;SetNotify*(cmd/config/notify/legacy.go):将旧版 JSON 配置结构体序列化为 KVS 键值对。
在 NATS 和 AMQP 中,共有四个配置键在这四个代码面之间发生了漂移:
| 目标子系统 | 配置键 | 引入来源 | Default*KVS |
Help* |
GetNotify* |
SetNotify* |
故障表现 |
|---|---|---|---|---|---|---|---|
notify_nats |
user_credentials |
#19139 (2024-02) |
误注册为 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
缺失 | 读取 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
写入 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
执行 mc admin config set 传入 user_credentials 被拒 |
notify_nats |
nkey_seed |
#21231 (2025-05) |
缺失 | 缺失 | 读取 nkey_seed |
写入 nkey_seed |
持久化配置无法通过 CheckValidKeys |
notify_nats |
tls_handshake_first |
#21008 (2025-02) |
缺失 | 已包含 | 读取 tls_handshake_first |
写入 tls_handshake_first |
持久化配置无法通过 CheckValidKeys |
notify_amqp |
immediate |
初始 KVS 迁移 | 缺失 | 缺失 | 读取 immediate |
将 cfg.Immediate 误写入 AmqpInternal! |
immediate 被拒;且 SetNotifyAMQP 用 cfg.Immediate 覆盖了 internal |
特别注意表格最后一行的 AMQP 缺陷:cmd/config/notify/legacy.go 中的 SetNotifyAMQP 存在一处复制粘贴笔误,把 strconv.FormatBool(cfg.Immediate) 写到了 target.AmqpInternal 键下,而真正的 cfg.Internal 字段则完全丢失。
爆炸半径放大器:全有或全无的目标加载机制
为什么一个未启用的 notify_nats:ONE 目标报错会造成严重后果?在 cmd/config/notify/parse.go 中,FetchEnabledTargets 会依次加载全部十类通知子系统(amqp、elasticsearch、kafka、mqtt、mysql、nats、nsq、postgres、redis、webhook)。只要其中任何一个子系统在 CheckValidKeys 中返回错误,FetchEnabledTargets 就会立即中止并返回错误,不会填充全局活跃通知目标映射表。
这意味着,哪怕某个早已停用的 notify_nats 历史条目里残留了一个未注册键,也会导致实例重启或配置重载时全局通知目标列表置空,使正在运行的 Kafka、Webhook 等所有桶通知静默中断,直到管理员手工清理该配置段为止。
我们曾评估是否要将 FetchEnabledTargets 改为按子系统隔离错误(跳过损坏的子系统、保留其余目标运行),但最终否决了这一改法:在 SILO 中,配置校验在设计上就是全有或全无的原子操作,以便 mc admin config set 能够明确拦截错误配置而非部分生效。补齐合法键注册即可消除误报,无需削弱配置校验的原子性契约。
修复键注册与敏感级别分类
提交 162ded343 修复了 NATS 与 AMQP 的键空间缺陷:
- 拆分配置键常量与环境变量常量:
target.NATSUserCredentials修正为"user_credentials",同时新增target.EnvNATSUserCredentials = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"。 - 在
DefaultNATSKVS与DefaultAMQPKVS中补齐四个键:将user_credentials、nkey_seed、tls_handshake_first注册到DefaultNATSKVS,将immediate注册到DefaultAMQPKVS。 - 补全
HelpNATS与HelpAMQP:为user_credentials、nkey_seed和immediate补齐帮助项。由于user_credentials和nkey_seed接收的都是服务端本地文件路径(与client_key一致),我们将二者标记为config.Sensitive而非config.Secret。 - 修复
SetNotifyAMQP:令SetNotifyAMQP将cfg.Internal写入target.AmqpInternal,将cfg.Immediate写入target.AmqpImmediate。
对存量 NATS 配置的窄化向后兼容
如果存量集群在 20260804 之前为了绕开 #19139,已经在持久化配置中写入了字面量键 MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds,该怎么办?
如果仅仅把 NATSUserCredentials 改名为 "user_credentials",这些存量集群在升级后首次加载配置时就会被 CheckValidKeys 判定为包含非法键——从而直接触发上一节所述的 FetchEnabledTargets 全局通知中断。
在设计向后兼容路径时,我们发现了 internal/config 中两个同名函数的细微陷阱:
- 包级自由函数
config.CheckValidKeys(subSys, kvs, defaultKVS, deprecatedKeys...)接受可选的废弃键变长参数(deprecatedKeys),并在加载校验时放行这些键; KVS的方法kvs.CheckValidKeys(subSys, defaultKVS)内部调用了CheckValidKeys(subSys, kvs, defaultKVS),没有透传任何废弃键参数。
因此,我们在 NATS 加载路径(GetNotifyNATS)中改用包级函数 config.CheckValidKeys 并将 "MINIO_NOTIFY_NATS_USER_CREDENTIALS" 列入废弃键白名单,同时在解析 UserCredentials 时规定了确定的优先级顺序:
MINIO_NOTIFY_NATS_USER_CREDENTIALS环境变量(最高优先级);user_credentialsKVS 配置键;- 历史遗留的
MINIO_NOTIFY_NATS_USER_CREDENTIALSKVS 配置键(仅当user_credentials为空时兜底)。
校验报错脱敏与敏感键加固(SN-2026-019)
在 20260804 之前,无论是 config.CheckValidKeys 还是 KVS.CheckValidKeys,在拼接非法键报错信息时都会直接调用 kv.String()(即输出完整的 key=value)。如果管理员在设置配置时不小心拼错了敏感键名(例如把 password 敲成 pasword=Hunter2,或把 auth_token 敲成 auth_tokn=eyJ...),又或者旧版迁移函数写入了未注册的凭据键,报错信息就会把明文密码原样回显到终端响应和服务端错误日志中。
提交 162ded343 修改了这两个 CheckValidKeys 函数,使其在报错时仅输出键名(kv.Key),不再输出 kv.Value。
随后在提交 672c07a0f 中,我们进一步审查了用于在 mc admin config get 导出和健康诊断包(health-diagnostics)中掩码敏感值的 config.HasSensitiveKeys(internal/config/config.go),将其匹配范围扩展至通知、身份认证与 KMS 子系统使用的全部凭据键名(password、auth_token、secret_key、client_secret、nkey_seed、user_credentials、sasl_password、connection_string 与 dsn_string)。
用 AST 审计测试锁死注册不变量
为了从根源上杜绝 Default*KVS、parse.go 与 legacy.go 再次出现手工同步遗漏,我们在 cmd/config/notify/config_audit_test.go 中编写了基于 go/ast 的静态审计测试。该测试会解析 parse.go 和 legacy.go 的抽象语法树,追踪每一处 kvs.Get(...) 和 kvs.Set(...) 调用所引用的 internal/config/notify/target 字符串常量,并断言这些键全部已在对应的 Default*KVS 中注册。
在对审计测试本身复核时,我们发现并堵上了初版测试的两个盲区:
- 反向覆盖与数量下界断言:如果只断言
parse.go ⊆ Default*KVS,万一未来语法树结构变化导致 AST 遍历器一个键都没提取出来,空集也是任何集合的子集,测试会假阳性通过!因此我们加上了反向检查,要求Default*KVS中注册的每一个键(除enable、comment等通用键外)都必须在parse.go中被读取,并为每个子系统设置了提取键数量下界。 - 无类型复合字面量(Untyped Composite Literals):在
legacy.go中,部分迁移函数并非调用kvs.Set(),而是直接用无类型复合字面量{Key, Value}构造config.KVS{...}切片。当我们把 AST 遍历器扩展到检查config.KVS复合字面量后,测试立刻在现有代码库中抓出了另一处潜伏缺陷。
AST 审计顺藤摸瓜抓出的问题:PostgreSQL 与 MySQL 离散连接键
一旦 AST 遍历器开始检查 legacy.go 中的 config.KVS 复合字面量,它当即报出 SetNotifyPostgres 与 SetNotifyMySQL 存在未注册键写入:这两个函数各自写入了 host、port、username、password、database 五个离散配置键,且 GetNotifyPostgres / GetNotifyMySQL(parse.go)在 connection_string / dsn_string 为空时也会读取这五个键,但它们完全没有在 DefaultPostgresKVS 和 DefaultMySQLKVS 中注册!
更糟糕的是,正因为 password 未在 DefaultPostgresKVS 或 DefaultMySQLKVS 中注册,在 162ded343 之前,任何传入 password=... 或触发旧版 PostgreSQL/MySQL 配置迁移的操作都会撞上 CheckValidKeys,并在错误消息中把数据库明文密码直接打印出来。
我们分两步在两个版本中彻底解决了这一历史包袱:
- Server 20260804(
672c07a0f)短期止血:在DefaultPostgresKVS和DefaultMySQLKVS中补注册host、port、username、password、database(并在HelpPostgres/HelpMySQL中将password标记为Secret),确保存量配置与新的 AST 审计不变量立即自洽。 - Server 20260903(
f1ba68358)架构清理:同时维护两套数据库连接配置方式(connection_string与离散的host/port/username/password/database字段)会导致SetNotifyPostgres和SetNotifyMySQL在配置读写往返时用空的离散字段覆盖掉合法的connection_string。因此 Server20260903从Default*KVS、Help*、parse.go与legacy.go中彻底退役了这五个离散字段,统一收敛为使用connection_string(PostgreSQL)与dsn_string(MySQL)。
明确不做项
- 不在本补丁中改动
kvFields分词边界规则:若将kvFields改为按任意key=value切分而非仅识别DefaultKVS中的已知键,会改变值内部包含未加引号空格(例如 SQL 语句或通知主题)时的解析行为。在通过 AST 审计保证Default*KVS完整注册后,合法配置键均可被kvFields正确识别。 - 不削弱
FetchEnabledTargets的快速失败校验:如爆炸半径放大器所述,配置校验在所有子系统间保持原子性。
审查记录
代码审查验证了 AST 审计测试的反向覆盖下界断言、无类型 config.KVS 复合字面量扫描、CheckValidKeys 报错脱敏(162ded343)以及 HasSensitiveKeys 敏感凭据键覆盖(672c07a0f)。
总结
随 Server 20260804(162ded343 与 672c07a0f)发布并在 Server 20260903(f1ba68358)完成离散键收敛后,所有通知子系统配置键均由 AST 静态测试锁死在 Default*KVS 中,且非法键报错不再回显配置值。