跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

SILO 安全编年史

SILO 分支处理过的应用层 CVE 编年史:按时间从新到旧排列,每个 CVE 独立成篇。

这里记录 SILO 社区分支自分叉以来处理过的安全事件,按时间从新到旧排列。每个 CVE 独立成篇:最初的威胁模型、复核中的转折、被否决的方案、最终恢复的不变量、验证证据与兼容性代价,都留在它自己的故事里。

1 - CVE-2026-32285:最终无需补丁的 jsonparser 通告

一次没有代码改动的安全研判:当前依赖已经包含补丁,可达性检查也没有发现漏洞路径。

状态: 无需代码改动,问题已关闭
GitHub Issue: pgsty/minio#26

安全维护不只有“发现漏洞,然后提交补丁”。CVE-2026-32285 的初始判断是:仓库可能仍携带未修复的 jsonparser,甚至一度讨论过替换依赖或维护自己的分支。真正检查 module version 与可达性后,结论却是当前代码已经使用包含修复的 v1.1.2govulncheck 也没有发现可达的 vulnerable symbol。

最终正确的动作不是制造一个依赖升级,而是把证据记录下来,然后关闭问题。

初始判断为什么有问题

问题最初被理解成“该依赖没有可用的 fixed version”。如果直接按照这个前提行动,很容易出现几种看似积极、实际有害的结果:

  • 无意义地改变 dependency graph;
  • 为一次并不存在的修复引入新的兼容性回归;
  • 增加本地 fork 的长期维护负担;
  • 让用户误以为过去的 SILO Release 确实暴露于该漏洞。

安全工作不能用“有没有产生 diff”衡量。没有漏洞时不改代码,本身就是一个需要证据支持的安全决定。

研判过程

这次调查按四层证据逐步收敛:

  1. 核对当前 go.modgo.sum 中实际解析到的版本;
  2. 检查上游 release,确认 v1.1.2 已经包含对应补丁;
  3. 运行并核对 govulncheck,没有发现可达 symbol;
  4. 将 issue 中的差异归因于漏洞数据库或问题信息滞后,而不是当前源码仍然存在漏洞。

这里必须区分四件事:一个版本曾被标记为受影响、一个 package 被导入、漏洞 symbol 在程序中可达、以及远程输入能真正触发利用。这四个结论不能互相替代。

为什么不做“保险升级”

如果当前版本已经包含修复,再随便 bump 到另一个版本并不会让系统“更安全”。它只会扩大变化面,让后续回归更难归因。对一个庞大的 Go module graph 来说,这种无目标滚动尤其危险。

最终决定是:

  • 不提交假修复;
  • 在 issue 中保留版本与可达性证据;
  • 继续让版本 gate 与 govulncheck 防止未来依赖回退;
  • 不把“当前无需改动”写成永久豁免。

验证边界

本事件证明的是:2026-04-15 当时的 checkout 不需要为 CVE-2026-32285 修改代码。 它不代表所有未来分支、依赖图或发布版本永远不受影响。只要依赖发生降级或 module selection 改变,就必须重新做版本与可达性检查。

这篇文章记录的是当时的研判与关闭依据,不声称本次博客整理重新运行了原始 govulncheck

这次事件留下的原则

安全维护的目标是让风险结论准确,而不是让每个 issue 都产生代码。面对依赖型 CVE,最重要的是依次回答:当前到底解析到哪个版本、漏洞代码是否进入程序、symbol 是否可达、部署入口是否可利用。只有这些问题的答案要求改变源码时,才应该制造 diff。

2 - CVE-2026-33322:OIDC JWT 算法混淆

OIDC verifier 混用 client secret 与 JWKS key,最终通过恢复 JWKS-only 非对称验证关闭算法混淆。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
影响入口: AssumeRoleWithWebIdentityAssumeRoleWithClientGrants
GitHub Issue: pgsty/minio#22

旧实现把 OIDC client secret 放进 JWT verifier keyring,同时允许 HMAC signing method。知道 client secret 的攻击者因此可以自行签发 HS token,再通过 STS 换取临时权限。最终修复恢复了 JWKS-only 非对称验证:宁可明确打破 HS256/384/512 兼容,也不保留一个会重新混淆信任语义的开关。

漏洞不在“有没有验签”

表面上看,旧代码确实执行了 JWT signature verification。真正失效的边界是:verifier 接受了哪一种 key,以及 token header 是否能选择与该 key 不匹配的算法语义。

攻击链需要满足几个条件:

  • 攻击者获得 OIDC client secret;
  • 攻击者构造 HMAC-signed ID token;
  • verifier 把 client secret 当作 HMAC signing key;
  • token 进入 WebIdentity 或 ClientGrants STS flow,换取临时凭据。

client secret 泄露本身当然严重,但它原本不应该自动获得“签发任意用户 ID Token”的权限。把两种能力混在同一个 keyring 中,才是算法混淆的核心。

兼容路径写出来了,又被主动删除

修复过程中曾经实现过 allow_hmac 一类兼容路径。它看起来很合理:默认安全,确有需要的用户可以显式打开。但继续把 shared secret 放进通用 verifier keyring,意味着管理员需要理解这个配置实际上扩大了整个 STS 信任边界;未来 method allowlist 只要发生漂移,漏洞就可能重现。

几种方案的取舍最终非常清楚:

方案 收益 风险 结论
保留 secret keyring,只限制部分算法 改动小,兼容 HMAC IdP keyring 仍混合两种信任语义 否决
增加 allow_hmac 配置 兼容性显式 配置本身难以正确理解,测试面扩大 实现后回滚
JWKS-only 边界清晰,刷新与重试共用同一 parser HS 用户必须迁移 接受

这次最重要的决策不是“新增了哪些代码”,而是主动删除了已经完成的兼容实现。

最终不变量

修复集中在 OIDC JWT 验证路径,并固定了四条规则:

  • verifier key 只来自 IdP JWKS;
  • OIDC client secret 不进入 JWT verification keyring;
  • HS256、HS384、HS512 一律拒绝;
  • 正常 RS256 流程以及 JWKS refresh/retry 使用同一 method allowlist。

修复没有借 CVE 顺手扩展 JOSE 功能。PS256 与 EdDSA 不在这次事件的支持范围内。

验证与发布

开发记录包含 HS256 rejection、RS256 acceptance、JWKS refresh/retry regression tests,以及 focused go test ./internal/config/identity/openid。临时兼容 helper、配置与测试在最终 diff 中全部删除。

公开发布 lineage 中的修复提交为 f1f2239,并随 SILO 2026-04-17 发布。这篇文章记录历史验证,不代表本次博客整理重新执行了测试。

兼容性代价

这是明确的 breaking change。仍签发 HS256/384/512 token 的 IdP 必须先迁移到 JWKS-backed RSA/ECDSA,再升级 SILO。这里选择的是更窄、更容易解释的信任模型,而不是让旧配置继续工作。

3 - CVE-2026-33419:LDAP STS 用户枚举与限流链

从统一认证失败,到修正成功退款、代理来源与账户锁定:一次经历两轮反向修复的 LDAP STS 加固。

状态: 已发布,经历两轮后续修正
首个包含版本: RELEASE.2026-04-17T00-00-00Z
完整修正版本: RELEASE.2026-06-18T00-00-00Z
GitHub Issue: pgsty/minio#23

核心漏洞很直接:LDAP STS 对“用户不存在”和“密码错误”返回不同结果,形成 username oracle。第一版修复统一外部错误,并增加 source IP 与 username 双重限流;连续复核却发现,成功退款、可伪造来源 header、reservation accounting 和共享 username bucket 都可能让安全控制本身成为新的攻击面。

六月的最终方案删除了会造成精确账户锁定的 username bucket,只保留 source IP bucket,并把代理来源识别写成明确的部署契约。

初始威胁模型

入口是 AssumeRoleWithLDAPIdentity。攻击者不需要已有 MinIO 账号,只要能够访问 LDAP STS endpoint,就可以比较 unknown user 与 wrong password 的 code、status 或 message,逐步枚举有效用户名,再结合 password spraying、组织结构猜测或社工攻击。

修复也不能简单把所有错误都伪装成“密码错”。LDAP connection、lookup bind 或目录服务故障必须继续表现为基础设施错误,否则运维会失去诊断能力。

第一轮:统一响应并增加 limiter

2026-04-15 的初始修复做了三件事:

  • unknown user 与 bad password 对外返回同一 STS auth error;
  • LDAP infrastructure error 仍返回 500,并在 server log 保留真实原因;
  • 新增 in-memory limiter,最初同时按 source IP 与 normalized username 分桶。

这一版关闭了内容侧信道,也给暴力尝试增加了成本,但 limiter 的状态机与来源识别随后暴露出更多问题。

第二轮:成功、来源与会计

4 月 16 日的连续修正处理了三类缺陷:

  1. 成功认证不应消耗失败额度,reserve/commit/cancel/refund 生命周期必须明确;
  2. 默认只能使用 socket peer,不能直接信任 X-Forwarded-ForX-Real-IPForwarded
  3. refund 与 capacity 必须有边界,避免 cancel 逻辑凭空增发 token。

trusted proxy 需要显式 allowlist,而不是因为请求带着“真实 IP” header 就自动获得信任。

第三轮:删掉 username bucket

六月的对抗性复核推翻了“source + username 一定比 source-only 更强”的直觉。共享 username bucket 可以被任意来源持续耗尽,攻击者只需要低频请求就能在合法用户真正执行 LDAP bind 之前,精确锁死一个目标账户。

最终修复因此:

  • 删除 per-username bucket;
  • XFF 从右向左剥离 trusted hops,取第一个非可信地址;
  • 拒绝 0.0.0.0/0::/0 这类 trusted-proxy footgun;
  • Forwarded 不再用于安全敏感分桶;
  • X-Real-IP 只在代理覆盖而非透传客户端输入的契约下使用。

这次转折说明,安全控制必须拥有自己的威胁模型。限制更多维度,不等于更安全。

被否决的方案

方案 否决原因
为未知用户执行 dummy bind 放大 LDAP 压力并引入易错的第二条认证路径;内容侧信道已经关闭
IPv6 统一按 /64 分桶 会让同一站点或运营商前缀下的合法用户互相误伤
XFF 直接取最左值 客户端可伪造
XFF 与 X-Real-IP 不一致就回退 peer 攻击者可故意制造不一致,把代理后的所有用户压入同一 bucket
完整支持 RFC 7239 Forwarded 安全解析复杂度高,现实收益不足

验证与发布

历史记录覆盖 limiter reserve/commit/cancel/refund、并发、success、infra failure、unknown user/bad password 外部等价,以及 RemoteAddr、spoofed header、trusted proxy、多 hop 与 catch-all CIDR。focused package test 与 build 均有记录。

LDAP security e2e 在缺少 _MINIO_LDAP_TEST_SERVER 时会 skip,所以外层 ok 不能冒充真实 LDAP 全场景证明。

初始公开修复提交为 6619d0c,后续修正包括 c55b52c817a457084a1545e40665

最终代价与残余风险

  • limiter 最终只按 source IP,放弃跨来源的单账号 hard throttle;
  • limiter 是 per-node、in-memory,不是集群全局密码防护;
  • botnet、分布式来源、IPv6 地址轮换与 LDAP bind timing 仍然存在;
  • trusted proxy 配置错误仍会破坏来源归属;
  • Forwarded-only 部署会退化为 peer bucket,粒度更粗。

限流只能降低单一来源的尝试速率。真正隐藏 username existence 的,是统一的外部认证响应。

4 - CVE-2026-34204:复制元数据注入

普通 PUT/COPY 可以伪造内部复制状态;修复让 replication-only metadata 只在授权复制路径中恢复。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Issue: pgsty/minio#24

普通 PUTCOPY 请求可以把 X-Minio-Replication-* header 伪装进内部 X-Minio-Internal-* SSE metadata,写出 replication state 与真实授权路径不一致、甚至无法读取的对象。最终修复不再默认接受 replication-only metadata,只在通过 ReplicateObjectAction 的可信复制流程中恢复,并在 CopyObject 的所有 header 消费者之前统一清洗。

威胁模型

攻击者只需要普通对象写权限,不需要 internode credential。输入完全来自客户端可控的 X-Minio-Replication-* header;metadata extraction 却会把它们转换成内部复制或 SSE 状态。

后续读路径按照错误的内部状态解释对象,可能导致对象不可读,形成完整性与可用性破坏。几乎所有接受不受信写请求的生产 server 都应该视为受影响。

问题的根本不是 header 名字本身,而是 不可信来源的数据在没有经过复制授权的情况下获得了内部语义

全请求拒绝,还是精确清洗

看到 replication header 就拒绝普通请求,是最直观的修复。但这会把客户端过去可以携带的多余 header,从“被忽略”改变成 hard failure。最终选择了更精确的模型:

  • 默认 extraction path 不接受 replication-only metadata;
  • ordinary PUTCOPY 先剥离这些字段;
  • 只有通过 ReplicateObjectAction 授权后才恢复;
  • replica status 写入使用同一可信条件;
  • multipart 与 Snowball 的合法 replication flow 显式恢复所需的 SSE metadata。

这让兼容性变化停留在内部语义,而不是扩大到所有携带多余 header 的客户端。

为什么 CopyObject 必须提前清洗

CopyObject 的 header 不只用于最终 metadata map,还会提前参与 precondition 与 SSE-C source 处理。如果只在写入对象前删除,早期消费者已经被污染。

最终清洗发生在这些消费者之前,把“不可信 replication header 不进入内部语义”变成单一不变量,而不是依赖每个后续函数记得再检查一次。

实现与验证

改动覆盖 handler-utils、object handler 与 multipart handler,并加入了几层测试:

  • helper 层 trusted/untrusted metadata extraction;
  • handler 层 malicious PUTCOPY
  • CopyObject header sanitization;
  • vulnerable parent 与 patched tree 的红绿对照;
  • live server before/after,确认恶意 header 不再让对象不可读;
  • legitimate replication、multipart 与 Snowball flow 保持可用。

公开发布 lineage 的修复提交为 fcb8f24。这篇文章保留历史验证边界,本次博客整理没有重新启动 live server。

代价与残余风险

  • 普通客户端夹带的内部复制 header 现在会被忽略或清洗;
  • replication-only metadata 必须在授权分支显式恢复;
  • 未来新增复制入口如果忘记恢复,会表现为功能回归,而不是重新放开不受信写入;
  • 本次审计聚焦 replication header,不代表所有 X-Minio-Internal-* 字段都完成了同样的 trust audit。

这个事件留下的审查问题很简单:一个字段看起来像“内部字段”并不能证明它可信,必须继续追问它来自哪里,以及哪一个授权决定允许它获得内部含义。

5 - CVE-2026-39414:S3 Select 超大记录与 SIMD 绕过

第一轮给 CSV 与 JSON Lines 加上 1 MiB 上限,第二轮又发现 SIMD fast path 完全绕过了它。

状态: 已发布,六月完成二次闭环
初始修复版本: RELEASE.2026-04-17T00-00-00Z
完整修复版本: RELEASE.2026-06-18T00-00-00Z
GitHub Issue: pgsty/minio#25

四月的第一轮修复使用既有的 1 MiB maxCharsPerRecord 同时限制 CSV 与普通 JSON Lines,避免在遇到分隔符前持续无界 buffering,并让客户端得到明确的 OverMaxRecordSize。六月复核却发现,支持 SIMD 的 CPU 会走另一条 simdjson fast path,完全绕过这个限制。

最终方案让 JSON Lines 统一走 bounded reader,同时修正错误码、parser error 与 terminal error 前的 completed-record flush。代价是暂时放弃 SIMD 快路径,以换取所有 CPU 上一致的安全语义。

威胁模型

攻击者可以提交或查询包含超长单条记录的对象。reader 在遇到 record delimiter 前持续缓存,造成 memory/CPU DoS。更麻烦的是,同一个输入会因为机器 CPU 能力不同而进入不同实现:测试机上的安全行为,并不一定等于生产机。

错误语义也属于修复的一部分。如果超大记录最后只表现为 generic InternalError,客户端与告警系统无法区分安全上限和服务端故障。

第一轮:复用已有的 1 MiB 不变量

第一版补丁没有发明新的配置项,而是沿用已经存在的 maxCharsPerRecord = 1 MiB

  • CSV splitter 与 line-delimited JSON 在 buffer/parse 前拒绝超长记录;
  • 保留最早发生的 splitter error,不让 partial decode 覆盖;
  • 将错误透传为 OverMaxRecordSize,不再折叠成 InternalError

这是一项有意的兼容性收缩。过去包含超过 1 MiB 单行或单记录的客户端,升级后必须切分输入。

第二轮:硬件相关的绕过

六月沿调用链继续检查时发现:

JSON Lines -> simdj.NewReader -> simdjson.ParseNDStream

simdjson.SupportedCPU() 为真时,JSON Lines 绕过 bounded json.PReader。第三方 parser 会在 chunk 结束后继续读取直到换行,普通 reader wrapper 无法同时做到“不丢失前面完整记录”和“下一条记录一定有界”。

最终选择不是继续包裹,而是让 JSON Lines 暂时全部走 bounded PReader。未来如果恢复 SIMD,它必须自己执行同样的 record bound,并通过同一组不依赖 CPU 的回归测试。

同一轮修正的流语义

复核还修正了几个相邻问题:

  • errors.As 透传实现 SelectError 的错误,而不是只识别一个 concrete type;
  • JSON worker 把 parser error 包装成 JSONParsingError
  • terminal error event 之前先 flush 已完成但不足 batch size 的 output queue;
  • 保留输入顺序中的错误优先级,不用更晚的 oversized record 覆盖更早的 parse error。

这些细节决定了客户端看到的是正确的失败,而不是“修复了资源上限,却破坏了流式协议”。

刻意没有塞进本 CVE 的问题

  • CSV AllowQuotedRecordDelimiter 与外层物理换行 splitter 的历史语义缺陷;
  • CRLF 中 \r 是否计入长度;
  • 在没有相同边界的情况下恢复 SIMD 性能。

这些问题有的真实存在,但需要独立的 AWS compatibility evidence 或更复杂的 quote-aware splitter,不适合借安全修复顺手猜答案。

验证与发布

历史记录包含 oversized JSON Lines、错误码保留和不依赖本机 SIMD 能力的行为测试;go test ./internal/s3select/... -count=1git diff --check 均有通过记录。

公开初始修复提交为 c5765dc,六月完整修复为 fd69c89。本次博客整理没有重新执行这些测试。

最终代价

  • JSON Lines 性能可能下降,本事件没有 benchmark 给出量化结果;
  • 1 MiB 单记录上限会拒绝过去可接受的超大输入;
  • quoted CSV multiline 语义仍需独立处理;
  • 任何 CPU-specific fast path 以后都必须与 slow path 共用安全测试。

这次二次修复留下的教训是:安全不变量必须跨硬件路径成立。只在当前 CPU 上跑绿的测试,不能证明另一个执行引擎也受保护。

6 - CVE-2026-40344:Snowball 自动解包认证绕过

Snowball unsigned-trailer 请求可以在完成认证前进入解包器;修复把 SigV4 验证前移到任何 tar 字节之前。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Advisory: GHSA-9c4q-hq6p-c237

Snowball PutObjectExtractHandler 漏掉了 streaming unsigned-trailer auth case。伪造 signature 的 tar stream 可以在认证完成前进入 untar(),而一次请求又能扇出为多个对象写入。最终修复在任何 tar 字节进入解包器之前,初始化正确 reader、处理 decoded length 并完成 SigV4 验证。

编号为什么变过

修复时正式 CVE 尚未分配,commit subject 使用了临时的 fake CVE-2026-40028。正式编号后来确定为 CVE-2026-40344。历史 commit 没有重写;公开 advisory 与本文一律使用正式编号。

从一次认证遗漏到批量对象写入

入口是 Snowball / PutObjectExtract 自动解包。请求采用 unsigned-trailer streaming,而旧 handler 没有像普通 PUT 一样覆盖该 auth type。

危险不只在于一个请求被错误授权。tar stream 一旦进入 untar(),单个请求可以创建多个攻击者指定的对象。认证错误因此被放大成批量写入问题。

最终不变量:失败时解包器必须看到零字节

修复过程中最关键的一句话是:

如果认证最终失败,untar() 必须看到零字节。

这直接排除了“先解包,认证失败后再回滚”的方案。对象写入会经过多条路径,要证明回滚完整远比证明输入从未越过边界困难。正确收口点只能在数据流进入解包器之前。

实现

最终改动包括:

  • 识别 authTypeStreamingUnsignedTrailer
  • 读取 X-Amz-Decoded-Content-Length
  • 使用 newUnsignedV4ChunkedReader()
  • 在进入 untar() 前执行完整 SigV4 request verification;
  • 保留合法 signed Snowball 与 CRC32 trailer flow。

验证

历史 commit 与会话记录覆盖:

  • forged-signature Snowball unsigned-trailer 被拒绝;
  • non-public bucket 的 anonymous Snowball 被拒绝;
  • 合法签名与 trailing CRC32 可以正常解包;
  • vulnerable parent 与 patched tree 的红绿对照;
  • containerized before/after smoke。

公开修复提交为 b50ab58。本次博客整理没有重新运行容器测试。

兼容性与残余风险

  • 过去依赖实际未验证的 unsigned-trailer Snowball 组合的客户端,升级后会失败;
  • 认证已经前移,但 tar 内容路径、归档大小与对象数量限制仍是独立安全面;
  • Snowball 与普通 unsigned-trailer 现在共享 reader,未来修改必须同时回归两条路径。

这个事件的重点不是多加了一次 if,而是把认证决定移动到真正的放大边界之前。

7 - CVE-2026-41145:Unsigned-Trailer 查询认证绕过

query-string SigV4 credential 进入 unsigned-trailer 流后没有被验签,最终在共享 reader 边界统一关闭。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Advisory: GHSA-hv4r-mvr4-25vw

query-string SigV4 credential 可以进入 STREAMING-UNSIGNED-PAYLOAD-TRAILER 数据流,但旧代码只在存在 Authorization header 时验证签名。结果是:请求只要提供有效 access key 标识,即使没有正确 signature,也可能完成写入。

最终修复把 presigned rejection 与 SigV4 verification 放进 newUnsignedV4ChunkedReader(),让所有消费该数据流的 caller 共用同一认证边界。

编号说明

修复时正式编号尚未分配,commit subject 临时写为 fake CVE-2026-40027。正式编号后来确定为 CVE-2026-41145。历史 commit 保留原样,公开材料统一使用正式编号。

根因:把认证绑定到传输形式

受影响入口包括 PutObjectPutObjectPart。请求使用 STREAMING-UNSIGNED-PAYLOAD-TRAILER,credential 与 signature 放在 query string,而不是 Authorization header。

旧 handler 用“header 是否存在”决定要不要验签。body reader 却仍然正常读取数据,于是 query auth 被静默降级成近似匿名写入。攻击者只需要知道一个有效 access key 标识,并不需要构造正确 signature。

问题不是 query 参数没有解析,而是认证决定依赖凭据的传输形态,而不是真正消费数据流的信任边界。

为什么不逐 handler 打补丁

方案 风险 结论
PutObjectPutObjectPart 各补一段 header/query 判断 当前入口能闭合,但新 caller 很容易再次遗漏 否决
发明 presigned unsigned-trailer 兼容协议 协议与测试面显著扩大,又没有既有支持契约 否决
newUnsignedV4ChunkedReader() 统一拒绝并验签 所有消费者强制经过同一边界 接受

匿名 unsigned-trailer 并没有被一刀切禁用。如果 bucket policy 明确允许 anonymous write,它仍然可以按匿名授权路径工作。真正被禁止的是“带 query credential,却没有验证 credential”的混合状态。

实现与验证

修复在 cmd/streaming-v4-unsigned.go 的 reader 入口完成 presigned rejection 与 SigV4 verification,同时移除 PutObject / multipart handler 中依赖 header presence 的 gate。

新增测试覆盖 forged query PUT、multipart、mixed auth 与 anonymous policy。历史记录还包括 vulnerable parent 上写入成功、patched tree 上失败,以及 header-authenticated 与合法 anonymous flow 继续工作的 live server before/after smoke。

公开修复提交为 fa7c579。本次博客整理没有重新执行 live exploit。

兼容性与残余风险

  • presigned/query unsigned-trailer 现在明确不受支持,这是有意的 breaking change;
  • 修复下沉到 reader,显著降低 sibling handler 再次漏检的风险;
  • 其他 streaming auth mode 仍然需要独立审计,不能因为这一条 reader 修复就宣称所有 SigV4 streaming 组合安全。

这次修复的形状比 payload 本身更重要:当多个 handler 共享同一种认证数据流时,认证应该属于 reader,而不是每个 caller 的可选判断。

8 - CVE-2026-42600:ReadMultiple Storage-REST 路径穿越

从完整 preflight 校验到删除整条 API:一个没有生产调用者的内部文件读取接口为什么不值得保留。

状态: 已发布
首个包含版本: RELEASE.2026-06-18T00-00-00Z
GitHub Advisory: GHSA-xh8f-g2qw-gcm7
影响范围: 仅 distributed erasure;需要 cluster-root / internode JWT

/rmpl 的 msgpack body 中包含 BucketPrefixFiles,旧代码直接把它们拼成文件系统路径,没有 containment。最初的修复实现了完整 preflight validation;继续审计调用链后却发现,这条 API 从 2024 年起已经没有 production caller。最终方案因此从“保留并加固”转为删除 route、handler、client、interface 与生成代码。

删除约一千行代码看似比局部校验更大,长期攻击面却更小。

威胁模型

这个漏洞只在 distributed erasure 模式下注册,single-node 部署不受影响。攻击者需要 root secret 派生的 internode JWT、被控节点,或者能够截获未加密的节点间流量。

危险字段位于 msgpack body,不在 URL 或 form 中,因此上层 HTTP path middleware 看不到。xlStorage.ReadMultiple 会直接 join 并读取,允许路径逃出 drive root。

这不是匿名 S3 漏洞,而是从“集群 root / peer”到“节点进程可读文件系统”的边界跨越。

第一版:保留 API,完整校验

最初补丁在 xlStorage.ReadMultiple 中:

  • 拒绝 absolute path、. / .. segment、反斜杠、Windows drive prefix 与 NUL;
  • 校验 drive → volume → prefix → file 的最终 containment;
  • 尽量保留空 Bucket 与 .minio.sys/multipart 历史契约;
  • 在任何 read 或 streaming 发生前返回错误。

这套设计本身可以关闭已知穿越,但审查很快发现了一个 early-return 缺口。

MaxResults 暴露了“边用边校验”的问题

第一版在读取循环里逐项验证 Files。如果请求是 Files=[good, bad]MaxResults=1,函数读取第一项后提前返回,第二项永远不会被校验。

它没有直接读取第二个恶意文件,却破坏了“整个 msgpack request 必须先合法”的修复目标。于是校验被前移成全量 preflight,path length 也在 streaming 前统一检查。

这次转折留下了一条通用规则:存在 early return 或 streaming 的请求,逐项使用前检查不等于整请求安全验证。

最终决定:删除 API

进一步调用链审计确认:

  • 上游在 2024 年 9 月移除了最后一个 production caller;
  • multipart 已改用 ReadParts
  • 当前树没有 in-tree production consumer;
  • 上游正式处置也选择删除 ReadMultiple

最终删除 route、handler、client wrapper、StorageAPI / xlStorage method、metric、datatype 与生成代码,storageRESTVersion 保持原有兼容策略。

方案 短期变化 长期维护面 结论
原地 validation diff 较小,保留接口 永久保留无人使用的高权限文件读取 API 放弃
删除 API 删除较多接口与生成代码 攻击面与维护面最小 接受

验证与发布

原地校验阶段运行过 xlStorage、storage-REST client、msgpack encode/decode 与 path edge case focused tests;对抗性 review 补出了 MaxResults 问题。删除阶段检查了 route、client、interface、generated surface 与 caller absence。

公开修复提交为 73ac524,并随 SILO 2026-06-18 发布。本次博客整理没有重新运行删除后的 full suite。

兼容性与结论边界

  • 外部 S3 API 没有变化;
  • 私自调用内部 /rmpl 的第三方实现会失效;
  • mixed-version rolling upgrade 可能出现 protocol mismatch,因此升级时应保持节点版本一致;
  • 删除 endpoint 只证明 ReadMultiple 不再存在,不能外推成所有内部节点 body path 都已经完成 containment 审计。

这个 CVE 的最终修复是正确的,但它也提醒我们:关闭一个 endpoint,与关闭一个缺陷类别,是两种不同结论。

9 - 内部节点路径 containment 审计:补完 CVE-2026-42600 欠下的那笔账

上一篇明说删除 endpoint 不等于关闭缺陷类别。这次我们做了那次审计,找到四个协议面、十二项缺陷,并在修复过程中自己制造了四次回归。

状态: 已在本地 pgsty/minio 分支修复,尚未发布、尚未披露(未申请 CVE/GHSA,上游仓库已归档) 影响范围: 仅 distributed erasure;需要 cluster-root / internode JWT 前置阅读: CVE-2026-42600 · ReadMultiple

本文包含完整利用向量与实测数据。发布即构成披露,请在修复版本发出后再上线。

上一篇的结尾写着一句话:

删除 endpoint 只证明 ReadMultiple 不再存在,不能外推成所有内部节点 body path 都已经完成 containment 审计。

那是一张写明了的欠条。本文是还账记录——以及一份不太好看的施工日志。

结论先行

  • 缺陷共 12 项,全部继承自上游。逐函数 md5 比对确认,fork 对相关文件的 diff 是纯删除、新增零行。
  • 这不是新漏洞,是 CVE-2026-42600 的剩余部分:同一根因下还剩三个协议面。
  • 我们的过失不在代码,在 记录——把一次点修复写成了一次闭合。
  • 修复过程中我们自己引入了 4 次回归,全部集中在"自己发明的规则"上,复用既有语义的部分零回归。

「N 个端点」是错的框架

此前的审计反复在数端点,先后得出 19、21、22 三个数字,而且每次都漏掉整个协议面。真实结构是四个面:

协议面 入口数 受全局 HTTP 中间件保护
storage-REST HTTP query 参数 9 ——此前被误报为无保护
storage-REST HTTP msgpack body 4 否,r.Form 从不来自 body
storage Grid RPC 18 否,一次 upgrade 后不再进 HTTP 链路
peer-S3 Grid RPC 5 否,且绕过 getStorage() 直达 drive

第一行同样重要:它推翻了"21 个端点全部可逃逸"的旧结论。之前的审计在 handler 函数体里 grep 校验函数,没找到就判定无保护,忽略了保护发生在中间件层

第四行则是任何"加固 storage-REST handler"的方案都碰不到的地方。

根因是三层叠加,不是一个 bug

三个设计事实,单独看都不算错:

  1. 校验只在 HTTP 表层——r.Formurl.ParseQuery(RawQuery) 填充,永不来自 body(2017 引入)
  2. Grid RPC 绕过中间件——/minio/grid/v1 一次 websocket upgrade 后,msgpack 帧不再进入 HTTP 链路(2023 引入)
  3. 存储层零 containment——getVolDir 只在 volume 恰好等于 ""/./.. 时拒绝,pathJoinClean(2018 定型)

一句话:上层以为下层会校验,下层以为上层已校验,中间那条通道两边都看不见。

ReadMultiple 只是踩中这个结构的其中一个端点。移掉它,结构原封不动。

时间线里有一行特别值得看:ShardFileSize 的除零风险 2020 年就在了,但直到 2024-10 它被挪进 xioutil.WithDeadline(本意是修大对象超时)才从"一次请求失败"升级为"整个进程退出"——因为 WithDeadline 在裸 goroutine 里执行工作函数,Go 无法跨 goroutine recover。这个升级在当时不可能被看出来。

实测向量

全部在真实 xlStorage 上、经真实 REST/grid 客户端、配合植入哨兵文件复现,不是静态推断。

向量 协议面 观测结果
WriteAll("vol","../../x") storage grid drive root 外 任意文件写
RenameFile(".minio.sys","","bucket","x") storage grid 整个系统卷(IAM、config)搬进可读 bucket,全程无 ..
DeleteBucket("../victim", force) peer-S3 grid drive root 外 整棵目录树递归删除
DeleteBulk("vol","") HTTP body 整卷进 trash
ReadAll(volume:"../") 任意 getVolDir 的检查 加个尾斜杠就被绕过
CheckParts + 零值 Erasure storage grid 进程终止
AppendFile 声明 Content-Length: 64 GiB HTTP 空 body 分配 68,719,574,840 字节
DeleteVersions 声明 1 亿条 HTTP 10 字节参数 分配 10.4 GB
part Size = -2 storage grid 截断的分片被报告健康,修复静默跳过

两条此前无人发现的值得单独说:

空源路径的 RenameFile 命中卷根别名并把整卷搬走。用在 .minio.sys 上,攻击者随后一次普通 S3 GET 就能读到集群 IAM 与配置。它 不需要任何穿越序列——所以任何以 .. 为线索的审计都必然漏掉它。

负数 part sizeShardFileSize 的两个分量都向下取整到 0。而 checkPart 的唯一判据是 st.Size() < expectedSize,于是 任何存在的文件都判定完好,包括被截断的分片。更糟的是这与纠删参数是否合法无关——一个能通过 FileInfo.IsValid()(修复流程自己信任的检查)的元数据同样中招。这不是输入校验问题,是 数据完整性 问题:合法的修复流程读到毒化元数据后,会得出"分片没问题"从而静默跳过修复。

修复:两处收口,不是二十处补丁

要恢复的不变量只有一句:来自内部节点载荷的路径必须解析在它所指定的 volume 之内,volume 必须解析在 drive root 之内。

前半句只可能被 .. 打破(绝对路径与反斜杠前缀会被 pathJoinClean 收拢),后半句还有一条独立破法:别名卷根 的路径。所以是两条规则,不是一张策略矩阵。

收口点 位置 覆盖
volume 轴 getVolDir(4 行) 全部调用方,含 peer-S3
path 轴 getStorage() 装饰器 31 个远程入口 + 嵌套字段

核心逻辑不到 40 行。不改任何 handler,不改 xl-storage.go 的调用点,不碰本地 erasure 路径。

两个细节值得记录:

  • 校验 必须 在 join 之前、针对原始参数。pathJoin 对绝对 drivePathClean,会把前导 .. 彻底抹掉——/drive/../../etc 变成 /etc,join 之后的检查看起来干干净净,实则放行一切。这是未来重构最可能悄悄撤销修复的方式。
  • NSScanner 是唯一不经 getVolDir 就触达文件系统的方法,它的守卫行是 承重的,不是顺手加的。

被否决的方案里最值得一提的是"在 33 个 pathJoin(volumeDir, …) 处加 containment"——那是真正的纵深防御,但要在全树最性能敏感的文件里改 33 处、每处单独判断卷根是否为合法目标。护栏测试用极小代价买到了大部分同等的抗漂移能力。这是一笔明确记账的欠条:若日后新增不经 getVolDir 就触达文件系统的路径,该决定必须重新评估。

施工日志:我们自己制造的四次回归

这部分不好看,但比修复本身更有信息量。

第一次:空白字符。 第一版把空白当分隔符,于是拒绝了 " "" " 这类 合法 S3 对象键。而 PutObject 正是经 RenameData 提交对象——这类键会在每块远程磁盘上同时失败,写入 quorum 崩溃。讽刺的是:漏洞需要 root 凭据,这个 bug 只需要用户传一个空格。

第二次:纯反斜杠键。 同一函数、同一根因。path.Clean 从不把 \ 当分隔符,所以 Unix 上 "\\" 是普通文件名。拒绝它导致 分布式集群拒绝单机服务器接受的写入——同一套 S3 API,行为随部署拓扑而变。

第三次:Windows 的空格与句点。 Win32 规范化层从组件末尾剥离空格和句点,因此只由这些字符构成的组件会消失、路径解析到父目录。这意味着 Windows 上 " ""..." 都是卷根别名。而 "..." 当时正躺在我们自己的"合法对象名"正向清单里——不仅漏了向量,还主动断言过它合法。

第四次:负数 part size。 新加的守卫只拒绝"正数尺寸 + 参数不可用",把"非零"等同于了"正数"。而负数走的是另一条通往 0 的路。

两次假绿

AppendFile 的验收测试时连续两次拿到无意义的绿灯:

  1. 用 REST 客户端发请求——客户端对 *bytes.Reader 特判并据其推导 Content-Length,伪造值被静默覆盖。
  2. 换不透明 reader——Go 的 http 客户端自己拒绝 发送 body 短于声明长度的请求。

最终改用 httptest 直打 handler 才成立。教训:客户端的自我保护不是服务端的防御,而攻击者用裸 socket 没有这些顾虑。

还有一次是设计层面的:我们实现过一版"逐字段反射投毒"测试,然后否掉了它——它无法区分"该拒绝却放行"和"本就该放行的非路径字段"(ETagAlgorithm…),会把正确行为报成失败。

最隐蔽的一次在 fuzz 里:第一版属性测试把"只由分隔符组成"的字符串写成 例外并提前 return。那不是例外,是 盲区——fuzz 被亲手挡在这个类别之外,跑一百万次也不可能找到纯反斜杠键。例外写错,比没有 fuzz 更危险,因为它给人"已经搜过"的错觉。

一个高度集中的模式

组件 语义来源 回归数
guardPaths 复用既有 hasBadPathComponent 0
getVolDir 守卫 复用既有 hasBadPathComponent 0
isVolumeRootAlias 自己发明的 3
guardErasureParams 自己发明的 1

复用既有语义的部分零回归,自己发明规则的部分贡献全部回归。

这不是巧合:hasBadPathComponent 已经作为对象层自己的规则(经 IsValidObjectPrefix)被真实 S3 流量验证多年,结构上不可能拒绝任何能通过 S3 API 创建的东西;而新造的规则只有作者的想象在背书。

可操作的结论:能复用就别造;非造不可时,属性测试必须用排除法而非枚举法,例外要用独立于实现的谓词表述,且越少越好。

护栏比补丁重要

最终测试里有两个方向、缺一不可:

  • 可证伪性——逐个临时移除守卫,确认测试真的变红(穿越守卫移除后 191 个子测试失败;分配守卫移除后 64 GiB / 10.4 GB 现形)。这一环正是被否决的社区 PR 所缺失的:它的测试断言 err != nil,而目标本就不存在,漏洞完好无损时也会通过
  • 合法流量 fuzz——断言凡 IsValidObjectName 接受的键守卫必须接受(196 万次执行无违例),以及合法 bucket 名必须通过 getVolDir(81 万次)。这一环是我们自己前两版缺失的。

再加一个方法级反射护栏:给 StorageAPI 新增未守卫的带路径方法时,按方法名报错

这些护栏存在的理由,历史给得很直白:CVE-2026-39414 也是 2026-04-15 点修复、两个月后 才来一个 fix: complete ...。加上本次,“点修复 → 记录成闭合 → 数月后补完"在这个 fork 里已经出现两次。问题不是谁不小心,而是树里没有任何东西能告诉你一个类别仍然敞着。 护栏就是把"必须有人记得"变成"CI 会失败”。

关于对抗审查

本次修复经历了五轮独立对抗审查,五轮各命中一条被漏掉的问题,五次全部成立:空白键 → 反斜杠键 → AppendFile 分配 → Windows 空格/句点 → 负数 part size。

同期我们的自查也确实找出两条(WithDeadline 的日志放大、ReadParts 用错规则),但那是在被逼到那个严谨度之后。

这个命中率说明的事情很直白:合并前的最后一道关应当是独立验收,而不是作者的自我结论。 在这次修复中,作者四次判断"可以发版",三次被推翻。

后续状态

初稿列出的两个实现缺口现已在本地分支关闭;但截至 2026-08-03,下面这些后续提交都尚未进入公开服务端版本:

  • ReadFileHandler 已有上界。 提交 b6f70ab08 会拒绝超过 5 GiB 的声明读取长度;这是该旧式整文件 bitrot 路径所代表 S3 part 的最大尺寸。合法的 GiB 级读取仍可能按相同量级分配内存;这里消除的是超过格式真实上限、由调用方任意指定的分配,并没有假装大读取毫无成本。
  • 负数 part size 既不能写入,也不能被信任。 提交 80e8eaa42AddVersion 写入收口点拒绝该值,并在 CheckPartsVerifyFile 再次校验,因此既覆盖新写入的毒化元数据,也覆盖已经落盘的历史元数据。内部节点边界使用同一个谓词。
  • 非正数 erasure block size 在构造时即被拒绝。 提交 80e8eaa42NewErasure 校验 blockSize,覆盖单独给 ShardFileSize 加守卫无法覆盖的其他 offset 与 decode 除法;rebalance 中独立的除法在自身边界另行校验。

仍有两项限制,不能被打包进更强的结论:

  • Windows 无 CI——发布 Windows 构建,测试只跑 Ubuntu。Windows 规则是从文档化的 Win32 行为推理而来,未经实机验证。
  • 符号链接——containment 校验是词法防御,与上游一致。

尾声

上一篇说"关闭一个 endpoint,与关闭一个缺陷类别,是两种不同结论"。这次已知 sink 已在本地分支关闭,代价是四次自制回归和三次被推翻的"可以发版"。发布仍是独立的一道门:以上修复尚未进入公开服务端构建。

如果只留一句:漏洞是上游的,我们的错在于把点修复当成了闭合。 而防止它第三次发生的,不是更仔细的人,是会失败的测试。

10 - 缺失不是空:一个空 versionid,和它诱发的 fail-open

一条只允许删除未指定版本对象的策略,把这类删除全都拒绝了。而那个显而易见的一行修复,会把这个 fail-closed 的麻烦,变成 Multi-Delete 上的 fail-open 绕过。条件值必须变成服务端真正据以操作的那个版本。

状态: 已在本地 pgsty/minio 分支修复,提交 744a9dcd7尚未发布 定级: 策略执行正确性——一个 fail-closed 的报告、一个被避免的 fail-open 陷阱、以及顺手关掉的一处窄绕过。不是重点 CVE——见我们如何定级 影响范围: 任何在 s3:versionid 上使用 NullStringEquals 的桶/IAM 策略;报告中的失效发生在 DeleteObject/DeleteObjects 跟踪: 上游 minio/minio issue #21735(报告者 iTrooz,2026-01-10);上游仓库自 2026-04-25 起归档只读

本文记录了一个尚未发布的修复,以及相邻路径上两个尚未修复的同类残留(治理绕过与 Snowball)。请在修复发布、残留完成分诊之后再上线。

结论先行

  • 策略引擎判定 Null 看的是 切片长度,不是内容。MinIO 无条件 往条件 map 里写了 "versionid": {""},于是一个未指定版本的请求仍然呈现出一个长度为 1 的切片。Null:{s3:versionid:true}——“仅当键不存在时命中”——因此 永远不可能 命中,而 Null:false 永远 命中。报告者那条"只允许删除当前对象"的策略,把每一次当前对象删除都拒了(HTTP 200 外壳,逐对象 AccessDenied)。
  • 那一行修复是个陷阱。 “只在非空时写入这个键"修好了报告,同时打开了一个更糟的口子。DeleteObjects 把每个对象的版本放在 XML body 里;条件构造器只读 查询串。去掉那个空键,body 里的版本就直接从 map 里消失了——被读成不存在,也就是 null——于是一条本意保护旧版本的策略会 授权删除某个指定版本。fail-closed 的缺陷,遇上 fail-open 的绕过。
  • 真正的修复有两部分:只在指定了版本时写入这个键,并且DeleteObject 把它绑定到 服务端解析出的有效版本ReqInfo.VersionID)——即 DeleteObjects 循环里已经逐对象解析好的 body 值——而不是查询串里碰巧带的任何东西。
  • 顺路关掉的第三个相邻口子:构造器读版本时 没有 trim,而对象层会 trim,于是一个带尾空格的 ?versionId=V%20 能在读/打标签/复制这些路径上绕过 Deny StringEquals s3:versionid "V"
  • 继承自上游,且无法在上游修。 minio/minio 已归档只读,修复只能落在 fork 里;这正是我们在条件来源加固里加固过的那个 getConditionValues

缺失不是空

MinIO 策略里的一个条件键,会解析成一个 map[string][]string 里的小写名;引擎回答 Null 的方式,是去问那个切片有多长(silo-pkg .../policy/condition/nullfunc.go):

func (f nullFunc) evaluate(values map[string][]string) bool {
	rvalues := getValuesByKey(values, f.k)
	if f.value { // Null:true —— "这个键必须不存在"
		return len(rvalues) == 0
	}
	return len(rvalues) != 0 // Null:false —— "这个键必须存在"
}

字符串的内容从不被读取。切片 {""} 的长度是 1。对这个函数来说,一个 存在但为空 的值,与一个真实版本 ID 无从区分,而两者都是 不存在 的反面。

再看喂给它的那个值,继承下来的样子(cmd/bucket-policy.gogetConditionValues):

args := map[string][]string{
	// ...
	"versionid": {vid}, // 任何未指定版本的请求,vid 都是 ""
	// ...
}

vid 是请求的 ?versionId,在绝大多数调用里都是空的。于是每个请求——不论有没有版本——到达引擎时都带着 versionid: [""],永远长度为 1,永远"存在”。

于是 Null 的两个方向都反了:

请求 map 状态 Null:true(要求不存在) Null:false(要求存在)
未指定版本 {""}(长度 1) false——永不命中 true——永远命中
?versionId=abc {"abc"}(长度 1) false true
(正确行为) 未指定版本 不存在(长度 0) true false

报告者写的是那条经典的"允许客户端删除当前对象、但不能回滚版本"策略——Allow s3:DeleteObjectCondition {"Null": {"s3:versionid": "true"}}——然后看着每一次未指定版本的删除都返回 AccessDenied。那条 Allow 从未生效,因为它的条件测的是"没有指定版本",而 map 坚称永远指定了版本。StringEquals 同样看不出区别({""} 和不存在都无法与一个非空策略值相交);只有 NullForAllValues:* 对它敏感,这就是它为什么在 Null 上浮现。

隔壁的 fail-open

显而易见的修复是只在非空时写入这个键,对单个 DeleteObject 这完全正确:没有版本 → 不存在 → Null:true 命中。但只发这一条,Multi-Delete 就会把它变成一个授权绕过。

DeleteObjectsPOST /{bucket}?delete)不把版本放在查询串里。每个对象各自把它可选的版本放在 请求体 里:

<Delete>
  <Object><Key>photo.jpg</Key><VersionId>a1b2…</VersionId></Object>
  <Object><Key>notes.txt</Key></Object>
</Delete>

条件构造器读的是 r.Form——查询串——而没有任何地方把 XML body 并进去。于是在那个天真的修复下,一个在 body 里指定了版本 a1b2… 的条目,产出的是 空的 查询版本,键被省略,引擎看到的是 不存在——null。一条本意只允许删除 null 版本的策略现在命中了,运维本想保护的那个特定旧版本被删掉。报告里那个 fail-closed 的小麻烦,变成了 fail-open——而且恰好发生在最需要逐对象限定的那个操作上。

这正是报告者那个简单例子掩盖的关键:条件值必须是 服务端将要为这个对象实际操作的那个版本,而对 Multi-Delete 来说,那个值待在一条构造器从没看过的通道上。

修复:有效版本,而非顺手的版本

两个机制,因为单独任何一个都是错的。

其一——诚实地表达"不存在"cmd/bucket-policy.go)。只在请求指定了版本时写入这个键,让"没有版本"成为一次长度为 0 的读取:

if vid != "" {
	args["versionid"] = []string{vid}
}

其二——把 DeleteObject 绑定到有效版本cmd/auth-handler.goauthorizeRequestWithTags)。DeleteObjects 循环已经把每个条目的 body 版本解析进了 ReqInfo.VersionID(经 checkRequestAuthTypeWithVIDcmd/bucket-handlers.go:502,一个顺序循环——不存在共享状态竞争)。授权把条件值重新绑定到那个服务端解析出的字符串,并在它为空时删除该键:

conditionValuesForAuth := func(lc string, cred auth.Credentials) map[string][]string {
	values := getConditionValuesWithTags(r, lc, cred, existingTags, requestTags)
	if action == policy.DeleteObjectAction {
		// DeleteObjects 把有效版本放在每个 XML object 里,
		// 而不是请求查询串里。把授权限定到该条目。
		if versionID == "" {
			delete(values, "versionid")
		} else {
			values["versionid"] = []string{versionID}
		}
	}
	return values
}

一个端到端测试在 DeleteObjects 的 URL 上挂了一个 &versionId=query-level-decoy,并断言它绝不进入任何条目的判定——逐对象的 body 值胜出,诱饵被剥掉。

为什么只对 DeleteObjectAction,而不是无差别地用 ReqInfo 重绑。 那个诱人的简化——“永远用 ReqInfo.VersionID"——会弄坏复制。对 CopyObject,源读取是以 GetObject 针对源的版本来授权的,而那个版本走在 x-amz-copy-source 头里,getConditionValues 已经从那里提取;复制时的 ReqInfo.VersionID 装的是 目标 查询串(通常为空)。无差别重绑会用错误的版本覆盖掉正确的源版本。每一个非删除的版本相关操作(Get、Head、打标签、保留、复制源读取)都把版本放在查询串或复制源头里,两者构造器都读,而对单个对象来说两者 就是 有效版本。只有 Multi-Delete 会分叉。所以这个覆盖恰好和分叉一样宽,不多一分。

服务端据以操作的,是 trim 过的那个版本

删除路径正确之后,还剩一个口子。构造器读版本时是原样读的:

vid := r.Form.Get(xhttp.VersionID) // 未 trim

而每一条真正 使用 版本的路径都会先 trim——newContextcmd/utils.go:806)和 getOptscmd/object-api-options.go:101)都 strings.TrimSpace。于是在非删除的版本相关操作上,一个带尾空格的 ?versionId=V%20"V " 呈给策略引擎,而对象层读取/打标签/保留的是版本 "V"。一条以 StringEquals s3:versionid "V" 为键的 Deny——“保护这个确切版本”——看到的是 "V ",匹配不上,不生效;对 "V" 的操作照常进行。窄绕过(攻击者必须知道版本、且知道一个空格在下游不改变任何东西),但确实存在。

修复对两处读取都 trim,让条件值与有效版本对齐:

vid := strings.TrimSpace(r.Form.Get(xhttp.VersionID))
// …… 复制源回退同理

DeleteObjectAction 本就免疫,因为它用的是已经 trim 过的 ReqInfo.VersionID。trim 不引入任何新的放行:它只能让条件值等于实际操作的那个版本,从而在同一个方向上收紧 Deny、修正 Allow。我们通过只去掉 trim、看着 padded 用例转红,证明了它是承重的。

它影响了什么

报告里的失效在删除上,但底层这个键被许多动作读取。修复之后,每一条版本相关的调用链都以服务端为该操作解析出的版本来评估 s3:versionid

调用链 s3:versionid 来源 有效
单个 DeleteObject ReqInfo.VersionID = trim 后的查询串,经覆盖
DeleteObjects,逐条目 ReqInfo.VersionID = XML body 版本,经覆盖 ✓——fail-open 已关
GetObject / HeadObject / Select 查询串,现已 trim
对象打标签 / 保留 / legal-hold 查询串,现已 trim
CopyObject / CopyObjectPart 源读取 x-amz-copy-source 版本,现已 trim
匿名 404-vs-403 探测 查询串(只读)
Admin / KMS / metrics / STS 无版本概念

有一条伪造路径此前已被条件来源那次工作关掉,值得重述:versionid 是一个保留的内部键(versionid 与规范化的 Versionid 两种拼写都保留),所以客户端无法通过头/查询串合并循环注入第二份。线上参数拼作 versionId(大写 I),会落进一个引擎从不读取的惰性 args["versionId"]

两个方向的影响,因严重程度不同而分开陈述:

  • 功能性(报告本身): 未指定版本的删除被错误地 拒绝。fail-closed——一个可用性与易用性缺陷,不是放行。
  • 安全性(陷阱与 trim): 那个天真的修复会在 Multi-Delete 上 放行 对受保护版本的删除(fail-open);而未 trim 的值在读/标签/复制上放过了一处窄的 Deny 绕过。修复在第一个能存在之前就关掉了它,在第二个已存在的地方关掉了它。

我们如何定级

我们不为此签发 CVE,诚实的理由值得写出来。

报告者提交的行为是 fail-closed:MinIO 拒绝了策略本意允许的操作。一个过于严格的系统不泄露任何东西,也不放行任何东西;它是正确性与易用性缺陷,把一次误拒膨胀成漏洞,会让这个编年史里每一条真实条目贬值——它左右两边是认证绕过和路径穿越。

真正带安全分量的不是报告,而是它的邻近区域。Multi-Delete 上的 fail-open 是真实的,但那是我们 本会引入 的隐患,不是已发布的——两段式设计的价值,正在于那个危险版本从未存在于任何构建中。trim 绕过 确实 存在过,但很窄:它要求一条以确切 s3:versionid 为键的 Deny、一个知道版本的攻击者,且只影响非删除路径。我们关掉它,是因为它触手可及,而不是因为它是头条。

所以:策略执行正确性,归档在这里,因为我们把静默的执行失效记在这里;安全意义如实记录,而非包装。

我们没有越过的边界

两个同类残留仍在,记录下来而非静默留置:

  • Multi-Delete 里的治理绕过。 当某个条目带对象锁时,enforceRetentionBypassForDelete 会以 BypassGovernanceRetentionAction 重新授权(cmd/bucket-object-lock.go:153)。那个动作不是 DeleteObjectAction,所以有效版本覆盖不生效,它的 s3:versionid 仍是查询值——在正常 Multi-Delete 里为空——而不是被绕过锁的那个逐条目版本。
  • Snowball tar 解包。 PutObjectExtract 在逐文件授权 之后 才从 tar PAX 记录 minio.versionId 取每个成员的版本,于是一个从未出现在任何条件值里的指定版本可能被写入。

两者都窄、都是既有行为,且都会把改动从"修好报告里的那个键"扩大成"把每个动作的版本都重新接进 ReqInfo"。我们限定在报告的这个面上,把欠条写在这里,理由和上一篇记录它对象层省略时一样:一个没有记录的刻意省略,半年后与疏忽无法区分。

一个相关的、被否决的决定:usernameuseridsignatureversionauthType 这几个同类键仍然被 无条件写空,带着 versionid 刚摆脱的那个存在但为空的缺陷——Null:{aws:username:true} 永远为 false,包括对它本该命中的匿名调用者。修它们每个都是一行,却是四十个调用方的影响面,而且有些(principaltype 从不为空)根本不带这个 bug。我们没有把一次广泛的存在性清理塞进一个 versionid 修复里;它作为下一根要拉的线头,记在这里。

证伪

三个实验,遵循那条纪律:一个你从没看它失败过的测试,还不算测试。

  • 把两个源文件回退到 HEAD 端到端 DeleteObjects 测试转红,每一个未指定版本的条目都返回 AccessDenied——对 issue #21735 的忠实复现——而单测直接抓住了 {""} 键(“一个不存在的 versionId 被暴露给了策略评估”)。打回,转绿。
  • 只去掉 TrimSpace padded 用例在那条确切断言上转红——got [7f4b6b5f-…dd8 ]——证明这个 trim 不是装饰。恢复,转绿。
  • 那个诱饵。 Multi-Delete 测试在 URL 上挂 &versionId=query-level-decoy,并断言它抵达不了任何条目的判定,这正是"读查询串"与"读逐条目有效版本"之间的分水岭。

改动只碰了五个文件(cmd/bucket-policy.gocmd/auth-handler.go、两个测试、一处文档示例),在一个同时存在无关并发工作的工作树里用显式路径提交,所以邻近那些工作的任何改动都没有被卷进来。

起因与来源

报告是上游 minio/minio#21735,2026-01-10 针对 RELEASE.2025-09-07T16-13-09Z 提出:一条 Null:{s3:versionid:true} 策略拒绝了未指定版本的 DeleteObjects。上游仓库于 2026-04-25 归档只读,所以没有上游修复可等,也没有维护者可协调——fork 是唯一的去处,这里的记录就是结案。

这个缺陷很老,且是继承来的。只要这个键存在,getConditionValues 就一直无条件写 versionid;基于长度的 Null 语义是上游的,在 fork 经由 silo-pkg 消费的那个 policy 包里。这与此前那次"阻止客户端输入影子化服务端派生条件值"的条件来源加固,是同一个函数、同一条脉络——一次关于"一条策略条件被允许相信关于请求的什么"的相关阅读,在这里延续为"它必须相信服务端将要实际操作的那个版本”。

结语

缺失不是空。一个无法通过"不放这个键"来表达"没有版本"的 map,会用"把值留空"来表达它;而一个数长度的 Null,会在每一个没有指定版本的请求上相信有版本被指定了。

如果只留下一句:fail-closed 的 bug 才是危险的那种,因为显而易见的修复会把它翻成 fail-open——所以把条件绑定到服务端实际操作的那个值,从操作实际读取的那条通道取,而不是那条顺手的通道;而当你止步于报告的这个面时,把你留在错误通道上的那些版本写下来,别指望下一个人自己找到它们。

11 - 对象授权,越界到桶:当 bucket/* 能改写桶本身

一个尾部斜杠,让一条只该管对象的 IAM 授权 arn:aws:s3:::bucket/* 触及了桶级操作——包括能把桶变公开的 PutBucketPolicy,以及 issue 自己复现的 DeleteBucket。我们做了窄的、对 Deny 无损的修复,并用一个问题决定它的最终大小:够到这个动作,能拿到对象权限本来就给不了的东西吗?

状态: 已在 pgsty/silo-pkg main 修复(3c24ad1,由 1f97549 扩展,并在 v3.11.0 收敛为最终的十二个动作),已发布为 silo-pkg v3.11.0;pgsty/minio 已消费该版本 定级: 访问控制加固——一条被收窄恢复的权限边界 影响范围: 仅被授予对象级(arn:aws:s3:::bucket/*)访问的 IAM 用户/角色/服务账号,且集群被多个租户共用的部署 跟踪: 上游 minio/minio issue #20449(2024 年起公开,至今未关闭)

先说结论

  • 在 IAM 策略匹配中,桶级请求携带的是 空对象名,匹配器把资源串拼成了 "bucket/"。于是对象级的策略模式 "arn:aws:s3:::bucket/*" 命中了它,一条本应只覆盖对象的授权 连带授予了桶级操作
  • 最危险的是 PutBucketPolicy。一个只拿到 bucket/*s3:* 的租户,可以装一条 Principal:"*" 的桶策略,把桶变成 匿名公网可读或可写,或给自己授予桶级控制权。同一机制、同一类别的还有:DeleteBucket/ForceDeleteBucket(正是 issue 自己复现的动作)、PutReplicationConfiguration(外泄)、PutBucketLifecycle(批量删除)、PutBucketVersioningPutBucketObjectLockConfiguration,以及其余的桶配置写入。
  • 全量 修正是一次双向的行为变更:它既收紧过度授予的 Allow 语句, 放松过度阻断的 Deny 语句;而且会撤销 大量真实部署今天就写成 bucket/*ListBucket/GetBucketLocation 授权。那是一次兼容性破坏,不是一个干净的补丁。
  • 所以我们做了一个 窄修复:第一轮覆盖六个敏感的桶配置写入,第二轮定为 十二个——那些"够到它就能拿到对象权限给不了的东西"的桶级写入,外加四个没有任何 handler 实现的动作。只作用于 Allow 语句,因此绝不削弱任何 Deny,也不削弱任何 NotResource 排除,并附带一个环境变量逃生舱。兼容敏感的读/列举族、CreateBucket、以及三个租户可能合理使用的桶写入 按决定保持原样
  • 我们两次断言这个变更只会收走权限,两次都被没测到的情形推翻——第二次是由对一个 已发布版本 的独立复核发现的。受保护路径现在要求资源 同时 命中裸桶形式与历史形式,于是这条性质在构造上成立,而不再依赖论证。
  • 修复在匹配器层 红/绿验证 通过,并通过真实 handler 端到端验证;对象级热路径未被触碰。

斜杠,与那个空对象名

每一个桶级 S3 操作,鉴权时对象名都是空的——checkRequestAuthType(ctx, r, policy.PutBucketPolicyAction, bucket, "")。IAM 匹配器把它拼成资源串,而对空对象名的情况补了一个尾斜杠:

resource.WriteString(args.BucketName)
if args.ObjectName != "" {
    // "bucket/object"
} else {
    resource.WriteByte('/') // "bucket/"  <-- 缺陷所在
}

"bucket/" 会被通配模式 "bucket/*" 命中,因为 * 匹配空串。于是一条把 s3:* 授在 arn:aws:s3:::bucket/* 上的策略——读起来是 “任意操作,但只作用于 bucket 里的对象”——被评估成了也授予桶级操作。匿名/公开访问走的桶策略评估路径从来没有这个斜杠,是正确参照;只有 IAM 这条路径是错的,而且只错在一个地方。

这是上游 minio/minio 的 #20449,2024 年提交。上游早期的一次尝试直接删掉了斜杠,当天就因打破依赖旧行为的策略而被回滚。我们从那次回滚里吸取的教训,塑造了下面的修复。

它到底能做什么

PutBucketPolicyHandler 只有一道鉴权闸,闸后什么都没有。IAM 检查一过,调用者就能为该桶存入 任意 合法桶策略。

在多租户集群里的确切攻击链:

  1. 管理员给租户 A 发策略 Allow s3:* on arn:aws:s3:::bucket-a/*,本意是 “A 只能操作 bucket-a 里的对象,别的都不行”
  2. 因为那个斜杠,A 可以对 bucket-a 调用 PutBucketPolicy
  3. A 装上 { "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::bucket-a/*" }bucket-a 里的每个对象现在 对匿名公网可读;换成 s3:* 就是公网可写。把 Principal 指向 A 自控的账号即可外泄数据;在这条桶策略里给自己授桶级操作就是自我提权。

同一条对象级授权也能触及其它桶配置写入,后果相当:复制到攻击者的目标桶、一条一天过期的生命周期规则删光桶内内容、关闭版本控制、篡改对象锁保留策略。这些都不该从一条只作用于对象的授权里被触及。

它不可远程利用,也不需要任何缺失的凭据——调用者是你 主动 授予了受限策略的、已认证的主体。在单租户部署里,这个主体就是你自己信任的用户,现实风险很低;在共享的多租户集群里,它是一次真实的跨租户边界失效。

为何做窄修,而非整条边界

显而易见的修法是:对所有桶级请求都不再补斜杠。我们没这么做,原因有两条,比那一行 diff 所暗示的更重要。

它会打破常见的、良性的用法。 这个修正撤销的不只是危险的桶写入——它也会撤销通过 bucket/* 授予的 ListBucketGetBucketLocationListBucketMultipartUploads。大量部署正是这么写、并依赖它的。证据就是上游自己的测试套件:11 个 STS 集成测试把 s3:ListBucket 授在 bucket/* 上,然后断言列举能成功。连写服务端的项目都这么写,生产策略里只会更多。一次维护升级把这些变成 AccessDenied,正是我们拒绝带给用户的那种意外。

它切的是两个方向。 匹配器对 AllowDeny 拼的是同一套资源串。所以全量修正在收紧过度授予的 Allow同时,也放松了过度阻断的 Deny:一个用 Deny s3:* on bucket/* 锁死某个桶的管理员,会悄无声息地失去对桶级操作的那层保护。一个看着干净、却同时把安全推向两个方向的修复,不是维护补丁——它是一次迁移。

于是我们把改动收窄到"明确正确、且几乎零兼容代价"的地方:

  • 只保护桶级写入。第一轮覆盖六个敏感配置写入:PutBucketPolicyDeleteBucketPolicyPutReplicationConfigurationPutBucketLifecyclePutBucketVersioningPutBucketObjectLockConfiguration;第二轮(见下)扩到十二个。几乎没有人会故意用对象级模式去授这些——你不会不小心依赖"一条对象授权还能改写桶策略、甚至删掉桶"——所以撤掉这条路径基本不打破任何人。
  • 只作用于 Allow 语句。 Deny 语句保持历史资源串,因此任何现存 Deny 都不会被削弱。窄修永远只 增加 拒绝。
  • 读/列举族原样保留。 bucket/* 上的 ListBucket 照常工作。那是兼容敏感的部分,它等。

修复

匹配器在所有情况下都保留尾斜杠,只有一个例外:一条桶级 Allow 语句,正在为一个受保护动作求值,且兼容开关关闭。

resource.WriteString(args.BucketName)
if args.ObjectName != "" {
    // "bucket/object" —— 不变
} else if args.BucketName == "" {
    resource.WriteByte('/') // KMS 两阶段哨兵 —— 不变
} else if legacyBucketResourceMatch.Load() ||
    statement.Effect != Allow ||
    !isSensitiveBucketMutation(args.Action) {
    resource.WriteByte('/') // Deny / 非敏感 / 开关开:历史行为
}
// else:裸 "bucket" —— 对象级 "bucket/*" 不再授予它

因为 args.Action具体请求动作,通配授权(s3:*)也被覆盖:通配在动作匹配那步命中,而轮到拼资源串时动作已经是具体的 PutBucketPolicy。裸桶资源(arn:aws:s3:::bucket)和 * 资源仍然命中,所以正确划定范围的授权——包括内置的 readwrite 策略——都不受影响。

逃生舱是 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on,启动时读一次。它恢复完整的历史行为——过度授予和过度阻断两个方向都恢复——供任何在调整策略期间仍需旧语义的运维使用。

第二轮,以及决定它大小的那个问题

第一轮保护了六个配置写入就停了。对照原 issue 复查后发现这不够:#20449 自己复现的那个动作——DeleteBucket——仍然可以 通过对象级授权触及。对第一轮的构建跑了一次端到端测试,证据很干脆:一个只持有 s3:* on arn:aws:s3:::bucket/* 的用户调 RemoveBucket,桶没了。

扩大集合于是引出真正的问题——扩到哪里?第一直觉是"除 CreateBucket 外的全部桶级写入",十五个动作。这个直觉是错的,原因藏在这个 bug 的触发条件里。

这个 bug 只有在语句本身已经授予了那个桶级动作时才会触发。 资源匹配发生在动作匹配之后,所以一个只拿着 s3:GetObject on bucket/* 的只读租户,永远走不到 DeleteBucket——动作那一步就没匹配上。现实中受影响的主体必然持有 s3:*,也就是说 他对这个桶里的每个对象本就有完整的读、写、删权限。这就重塑了每个候选动作的严重度:该问的不是"这个动作抽象地看有多危险",而是"在一个已经握有全部数据的位置上,够到它还能多拿到什么"。

按这个标准,三组自然分开:

保护——够到它能拿到对象权限给不了的东西。 PutBucketPolicyDeleteBucketPolicy 能把访问权发给 别的 主体(包括匿名),也能给调用者自己补上从未授予的桶级动作:自我提权与公开暴露。PutBucketObjectLockConfigurationPutBucketVersioning 击穿的,恰恰是专门用来"防住有写权限的人销毁数据"的保护。PutReplicationConfigurationPutBucketLifecycle 以服务端凭据运行,并在调用者权限被吊销后继续生效。DeleteBucketForceDeleteBucket 不可逆地销毁桶实体及其配置。

零成本保护。 PutBucketCorsDeleteBucketCorsPutBucketQOSPutInventoryConfiguration 在今天的 MinIO 服务端没有挂任何行为——要么根本没有 handler,要么 handler 在鉴权之后直接返回 NotImplemented。收走它们不影响任何能用的东西,并且万一将来接上了 handler,保护已经提前就位。

刻意不保护。 PutBucketTaggingPutBucketEncryptionPutBucketNotification 都是桶级写入,这一轮的初稿确实把它们纳入了保护,后来又拿了出来。三者都不给调用者任何它还没有的访问权——受损的是所有者的合规姿态,不是访问边界;而一个拿到 s3:* on bucket/*、并被告知"这个桶归你"的租户,完全合理地会去给它打标签、设默认加密、配事件通知。用很低的安全收益去换实打实的兼容成本,在维护版本里是个错误的交易。它们保持历史匹配,并且现在有一条测试断言它们 受保护——于是把其中任何一个加回去,都是一次带可见代价的明确决定,而不是往列表里添一行。

最终留下十二个动作,随 silo-pkg v3.11.0 发布。另有两条更早的边界原样不动:CreateBucket 保持历史匹配(它作用于一个还不存在的桶,而供应流程常用租户自己的凭据去创建租户的桶),读/列举族 照旧等待那次带迁移路径的变更。

换句话说,选择破坏面时用的筛子是"管理员会不会故意这么写",而不是"这个动作听起来有多危险"。前一个问题预测哪些升级会炸,后一个只决定紧迫性。

那个错了两次的论断

上面这一切都建立在一条性质上:这个变更可以收走权限,但绝不能新增权限。 而我们两次断言这条性质时,凭的都是把机制"推理"过一遍,而不是"测试"过一遍。两次都是错的。

第一轮把省略的斜杠同样作用在了 NotResource 匹配上——而 NotResource排除Allow s3:* NotResource bucket/* 这样的语句,历史上不会作用于该桶的桶级请求;把排除拿去和裸桶名匹配,排除就不再命中,于是它所限定的那条 Allow 反而 变宽 了,而且恰恰是在受保护的那些写入上。让 NotResource 恢复历史形式修好了这一处,第二轮随即发布,并写着结论是"可证明地单调"。

对那个版本做的独立对抗性复核,在一小时内就给出了反例。省略斜杠并不只是"少了一次匹配"——它改变了 模式所匹配的那个字符串,而一个模式完全可能匹配 "mybucket",却从来匹配不上 "mybucket/"。最干净的例子是定长通配:

Allow s3:PutBucketPolicy on arn:aws:s3:::mybucke?

? 恰好匹配一个字符。对九字符的历史串 "mybucket/" 它匹配不上,所以这条语句从来没有授予过那个桶级写入;而对八字符的新串 "mybucket" 它匹配上了,于是这次加固 授予了 有缺陷的匹配器都拒绝的东西。影响面很小——你得写一个长度敏感的模式——但它恰恰属于那条性质本该排除的缺陷类别,而且发布说明里还写着那条性质成立。

修法不是再加一个特例。在受保护路径上,匹配器现在要求 两种形式同时命中:裸桶名,以及历史的 "bucket/"。结果是与历史判定取交集,于是它 在构造上 就是单调的——不存在任何它能新满足的模式,也不再有下一次会推理错的论证。mybucket* 照旧授予(它本来两种形式都匹配),mybucket/* 照旧被收走,mybucke? 被拒绝——和它一直以来的行为一样。这随 silo-pkg v3.11.0 发布。

除了补丁本身,有两点值得带走。鉴权路径上的正确性修复,绝不能让任何东西变成新允许的——而确认它的唯一办法是把两个方向都测一遍,因为在这两次里,推理给人的感觉都是无懈可击的。以及:当一条安全性质是承重的,就 用一个不可能违反它的操作把它构造出来,而不是用一份你认为已经穷尽的分情况讨论。

回归测试现在钉死每个方向:授权收窄、Deny 不动、NotResource 排除不动、定长通配不被放宽、三个不保护的写入仍然可达,外加一条不变量测试确保受保护动作个个都是纯桶级动作(ResetBucketReplicationState 名字唬人,实为对象动作,不入集合)。在服务端,它们通过真实 handler 端到端运行——客户端、内联会话策略、以及 S3 路由三个层次——并且每一条在带缺陷的那个版本上都会失败。

你会察觉到什么

对绝大多数人:什么都没有。 对象访问不变,bucket/* 上的 ListBucket 不变,写对了的桶策略不变。

唯一可见的变化:当一个请求试图 删除桶,或修改桶的策略、复制、生命周期、版本控制或对象锁配置,而它凭据的唯一匹配授权是一条对象级的 bucket/* 模式时,现在会返回 AccessDenied。桶标签、默认加密与事件通知 不受影响。这就是那条边界在被执行。如果某个部署确实依赖旧行为,设置 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on,并按自己的节奏把这些动作授在裸桶 ARN(arn:aws:s3:::bucket)上。

我们刻意留下的口子

#20449 的一般性问题——bucket/* 仍会触及剩余的桶级操作:ListBucketGetBucketLocation、各类配置 读取CreateBucket、以及上面那三个租户可能合理使用的写入——在这里 没有 被修复。彻底关掉它意味着撤销真实部署所依赖的授权,所以它属于将来一次带迁移路径的发布。

那次发布欠运维的东西,比"一份更长的动作清单"要多,因为 没有人能穷举所有部署的策略写法——这意味着靠猜去放大或缩小保护集合,收益有一个硬上限。有三件事能抬高这个上限:

  • 启动时的策略审计。 遍历存量策略,逐条点名哪一条的含义会改变,授予与拒绝两个方向都点。它把"升级后的意外"变成"升级前的清单",而且它是只读的,甚至可以 先于 强制生效单独发布。
  • 会自我解释的拒绝。 当一个请求因为"只有对象级授权匹配上"而被拒时,就把这句话说出来,并点名那个兼容开关。一次 30 秒能自诊断的破坏,成本比静默破坏低一个数量级。
  • 带作用域的开关。 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH 今天是全有全无:只想要回一个动作的运维,被迫连自我提权那条路一起重新打开。按动作粒度的作用域,才是让这个变更敢被采纳的东西。

把边界写下来而非默认:今天修正的是十二个桶级写入。其余的——读/列举族、CreateBucket、以及桶标签、加密、事件通知——在那次带迁移的变更落地前,仍按决定把 bucket/* 当作桶级授权。

收尾

一个补上的斜杠,把 “只有对象” 变成了 “连桶也算”。诱人的修法是到处删掉斜杠,而这么做会打破半个世界都在依赖的列举写法,并悄悄削弱每一条写在 bucket/* 上的 Deny。我们发出的修复,只在"一条对象级授权本就绝不该触及"的地方删掉它——那些能把桶变公开的写入,和能把桶删掉的写入——别处一概不动。其余的都写了下来,等一个"打破它是被提前告知、而非凭空降临"的发布。

12 - 让客户端源地址真正可信

一个以单个请求头命名的开关,被当成了防住三个头的手段。关掉 X-Forwarded-For 之后,X-Real-IP 和 Forwarded 接着回答,于是 aws:SourceIp 和每一条审计客户端地址,对任何能连到 API 端口的人来说仍然可伪造。修法是一个可选的可信代理边界——而更值得记录的决定,是我们拒绝去修那个旧开关。

状态: 已合入 pgsty/minio master,提交 fe6dc4780尚未发布 定级: 可选加固 + 文档缺陷,不是漏洞,也不是回归;未申请 CVE。底层弱点继承自上游,本次未改变其默认行为 影响范围: aws:SourceIp 策略条件、审计日志 remotehost 字段、S3 事件通知的 Hostmc admin trace 显示的客户端——凡是 S3 API 端口无需经过清洗请求头的代理即可抵达的部署 上游: 无处可报——minio/minio 已归档。上游既有记录:PR #4736(2017,疑虑被提出并被半途解决)、discussion #17878(2023,维护者标记为符合预期)、PR #20977(2025,那个只管一半的开关)

本文明确指出:在可直连的 MinIO 部署上,IpAddress 策略条件不可执行,而且本次改动之后默认情况下依然如此。这是上游 MinIO 出厂即有的性质,不是本 fork 引入的缺陷,而且从来没有在任何地方被写下来过。把它写出来正是本文的目的。

结论先行

  • MinIO 从三个可互相替代的请求头里读取客户端地址——X-Forwarded-ForX-Real-IP、RFC 7239 Forwarded——只有三个都不存在时才回落到 TCP 连接。这个地址会成为 aws:SourceIp 和审计日志的客户端字段,所以谁控制它,谁就同时控制了基于 IP 的访问控制和每一条操作记录的归属。
  • 唯一存在的开关 _MINIO_API_XFF_HEADER=off 只压制了三者中的 一个。攻击者的应对是改发 X-Real-IP。而我们自己的代码注释当时正把它写作缓解手段。
  • “把 MinIO 放到反向代理后面"并不足够,有两个彼此独立的原因:K8s 上 Ingress 与 ClusterIP Service 惯常并存,代理并非唯一入口;以及通行的 nginx 配方对 X-Forwarded-For追加,会把客户端提供的条目留在最左侧——而那正是 MinIO 读取的位置。
  • 修法是新增一个可选设置 MINIO_API_TRUSTED_PROXIES,把本 fork 早先为 LDAP STS 限流建立的可信代理机制推广到全局。设为列表时,只采信名单内 peer 送来的转发头,并从右向左走链;设为 none 时,什么都不信。
  • 最有分量的一个决定,是我们推翻了自己。 第一版实现把 _MINIO_API_XFF_HEADER=off 的语义扩大到压制全部三个头。那是整个改动里唯一可能改变现存部署行为的部分,已经被回退。该开关保持上游的确切语义,上游的 TestXFFDisabled 原封不动保留下来作为凭证。
  • 净兼容性影响:对任何未主动启用新设置的部署为零。
  • 对抗式审查在第一版实现中找出四个缺陷,其中一个会让新设置对所有通过环境变量文件配置的部署 静默失效

这个地址究竟被用在哪

值来自同一个函数 handlers.GetSourceIPFromHeaders。把它的下游追一遍,问题的性质就从"日志细节"变成了"安全问题”:

消费方 为什么要紧
aws:SourceIpcmd/bucket-policy.go 决定 IpAddress / NotIpAddress 策略条件
审计 remotehost 调查其它一切事故时所依据的那条记录
事件通知 Host 作为事实流向下游消费者
mc admin trace 客户端 运维实时观察"谁在做什么"的视图

其中两项在不同意义上与安全相关。伪造 aws:SourceIp 是活的访问控制绕过:本意把某个主体限制在办公网段的 IpAddress 条件,只要声称一个该网段内的地址就满足了;NotIpAddress 的拒绝规则,只要声称一个范围外的地址就规避了。伪造审计地址更安静,也可以说更糟——它是回溯性地污染记录,即使不存在任何基于 IP 的策略也照样成立,而且直到需要查日志的那天才会有人发现。

另外值得一提:默认路径上这个值从不被校验为 IP 地址。Forwarded: for="_gazonk" 会被原样接受并返回,上游自己的测试就断言了这一点。

那个看起来像缓解手段的开关

internal/handlers/proxy.go 只拦住了一个头:

if enableXFFHeader {
    if fwd := r.Header.Get(xForwardedFor); fwd != "" {
        // ... 取最左侧条目
    }
}
if addr == "" {
    if fwd := r.Header.Get(xRealIP); fwd != "" {
        addr = fwd                       // 没有拦
    } else if fwd := r.Header.Get(forwarded); fwd != "" {
        // ... 取第一个 for= 元素      // 没有拦
    }
}

设置 _MINIO_API_XFF_HEADER=off 给攻击者带来的成本是一行:把 X-Forwarded-For 换成 X-Real-IP。更糟的是,关掉 X-Forwarded-For 会把信任 转移 到一个运维根本没考虑过的头上,于是这个开关可能让部署落到一个它的拥有者从未建模过的状态里。

它是怎么来的?上游 PR minio/minio#20977,全部动机就是一句:

Customer request to disable all XFF header handling, ping me in Slack for more details.

没有安全论证,没有提到 X-Real-IPForwarded,也没有任何公开讨论说明为什么拦一个而放两个。这个窄不是深思熟虑的作用域,是疏漏。这一点影响了设计,因为它意味着 没有人 决定过另外两个应该继续被信任——但正如后文所述,它最终并没有构成修改那个开关的理由。

上游早就知道,2017 年就知道

写这篇复盘时发现的最有意思的一件事是:这一切对上游都不是新闻。这段历史本身是一堂关于"安全决策如何腐化"的小课。

2017 年 8 月。 IpAddress / NotIpAddress 条件支持在 PR #4736 中加入。评审过程中,维护者 @harshavardhana 提出的正是本文讨论的这件事:X-Forwarded-For 极易伪造,最左侧那个条目是客户端自己写的,拿它做安全判断会让恶意客户端拿到对象。贡献者接受了意见,X-Forwarded-For 支持整个删掉,只留 X-Real-IP,理由是"这个头由代理设置,客户端改不了"。五天后合并。

这个理由对了一半,而没说出来的那一半正是全部问题所在:X-Real-IP 不可篡改,前提是前面的代理会覆盖它。没有任何机制保证这一点,也没有任何地方告诉运维这是一个承重假设。

今天。 X-Forwarded-For 被最先读取,排在 X-Real-IP 前面。2017 年的那个决定没有活下来——它不是被有意推翻的,而是在后续对条件值管道的若干次重构中溶解掉的。没有任何一次提交说"我们要把这个可伪造的头重新放回策略判断里"——而这恰恰是这类腐化发生的方式。

2023 年 8 月。discussion #17878 中,一位运维报告负载均衡后面的源 IP 不可靠。维护者的回答毫不含糊:在源 IP 不可靠可见的前提下,基于 IP 的限制不切实际,建议改用按标签或按命名空间做隔离。议题被标记为"符合预期"。

也就是说,上游自己的立场——由维护者公开陈述过——是 不要依赖 aws:SourceIp。这是一个站得住的工程判断。缺的是:运维在任何地方都遇不到这句话。它不在策略文档里,不在条件键参考里,也不在那个看起来能让它变安全的开关旁边。写一条 IpAddress 条件不会收到任何抱怨,它表现得就像生效了一样。

这个落差才是本次真正修复的缺陷,它也重新定义了这次改动的性质:白名单并不是在推翻上游的判断,而是把 2017 年那个疑虑变得 可以回答,提供给需要它的部署。而承担更重分量的其实是文档——把一份从 2017 年起就只存在于评审记录里、并且从 2025 年起被自家开关反向暗示的契约,正式写下来。

为什么"放到代理后面"不是答案

这是标准建议,而它以两种常见且彼此独立的方式失效。

代理不是唯一入口

K8s 上 Ingress 和 ClusterIP Service 惯常并存。Ingress 会清洗请求头,Service 不会,而集群里任何一个 Pod 都能连到它。大家以为安全边界是 Ingress,实际上是整个 Pod 网络。Pigsty 部署里也是同一个形状:负载均衡挡在 MinIO 前面,而服务端口在内网依然可达。

那条通行的 nginx 配方会保留攻击者输入

几乎人人复制的那一行是:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for 展开为 $http_x_forwarded_for, $remote_addr——它对客户端发来的内容是 追加。客户端发 X-Forwarded-For: 1.2.3.4,nginx 转发出去的就是 1.2.3.4, <真实客户端>。而 MinIO 取的是 最左侧 元素,也就是攻击者的那个。

所以一个配置正确、加固过、完全不可直连的部署,照样 可以被伪造,因为"取最左"和"追加式代理"这两个约定天然不兼容。默认模式下只有 proxy_set_header X-Forwarded-For $remote_addr;(覆盖)是安全的,而那不是运维从文档里抄走的那一行。

正是这条排除了"只改文档"的方案。部署纪律关不掉它;必须从链的另一端读,而那需要代码。

设计

我们没有另起炉灶,而是推广了本 fork 已有的机制。MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES——在 LDAP STS 限流那次工作中加入——已经实现了 CIDR 白名单解析、catch-all 拒绝,以及从右向左的走链,只是作用域限于限流分桶。解析器移到了 internal/config,现在两条路径共用它。

一个设置选定模式:

模式 MINIO_API_TRUSTED_PROXIES 源地址
不设防(默认) 未设置 与今天完全一致
谁都不信 none 一律为 TCP peer
白名单 地址与 CIDR 块列表 采信转发头,但仅限名单内 peer

白名单模式下,X-Forwarded-ForForwarded 从右向左读,跨过名单内代理的条目,第一个剩下的地址胜出。每一跳代理都会追加它实际看到的 peer,所以客户端注入的条目一定位于其代理写入的条目 左侧,走链在到达它之前就停住了。追加式代理由此变得安全。

GetSourceScheme 特意未动。它喂的是 S3 响应里 Location URL 的 scheme,不是策略判断;一并压制会让所有在代理上终结 TLS 的部署收到 http:// 的 URL。

利弊权衡

要不要改默认值

把默认改成不信任转发头,会让所有现存反向代理部署的 aws:SourceIp 和审计地址在一夜之间变成代理的地址。策略可能变成拒绝,审计连续性会断。

不改,则可直连的部署继续暴露。

决定:不改。 但"不改"不等于"不说"。这个代价现在被明明白白写进了代码注释和运维文档:默认模式下,IpAddress 条件不是访问控制,审计地址不是证据。把代价摆到台面上让运维自己选,好过替他们做一个会在发布窗口引爆的决定。

要不要扩大既有开关的语义

这是我们第一次做错、随后推翻的决定,也是整个故事里最值得记录的一段。

最初的需求是:当运维显式关闭转发头信任时,客户端不能再通过等价的头来伪造。最直白的读法是"把 _MINIO_API_XFF_HEADER=off 修成覆盖三个头",第一版实现也确实是这么做的。

支持扩大的理由并不弱。上游 PR 标题写的就是 “disable all X-Forwarded-For header handling”;其描述的作用域是审计日志与基于 IP 的访问控制,两者都关乎"不要相信客户端声称的地址";这个开关没有文档,受众很小;而且失败方向是安全的,回退到真实的 TCP peer 而不是攻击者可控的值。

反对的理由最终占了上风。扩大它会改变一类真实人群的行为:那些代理对 X-Forwarded-For 是追加(脏)、对 X-Real-IP 是覆盖(干净)的运维,可能已经把 off 当作拿到正确地址的办法。上游的 TestXFFDisabled 恰恰断言了这个行为——开关关闭且两个头都在时,X-Real-IP 胜出——所以这个行为不只是顺带发生的,它被一个测试钉住了。这些运维会看到审计地址悄无声息地从真实客户端翻成他们的代理,IpAddress 条件也可能开始拒绝。

推翻的关键在于把 目标机制 分开。目标是"存在一种完整、可执行的关闭方式",从来没有任何东西要求这个能力必须由那个已有变量来提供。用 MINIO_API_TRUSTED_PROXIES=none 表达,能得到完全相同的保证,而对任何没有主动启用的人影响为

决定:_MINIO_API_XFF_HEADER 保持上游定义,一字不动。 它只拦 X-Forwarded-For,且在任何一种信任模式内部生效。上游的 TestXFFDisabled 原样保留并继续通过。文档现在明确写出这个开关 不是 什么:它是一个解析开关,不是信任边界;被拒绝一个头的客户端,换一个发就是了。

还有一个附带好处:这把两个互相影响的变量收敛成了一个策略设置,于是不再存在"off 优先级高于白名单"这样一条需要运维去学、也需要我们去写对的规则。

环境变量还是配置子系统

trusted_proxies 注册为 api 子系统的键,能获得 mc admin config 可见性、帮助文本和热加载,与 sts_trusted_proxies 的做法一致。

但它也会引入一个窗口。配置子系统在对象层初始化之后才加载,于是从进程启动到配置生效之间,信任策略是空的——按照"列表为空即信任所有人"的读法,这就是 fail-open。安全边界不能有 fail-open 窗口。另外,一个能在运行时改变的信任边界,本身也谈不上是好事。

决定:用环境变量。 代价是可发现性,以及下文描述的一个 bug。

loopback 作为 peer 永远可信

FTP 与 SFTP 前端通过 127.0.0.1 连到 S3 层,并用 X-Forwarded-For 声明其会话的客户端(cmd/sftp-server-driver.go)。不豁免 loopback 的白名单,会把每一个 FTP/SFTP 请求都记成服务器自己。

代价是同主机上的任何进程都能伪造。这可以接受:能从 localhost 发起连接的攻击者已经在该主机上取得了代码执行能力,威胁模型早在此之前就已经输了。而 FTP/SFTP 的退化则是必然发生的,影响所有使用这些前端的人。

决定:把 loopback 豁免为可信 peer。 随后对抗式审查发现,第一版实现同时也把 loopback 当成了可跳过的 链条目——这对 FTP/SFTP 场景毫无必要,而且有害,详见下文。现在这是两个分开的判断。

X-Forwarded-ForX-Real-IP 的优先级:真正无解

白名单模式下,两个头同时存在时谁赢?

  • 代理只写 X-Real-IP、透传客户端的 X-Forwarded-For(某些 nginx 配置)→ 优先 X-Forwarded-For 就取到伪造值。
  • 代理只写 X-Forwarded-For、透传客户端的 X-Real-IPAWS ALB)→ 优先 X-Real-IP 就取到伪造值。

两者都很常见,而服务端 无法从请求本身判断 自己处于哪一种。这不是"还没决定",而是在运维不告知其代理写哪个头的前提下 不可判定

决定:优先 X-Forwarded-For 两者之中只有它携带可以对着白名单校验的链,而这条路径判的是访问控制而非限流分桶,所以应当让可验证的那个赢。同时它让请求头优先级与默认模式保持一致,切换模式时不会额外再变一次优先级。

这与 getSTSLDAPTrustedProxySourceIP 有意相反,后者优先 X-Real-IP。同一个代码库里存在两套互相矛盾的实现,本身就是隐患,所以两处现在都带上了注释,点名这个分歧及其理由,以免后人在没有重新决策的情况下把它们"统一"掉。运维文档给出了两个方向各自的缓解办法:把你的代理 负责写的那个头,在边缘删掉。

白名单写宽了,比不写还糟

这是最反直觉、也最容易咬人的一条性质。

名单内的条目在走链时会被跳过。于是因为负载均衡是 10.0.0.1 而配置的 MINIO_API_TRUSTED_PROXIES=10.0.0.0/8,会让 10/8 内的所有客户端也变得可跳过。位于 10.5.5.5 的客户端发送 X-Forwarded-For: 8.8.8.8,形成的链是 8.8.8.8, 10.5.5.5;走链把 10.5.5.5 当作"可信跳"跨了过去,返回 8.8.8.8

所以一份过宽的名单不只是多信任了一些 peer——它让这些 peer 具备了伪造能力。nginx 的 set_real_ip_from 在开启 real_ip_recursive 时有完全相同的性质。

这没有算法解:这份名单同时承担了"谁可以转发"和"谁的地址可以被丢弃"两个职责,把它们拆开就意味着两份需要保持同步的名单。决定:保留单一名单,把约束写得足够显眼——运维文档里的醒目提示块,以及走链处代码注释里的明文规则。catch-all 只拒绝 /0,文档也明说了这是护栏而非证明,因为 0.0.0.0/1,128.0.0.0/1 覆盖同样的范围。

多节点内部转发

MinIO 会在节点之间转发请求,用于 bucket-DNS 路由、列举续传、heal-by-token、批量作业与存储池下线。接收节点的 TCP peer 是转发节点,不是客户端。

模式 接收节点解析出
默认 客户端
none 转发节点
白名单但未含节点地址 转发节点
白名单且含节点地址 客户端

这不是冷僻路径:ListObjectsV2 的 continuation token 里带着节点索引,任何客户端都能让自己的请求被转发。在 none 之下,该请求随后会以一个内网节点地址作为 aws:SourceIp 参与判定——而一条允许内网段的 IpAddress 条件会把它当作通过。

决定:写进文档,并引导多节点集群使用白名单。 none 无法为此情形修正,因为它按定义什么都不信。自动把集群自身地址注入名单的方案经过考虑后被否决:它需要启动时做 DNS 解析、并在节点地址变化时重新解析,机制和失败模式都比它所替代的显式配置更多。

兼容性

改动被刻意组织成"风险不均匀分布"的形态。在默认配置下,每一块要么是 opt-in,要么是死代码。

改动 谁会受影响 风险
MINIO_API_TRUSTED_PROXIES 白名单 只有主动设置的人
转发器清洗 X-Real-IP / Forwarded 默认模式下该代码路径不执行
值非法时启动失败 只有主动设置且写错的人
环境文件加载后重读策略 什么都没设时结果相同
LDAP 解析器抽取共用 无人——纯代码搬家,已验证行为一致
_MINIO_API_XFF_HEADER 语义 无人——已回退
_MINIO_API_XFF_HEADER 读取时机 无人——有意保留上游时机

默认路径的凭证。 unverifiedSourceIP 是原函数体的逐字拷贝,连同它的怪癖一起:", " 分隔符、最左元素为空时的向下穿透、以及对 _gazonk 这类非 IP 值的接受。独立审查针对 HEAD 在 21 个用例上验证了行为一致性——空 X-Forwarded-For、单个逗号、开头的 ", "","", " 分隔符的差异、" , "、IPv4-mapped 地址、带括号的 IPv6、非 IP 垃圾,以及三种 Forwarded 形式。上游的 TestGetSourceIPTestXFFDisabled 均原样保留并通过。

自审时抓到的一处微妙之处。 新设置是在 MINIO_CONFIG_ENV_FILE 加载之后读取的,这正是它能在打包部署中生效的原因。顺手把 _MINIO_API_XFF_HEADER 也挪到同一处读取看起来很整洁——而那会构成行为变更,因为上游是在包初始化时读它的,那时环境文件 还不存在。今天,把它写进环境文件的运维实际上是被静默忽略的;一旦顺手接管,这个早已部署的设置会突然开始生效,把他们的源地址从 X-Forwarded-For 最左条目翻成 X-Real-IP。所以那个旧开关不仅保留上游的语义,也保留上游的读取时机,并且有一个测试钉住它,免得后人来"整理"。这个怪癖改为写进文档:想让它生效,就设在进程环境里。

那段并没有在防御任何东西的防御性代码。 共用解析器最初还附带了两样东西:把以 IPv4-mapped 形式书写的白名单条目还原成它所指代的 IPv4 前缀,以及在匹配时对地址做 unmap 和去 zone。两者看上去都像修正——写成 ::ffff:192.168.1.10 的条目否则会被接受却什么都匹配不上,这种静默失效确实值得消除。

但它们最终还是被删掉了,理由值得记下来。两条调用路径在匹配之前都会先做 net.ParseIP(...).String(),而这一步本身就把 ::ffff:10.0.0.1 收敛成了 10.0.0.1;双栈监听器给 IPv4 对端报的本来也是点分形式。所以这两样东西在任何真实请求上都执行不到。它们唯一可观察的效果,是改变了这个 共享 函数对更早使用它的 LDAP STS 白名单意味着什么——那 18 处差异,只有直接调用函数的测试能看见,没有任何部署能碰到。

更糟的是,其中一样还亲手制造了下文那个 fail-open:把 ::ffff:0:0/96 还原之后,一个 /96 变成了 0.0.0.0/0。删掉这个还原,是在移除 bug 的 成因,而不是靠调整检查顺序去绕开它。剩下的部分是纯代码搬家,已在 37 个白名单取值 × 21 个 peer 地址的全部组合上验证与原实现完全一致——零解析差异,零匹配差异。它选择不修的那个瑕疵(mapped 形式的条目匹配不上任何东西)是既有行为、方向 fail-closed,现在写进了函数自己的注释里,免得下一个人重新推导出同一个诱人的"修复"。

唯一值得点名的残余风险。 默认模式的 代码路径 确实变了:原函数体前面多了一层 switch 和一次函数调用。如果这段管道本身有错,影响的是所有人而不只是启用者。上面的一致性测试是我们认为它没错的依据,但"在 21 个用例上验证等价"与"可证明完全相同"是两个不同的论断,诚实的说法是前者。

对抗式审查发现了什么

我们指派了一个独立 agent,任务是攻破第一版实现。它找出四个真实缺陷,现已全部修复并有回归测试覆盖。

一个静默的 fail-open,也是四者中最严重的。 信任策略是在包的 init() 里读取的。但 loadEnvVarsFromFiles() 会在很久之后才为 MINIO_CONFIG_ENV_FILE 里的每个键调用 os.Setenv——而那几乎是所有打包部署配置 MinIO 的方式。把 MINIO_API_TRUSTED_PROXIES 放进 /etc/default/minio 的运维,得到的会是历史上的"信任任意 peer"模式,而且 不报任何错;非法值也会被静默忽略而非致命。策略现在在 serverHandleEnvVars 中应用,它运行于文件加载之后、任何监听器启动之前。

loopback 被当成链条目跳过。 如前所述:peer 豁免被复用成了跳数豁免,这是"名单过宽"问题的一个不必要实例。现已拆成两个判断。

两个 fail-closed 的正确性 bug。 带 zone 的 IPv6 peer(fe80::1%eth0)会被规范化成空串,因为 net.ParseIP 直接拒绝 zone——于是这样的 peer 永远不可能成为可信代理。以及,用 IPv4-mapped 形式书写的名单条目(::ffff:192.168.1.10)在启动时被接受,随后却什么都匹配不上,因为 netip.Prefix.Contains 在位宽不同时恒为 false。

另外两个缺陷是在写文档而非写代码时发现的,这本身也是个小教训:

重复的请求头行。 Header.Get 只返回 第一行。HAProxy 的 option forwardfor新增一行 X-Forwarded-For 而不是扩展已有那行,于是 Get 会把客户端那行交回来,正好把伪造值放回到"从右向左走链"本要避开的位置。现已改用 Header.Values,把所有行展平成一条链处理。

内部转发器转发了客户端的声称。 internal/handlers/forwarder.go 仅在 X-Real-IP 缺失时才设置它,于是客户端的值会原封不动地在节点之间传递。在包含集群自身节点的白名单下——也就是我们推荐的配置——客户端由此可以借用一个 peer 节点的权威。转发器现在会在入站 peer 无权设置这些头时,丢弃 X-Real-IPForwardedX-Forwarded-For 无需如此处理,因为 Go 的 ReverseProxy 会追加真实 peer,接收节点的走链会先到达那个条目。

重构之后的第二轮

把设置改形之后,值得再跑一轮对抗式审查,而这一轮确实有收获:差分测试在 4,745,520 个源地址解析用例与 345,600 个转发器改写用例上,与 HEAD 相比 零差异;但它同时又找出三条 fail-open 路径——其中一条是第一轮的修复自己引入的。

用 IPv4-mapped 前缀夹带进来的 catch-all。 MINIO_API_TRUSTED_PROXIES=::ffff:0:0/96 按字面是 /96,因此通过了 catch-all 检查;而那个"把 IPv4-mapped 条目还原"的改写随后把它变成了 0.0.0.0/0,于是信任所有 peer。第一次修复把宽度检查挪到了改写之后。最终的修复是 把改写整个删掉——在弄清楚它对任何真实请求都不可达之后——这是移除成因,而不是给它的输出加一道岗。

这一条最值得多说几句。这个 fail-open 是由一个针对无关的 fail-closed bug 的修复亲手制造的;而针对这个 fail-open 的修复,又只是调整顺序、把制造它的那一步原封不动地留在了原地。两轮修正,各自都说得通,却都没有触及"这段代码本就不该在那里"。加固性改动需要接受与它所加固的代码同等的对抗式检验,而"这东西到底可达吗"这个问题,应该排在那份检验的前面。

一个有意写下、却谁也没点名的值。 MINIO_API_TRUSTED_PROXIES="," 会解析成空列表,然后回落到宽松默认。未设置 的空变量必须意味着"默认",但运维实际敲下的、却没有点名任何代理的值是一个错误,用"信任所有人"去回应它,恰恰是他们最不可能想要的那个行为。现在它是启动错误。纯空白仍然等同于未设置,因为那正是空 shell 变量展开后的样子。

一个读不出来的远端值。 MinIO 支持 env:// 间接寻址,即变量的值从远端 webhook 拉取。env.Get丢弃 该拉取的错误并返回空串——而这段代码会把它读作"未设置",于是恰好在无法确定运维意图的那一刻重新启用"信任任意 peer"。该设置现在改为经由 env.LookupEnv 读取,错误得以暴露出来并终止启动。这是任何经由 env.Get 读取的安全相关设置都存在的通用隐患,值得记在本次改动之外。

第三轮:从前两轮共有的盲区进攻

前两轮都是把解析器当作一个单元来攻击。有两件事它们都看不见:

从来没有测试验证过信任策略真的抵达了决策。 到那时为止的所有测试检查的都是解析器返回什么,而策略层唯一那个测试只断言 aws:SourceIp 等于 解析器的输出——而且是在默认模式下。也就是说,一个"解析器正确、但策略引擎读的是别的东西"的版本,可以通过全部测试。现在有一个测试把伪造的 X-Forwarded-For 一路推过 getConditionValues,进入真实的 IpAddress 判定,覆盖每一种模式:默认模式采信、none 忽略、来自未列名 peer 时忽略、来自列名代理时依然采信。它通过了——但它本该在这次改动被称为"完成"之前就存在。

白名单模式带有一处默认模式没有的资源放大。 走链之前会先把整条链拍平成切片,于是一个位于可信代理之后的客户端,可以把 1 MiB 的请求头额度变成每个请求约 33 MB 的切片头开销——大约三十倍——外加一次百万次迭代的遍历。默认路径从来没有这个问题,因为它在原始头上用 strings.Index。现在走链改为在头文本上就地反向扫描,零分配,并在 100 跳后停止;真实的链只有几跳,答案就在最右端,而预算耗尽则得不到地址,于是回落到 peer。有一个测试钉住零分配这一性质,因为这正是一次看起来人畜无害的重构会顺手破坏掉的东西。

本轮还纠正了一处:部署契约此前写得太窄。“代理必须覆盖它所设置的那些头"漏掉了真正会咬人的情形——一个只正确书写 X-Real-IP 或只书写 Forwarded 的代理,仍然会转发客户端的 X-Forwarded-For,而那恰恰是最先被读取的头。现在明确写成通则:把代理不负责书写的每一个源地址头都在边缘删掉。

有一条上报的发现经复核后 按缺陷处理:catch-all 守卫只拒绝 /0,所以 0.0.0.0/1,128.0.0.0/1 覆盖同样的范围却会被接受。收紧它意味着要在现已与 LDAP 白名单共用的解析器里拒绝"宽但非 /0“的前缀,从而让今天合法的配置开始启动失败,换来的只是防住一个现实中没有任何部署会持有的值。文档已明确写出这个检查是护栏而非证明,而真正的防线在那条"写代理本身、不要写它所在网段"的提示里。

推荐做法

按拓扑:

  • 代理可控,且 API 端口确实无从旁路。 什么都不用设。但要确认你的代理是覆盖还是追加:如果配置里写的是 $proxy_add_x_forwarded_for,那你今天就是可伪造的。改成 $remote_addr,或者启用白名单。
  • 直连暴露,没有代理。 MINIO_API_TRUSTED_PROXIES=none
  • Kubernetes 或 Pigsty,Ingress 加上可达的 Service。 用白名单,内含代理地址 MinIO 节点地址。这是唯一能让 IpAddress 条件具备意义的配置。
  • 任何多节点集群。 用含节点地址的白名单,而不是 none

所有启用白名单的部署还有两条通则。第一,写代理本身,不要写它所在的网段。第二,把你的代理不负责写的每一个源地址头,在边缘删掉——把一个 peer 列入名单,意味着相信它送来的全部三个头,而它们是按固定顺序被读取的,你的代理没碰过的那个头完全由客户端说了算。一个只正确书写 X-Real-IP、或只书写 Forwarded 的代理,仍然会把客户端的 X-Forwarded-For 原样转发过来,而它是被最先读取的那一个。

没有做的事

  • 自动注入集群节点地址。 通过 EndpointServerPools 技术上可行,但需要 DNS 解析以及地址变化时的重解析。相比它所替代的显式配置,判断认为显式配置风险更小。
  • 注册为 api 配置键。 见上文的 fail-open 窗口。若日后可观测性的价值超过启动期保证,可以重新讨论。
  • ExistingObjectTag/* 条件值那次工作留下的同类缺陷——它带的是请求自身的标签而非对象已存储的标签——按决定继续保留,且不受本次任何改动影响。

关于定级的判断

默认行为与上游一致,且 MinIO 从未把 aws:SourceIp 在可直连部署上宣称为可信,因此"默认不安全"更接近文档缺陷而非漏洞。但有一件事确实是缺陷:运维设置了一个明确的安全开关,而它没有做到其名称与唯一公开描述所暗示的事情,并且是静默失效。这值得一条记录,作用域应限定为上游 _MINIO_API_XFF_HEADER 的不完整,而不是 fork 引入的任何东西。

minio/minio 已归档,没有可协调的上游——与 CVE-2026-42600 处境相同。

13 - 解析器认识,Schema 不认:足以带走全部通知的配置键

NATS 的 JWT 凭据被读取它的那台服务器判为非法键。同样的缺口被写进了旧配置迁移,升级后的配置在每次启动时验证失败——而一个子系统失败就会清空整张通知列表。上游三个功能 PR 各自忘掉了同一次注册;第四个表面则在无声地写错值。

状态: 已在本地 pgsty/minio 分支修复,提交 162ded343尚未发布 定级: 配置 Schema 一致性与可用性问题,不是漏洞;附带一项防御性加固(验证报错不再回显凭据值) 影响范围: notify_nats 的 JWT/NKey/TLS-handshake-first 选项、notify_amqpimmediate,以及任何带着启用状态 NATS 目标从 2020 年前配置迁移上来的部署——它的失败会连带压掉 所有 通知后端 跟踪: pgsty/minio issue #39

本文点名了相邻代码中两个尚未修复的可用性缺陷(Postgres/MySQL 迁移写入、kvFields 键名吞并)。两者都不可利用——破坏的是操作者自己的配置,要么响亮要么根本不发生——且都已写进已提交审计测试的允许清单。发布无需额外扣留,等版本发布即可。

结论先行

  • notify_nats 的三个选项——user_credentialsnkey_seedtls_handshake_first——和 notify_amqpimmediate解析器在读,旧配置迁移在写,却没有任何地方注册CheckValidKeys 拒绝的恰恰是 GetNotifyNATS 需要的。
  • 一个常量身兼二职。target.NATSUserCredentials 的值是 "MINIO_NOTIFY_NATS_USER_CREDENTIALS",躺在环境变量常量块里,却 被当作环境变量名 被当作配置键使用。凭据文件认证的 snake_case 配置键在整个程序里根本不存在。
  • 报告者的报错不是他的命令触发的,而是来自 旧配置迁移:用修复前的迁移代码可以 逐字节 复现 issue 里的错误文本,连他的命令从未提过的 notify_nats:ONE 目标名都对得上。迁移只把存储写坏一次;此后 每次启动 验证都会拒绝它。
  • 爆炸半径来自放大器:FetchEnabledTargets 在第一个坏子系统上快速失败,唯一的调用方只记一条日志,全局目标列表保持 nil——一条坏掉的 NATS 配置就把 Kafka、webhook、MQTT 等一切通知无声关停。
  • 继承自上游。 三个功能 PR——#19139(2024-02,user_credentials)、#21008(2025-04,tls_handshake_first)、#21231(2025-04,nkey_seed)——每个都加了解析器和环境变量,每个都跳过了 Schema。上游已归档;缺陷与义务都由 fork 继承。
  • 修复内容:注册这些键;拆开一身二职的常量;修正迁移——包括一个把 immediate 的值写进 internal 键的隐蔽同胞 bug;仅在加载路径容忍磁盘上已有的旧拼写;让 两个 CheckValidKeys 形态的报错都不再回显值;再装上一个 AST 审计,把这一整类缺陷在全部十个通知子系统里机械地封死。
  • 墨迹未干,审计就抓到了下一例:Postgres/MySQL 的旧配置迁移会写入 五个 未注册键,其中一个是明文数据库密码。已记录、进只减不增的允许清单、列为后续事项。

报错点名了一个没人问过的目标

报告(issue #39,报告者 kuldeep-link11,环境是使用 JWT operator/accounts 认证的 NATS 集群)是一次干净的复现:给 notify_nats 配置凭据文件,看它被弹回来。

$ mc admin config set us notify_nats:FITCHECK \
    address=nats-1:4222 subject=events.object.created \
    MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/creds/minio_notifier.creds \
    jetstream=off queue_dir=/data/queue-fitcheck queue_limit=100000

mc: <ERROR> ... found invalid keys
    (MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/creds/minio_notifier.creds
     nkey_seed= tls_handshake_first=off ) for 'notify_nats:ONE' sub-system,
    use 'mc admin config reset myminio notify_nats:ONE' to fix invalid keys

这条报错里有两处怪异,是这条命令解释不了的。非法键列表里有 nkey_seed=tls_handshake_first=off——用户根本没传过;被拒绝的子系统是 notify_nats:ONE,而命令配置的是 notify_nats:FITCHECK

第二处怪异就是整个案子。评审者证明了 mc admin config set 这条路径 根本携带不了未注册键:服务端分词器 kvFields 是按 已注册 键名去切分输入行的,未知 token 从来不会成为键——它会被吸进前一个键的值里。直接探测:

输入:  subject=s MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/x.creds
存储:  subject="s MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/x.creds"

所以拒绝不可能是针对命令行的。那是 validateConfig 在横扫整个子系统时,绊倒在一个 早已存进存储、名叫 ONE 的另一个目标 上——它身上带着全部三个坏键。整棵代码树里只有一条路径会把这几个键名写进存储:旧配置迁移。用修复前的迁移代码驱动一个启用状态、名为 ONE 的 NATS 目标,issue 里的错误文本 逐字符 复现——连空的 nkey_seed= 都一样,那正是旧配置里没有 NKey 时迁移写出来的样子。

这就改写了事故的性质。这不是"服务器拒绝了我的命令",而是:一份旧配置被迁移过一次,迁移写下了三个验证器不认的键,从那以后这份存储每次启动都验证失败——并且因为失败只被记日志然后吞掉,它无声地压掉了其它所有通知目标。报告者的命令只是走进了爆炸半径,接住了别人的报错。

一个常量,两种含义

继承下来的声明(internal/event/target/nats.go,修复前):

const (
    NATSAddress  = "address"
    NATSSubject  = "subject"
    NATSUsername = "username"
    NATSPassword = "password"
    NATSNKeySeed = "nkey_seed"            // 配置键——形状正确
    // ...
    EnvNATSUsername     = "MINIO_NOTIFY_NATS_USERNAME"
    NATSUserCredentials = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"  // ← 在 Env 块里
    EnvNATSPassword     = "MINIO_NOTIFY_NATS_PASSWORD"
)

NATSUserCredentials 起了个配置键的名字,装着环境变量的值,躺在环境变量的货架上。解析器把它 两用:一次当环境变量去查,一次当配置键去存储 KVS 里读。迁移则拿它当键去 。整个程序里不存在 "user_credentials" 这个字符串——凭据文件认证的配置键压根没有出生,这正是报告者翻遍文档找不到键名、只好把环境变量名当键传的原因。

一个名字身兼二义,早晚会在其中一义上出错。这里它两头同时错:作为键,它是没注册的垃圾;作为唯一可用的拼写,它教会了用户和迁移代码去写垃圾。

四个表面,没有握手

这套代码里的一个通知选项活在四个必须一致的表面上:默认值DefaultNATSKVS——验证接受什么、mc admin config get 显示什么)、帮助HelpNATS——mc admin config 文档写什么)、解析器GetNotifyNATS——服务器实际读什么)、迁移SetNotifyNATS——升级会写什么)。没有任何机制把它们绑在一起。上游三个功能 PR 每个都更新了解析器和环境变量管线,每个都忘了前两个表面:

解析器读 迁移写 默认值 帮助 引入
user_credentials 读(经由二职常量) 写(写成环境变量名) #19139,2024-02
nkey_seed #21231,2025-04
tls_handshake_first #21008,2025-04
immediate(AMQP) 见下 配置 KV 重写时代

AMQP 那一行藏着更安静的同胞。AMQP 迁移没有跳过 immediate——它把 immediate值写在了 internal 键下,并把 cfg.Internal 整个丢掉:

config.KV{
    Key:   target.AmqpInternal,               // 错误的键
    Value: config.FormatBool(cfg.Immediate),  // 正确的值
},
// cfg.Internal:无处安放

因为 internal 已注册的,这一条能通过验证。NATS 的缺口把迁移后的配置弄坏得足够响亮、终归会被发现;AMQP 的缺口则 无声地写错——迁移出来的 broker 配置带着错误的开关,验收单上却干干净净。同一类缺陷,两种表现:未注册的键失败得干脆,走错门的值错得安静。

放大器

若没有聚合语义,这一切都配不上"停摆"二字。FetchEnabledTargets 遍历十个通知子系统,在 第一个 失败处返回 (nil, err);唯一的调用方记一条日志就继续走,全局通知目标列表保持 nil;后续所有查询在 nil 保护下拿到空列表。于是一个被拒的 notify_nats 目标就关掉了 全部 桶通知——Kafka、webhook、AMQP、MQTT,一个不剩——只在服务器日志里留一行。

我们考虑过改成按子系统隔离,决定在本次修复中不改。跳过坏子系统是对操作者体验的真实行为变更:今天的语义是聚合式响亮失败(全部停摆),已有部署对"验证是全有或全无"的预期是推理过的。重接这套语义是一项值得独立变更的兼容性决策,不该搭注册修复的车——而且注册修好之后,合法 配置根本不会再触发级联。这一决定已写成 FetchEnabledTargets 的文档注释,并用行为固化测试钉住:下一个动它的人要么有意为之,要么动不了。

修复

约一百行生产代码变更,由九百行测试托着(162ded343:8 个文件,+1029/−7)。

注册。 四个键全部进入所属默认 KVS 和帮助 Schema,摆在操作者会去找的位置(user_credentials 挨着 usernamenkey_seed 排在 token 后,tls_handshake_first 跟在 tls_skip_verify 后,immediate 挨着 mandatory)。注册同时决定可见性:这四个键现在会出现在 mc admin config get 的输出里,此前不会。

常量,拆开。 NATSUserCredentials 成为真正的配置键 "user_credentials";新增 EnvNATSUserCredentials 承载环境变量字符串。涉及的每一个环境变量名——MINIO_NOTIFY_NATS_USER_CREDENTIALS_NKEY_SEED_TLS_HANDSHAKE_FIRSTMINIO_NOTIFY_AMQP_IMMEDIATE 及其 _TARGET 后缀形态——逐字节冻结:它们是公开接口,全程可用(环境变量一直是可行的绕行方案),现在有测试用 裸字符串字面量 钉住它们,任何 Go 常量的重命名都无法再让它们悄悄漂移。

帮助标志,循先例。 两个新 NATS 值都是文件 路径.creds 文件;NKey 种子文件),故标 Sensitive 不标 Secret,看齐 cert_authority/client_cert/client_key 而非 password/tokenSecret 会连 mc admin config get 里都做脱敏——把操作者自己配的路径对他本人藏起来,这正是私钥路径 client_key 也从不带它的原因。

迁移,修正。 SetNotifyNATS 现在写真键;SetNotifyAMQP 同时写 immediate = cfg.Immediate internal = cfg.Internal

如果你今天在修复前的构建上受影响:环境变量路线一直可用;mc admin config reset myminio notify_nats:<target> 能以丢失该目标设置为代价解除毒化存储。在修复后的构建上,毒化存储直接恢复加载——见下一节。

与旧迁移已经写下的东西共处

修好迁移帮的是下一次升级,帮不了旧迁移已经写坏的存储——它们带着字面键 MINIO_NOTIFY_NATS_USER_CREDENTIALS,依旧未注册,依旧每次启动都致命。让这些操作者手工重置配置,等于用我们写下的错误惩罚他们。

所以加载路径窄窄地容忍它。验证 仅对 NATS 子系统 接受旧拼写——有测试断言 AMQP 依然拒绝它,容忍成不了通用逃生门——解析器只在真键为空时才回退去读它。优先级是 env > user_credentials > 旧键,而且 靠构造成立 而非靠约定:回退结果是作为环境变量查询的 默认参数 传入的。三种次序全部有测试。旧键刻意不进默认值、不进帮助:容忍,但绝不宣传、绝不可能新设(kvFields 保证了这一点)。

它的常量是包内字面量,不是 EnvNATSUserCredentials 的别名——刻意如此。它命名的是 已经躺在磁盘上的字节,不能跟着环境变量常量未来的任何改名走。注释就是这么写的。

接线时发现的一个陷阱,值得单独一段,因为它迟早咬人:这套代码里有 两个 CheckValidKeys——自由函数和方法——它们的 deprecatedKeys 参数含义 相反。自由函数 容忍 所列的键(跳过);方法 把它们从合法集合里减掉(拒绝)。把这处调用从一种形态重构成另一种,会把容忍无声地反转成封禁。这一不对称现已记录在调用点——在给导出 API 改名之外,这是能做到的最好的了。

容忍从写下之日起就带着退役方案:干净的终态是在加载时把旧键改写成 user_credentials,然后把容忍和回退一并删除。这就是下文的后续事项 #2——它还能顺手封上容忍留下的一个小洞:未注册键不带 Sensitive 标志,被容忍的旧键会把值(一个路径)原样送进健康诊断包,而 user_credentials 显示 *redacted*

报错里的秘密

引发这一切的非法键报错,是 连值一起 打印被拒键值对的:found invalid keys (MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/creds/minio_notifier.creds ...)。这些路径无伤大雅,机制却不是:任何骑在被拒键上的值——手滑打成 nkey_sed=<seed>、遗留 LDAP 键上的 bind 密码——都会落进服务器日志和 mc 客户端的终端。

两个 CheckValidKeys 形态现在都只打印 键名,保留原有形状和 mc admin config reset 提示。第二处超出了书面任务范围——方法形态服务于 LDAP、OpenID 和策略插件,那里被拒的值可能是真正的 bind 密码——被要求评判这次越界的独立评审说,换他会主动要求这么做:两处一模一样的泄漏只修一处,是半个修复。全仓库范围内没有任何代码从那个字符串里解析值,也没有测试断言旧文本;这一变更是全局的、有意的。

还有一个反向事实值得记录:正是这次脱敏,站在了 下一个 同类缺陷与日志中的凭据之间。下文的后续发现里,Postgres/MySQL 迁移在未注册键下写入明文数据库密码——在脱敏之前的构建上,随之而来的拒绝会把那个密码打印出来。

让这一类绝种的护栏

注册四个键,修好的是四个键。这一类——四个表面、没有握手——只要没有机制把表面机械地绑在一起,就仍然敞着。所以修复附带一个基于 AST 的审计测试:解析 parse.golegacy.go,从 target 包源码解析常量(没有会腐烂的手工清单),对 全部十个 通知子系统断言:

  • 解析器读到的每个键都注册在该子系统的默认值里;
  • 迁移写出的每个键都已注册(减去一份显式的、只减不增的允许清单——见下节);
  • 每条帮助条目都指向已注册的键。

对修复前的代码树跑它,恰好在四个已知缺口上失败、别处全绿——这正是它测的是对的东西的红色证明。

对抗评审随后用一组变异攻击装置去打审计本身,依据的是上一篇文章论证过的纪律——没看它失败过的护栏只是猜测。九个变异里七个被抓住,两个漏网,且都让审计 无声 失明:把解析器的循环变量改个名(读收集器模式匹配了接收者名 kv),或把迁移条目换成 Go 惯用的省略式复合字面量(写收集器要求显式的 config.KV{...})。两种情况下收集器返回 空映射,断言循环迭代零个键,测试空洞地通过。两者都是维护者不会多想一秒的重构;其中之一还是 gofumpt 会推着你去做的。

两项加固封住了缺口,每项都做了双向验证——有加固时变异被抓,去掉加固(反事实)空洞通过就回来:

  • 反方向的下限断言: 每个 已注册 键必须被 看见在读。这条今天对全部十个子系统成立——是量出来的,不是假设的,包括在嵌套条件里被读取的废弃 streaming_* 键——所以零成本;而失明的收集器现在会换来每个已注册键一条响亮报错(NATS 是 22 条),而不是一次绿色运行。
  • 放宽的字面量守卫: 类型既非 config.KV 也非 config.KVS 的带类型字面量跳过;无类型(省略式)字面量没有类型可查,现在会被检查而非无视。

终局比分:十个变异,十个全抓——装置途中添了一个变体,收敛轮全量重扫。审计还双向执行自己的允许清单——删掉仍需要的条目会失败,条目过期(迁移不再写那个键)也会失败,清单既不能悄悄变长,也不能对现状撒谎。

审计接着抓到的东西

写侧检查在两个与 issue #39 毫无关系的子系统上拒绝转绿。SetNotifyPostgresSetNotifyMySQL 写五个键——hostportusernamepassworddatabase——没有任何默认 KVS 注册它们,也没有任何解析器读它们。这些是 DSN 之前配置形态的遗物,迁移至今还在产出。驱动真实的迁移助手证实了它:迁移后的 Postgres 或 MySQL 通知目标在下次加载时被拒,报 found invalid keys (host, port, username, password, database)——和 NATS 同一种失败模式、同样每次启动都发作、同样经由快速失败波及全部通知。而那里的 password 是明文数据库密码——正是上文那次脱敏如今挡在日志之外的值。

它在本次变更中 刻意不修。范围锁定在 NATS 与 AMQP 的缺口上,而正确的处置(把五个键注册为废弃、或停止写出、或两者兼施)是值得独立走一遍红/绿循环的判断题。它被钉在审计的 knownUnregisteredWrites 允许清单里,配着只减不增的注释,不可能被悄悄遗忘:哪天有人修好它,过期的清单条目会让测试失败,索要属于自己的删除。

开放事项,截至 2026-08-04 均未进入任何已发布构建:

  1. Postgres/MySQL 迁移的未注册写入——major;任何带着启用状态的这两类目标从 KV 之前配置迁移上来的部署,每次启动都会发作。
  2. 旧 NATS 键的加载时改写,之后退役容忍与回退;顺带封上被容忍键在健康诊断包中的脱敏缺口。
  3. kvFields 键名吞并——mc admin config set 中未知键名会被无声吸进前一个键的值而不是报错。上游既有的老毛病;这回它没保护任何人,早晚会污染谁的 subject

评审记录

变更在提交前过了三道门:

方法 结果
实现者 先写测试、对未修改代码树运行;缺失的常量让测试套件 编译失败,这本身就是常量拆分的红色证明;定向回退产出其余运行期红色 每项主张都有红色在案
独立对抗评审 在修复前提交处的分离 worktree;不信报告、逐项重推红色;变异装置攻打审计;优先级边界探针;从迁移路径逐字节复现报告者的报错 REVISE,两项要求
收敛轮 两项要求全部落地;反事实变异运行(有加固与无加固各跑一遍)证明加固承重;评审重比对 diff、重跑、重变异 ACCEPT,10/10

按本系列的老规矩,如实入账:两项要求都不是生产修复里的缺陷。一项是 lint 门(两处英式拼写会让 make test 失败——而实现者在修它们时重写的注释又引入了第三处 dialled,被同一道门当场抓住;这项要求实时证明了自己)。另一项就是上文的审计失明——护栏的耐久性,不是变更的正确性。评审真正推翻的是事故的起源叙事:迁移路径复现、ONE 目标、kvFields 吞并证明,全部来自评审者,而它们改变了操作者应当得出的结论——这是一场埋伏在存储配置里的启动期停摆,不是一个 CLI 验证怪癖。

实现者的红色阶段也在修复落地前抓出了自己新测试里的三个 bug,记录在案而非抹平:一个夹具假设存储目标会叠加在默认值之上,而 config.Merge 实际是原样透传;一个行为固化测试在 nil HTTP transport 上段错误(FetchEnabledTargets 无条件解引用它——对测试不友好,已记录,未修);还有一版早期草稿把夹具钉在了修复恰好要改名的那个常量上,导致它在修复前也能绿——重写为字面字符串,钉住磁盘上的 Schema 而非 Go 符号。

拒绝了的,和留着的

刻意拒绝:

  • FetchEnabledTargets 的按子系统错误隔离——兼容性决策,不搭车(见上)。
  • 注册或宣传旧键——加载时容忍,默认值与帮助中缺席,无法新设。
  • 修正 EnvNatsTLSHandshakeFirst 的怪异大小写——fork 里的美观性改名是买不来任何东西的 diff 噪音。
  • 在本次修复 Postgres/MySQL 迁移——范围锁定,改钉进允许清单(见上)。

留着的:上面三项后续事项,外加一个外观后果——仍带着被容忍旧键的存储,在加载时改写落地之前,mc admin config get 会原样显示那个键。

结语

这些键每一个都能通过环境变量完美工作,这正是三个功能 PR 得以发布、过审、被使用,却始终无人注意配置文件那一半接口生来即死的原因。解析器和 Schema 是同一份契约的两份描述,靠手维护,横跨四个表面——两年半里,构建中没有任何东西检查它们是否一致。

如果只允许一句话留下来:当两个工件必须保持一致、而绑住它们的只有约定,分歧就不是风险,而是日程表——在它们之间放一台机器,然后变异这台机器,直到你亲眼看它抓住你害怕的那种漂移为止。

14 - 有序不等于递增:一个重复 Part 如何把对象变成两倍

5 MiB 的 part 只上传了一次,却被拼装了两次,服务端返回 HTTP 200 和一个 10 MiB 的对象。比较符是严格的,动词不是。十年之间两次重构都忠实地保留了它。

状态: 已在本地 pgsty/minio 分支修复,提交 22c1e41fd尚未发布 定级: 数据正确性问题,不是漏洞——见为什么这不是 CVE 影响范围: 所有后端;任何已认证的 S3 客户端,作用于它自己的上传 跟踪: pgsty/minio issue #49

本文有一节描述了相邻代码路径中一个尚未修复的进程级 panic。请在该问题修复并发布之后再上线。

结论先行

  • sort.SliceIsSorted< 比较符 并不检查严格递增,它检查的是 有没有逆序对。相邻相等不构成逆序,于是 [1,1] 被放行。
  • 上传一个 5 MiB 的 part,用 [1,1] 完成,服务端返回 HTTP 200 和一个 10 MiB 的对象。且该 upload 已被消耗:用正确清单重试得到 NoSuchUpload,客户端无法自救。
  • 继承自上游,而且很老。 这个检查从 2016 年 8 月起就是这个形状,2017 与 2023 两次重构都把它忠实地重写了一遍——因为每次重构保留的都是比较符,而 问题从来不在比较符上
  • 修复是 handler 层的一个循环。对象层 按决策 保持不设防,这张欠条写在这里,而不是留在某个人的记忆里。
  • 三次独立评审 都没有在修复本身里找到缺陷。它们找到的是一条把相邻守卫的作用写反了的注释——并顺着那条注释挖出了一个无关的节点级 panic。

问题在动词,不在比较符

继承下来的代码:

if !sort.SliceIsSorted(complMultipartUpload.Parts, func(i, j int) bool {
	return complMultipartUpload.Parts[i].PartNumber < complMultipartUpload.Parts[j].PartNumber
}) {
	writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidPartOrder), r.URL)
	return
}

它读起来是"除非 part number 严格递增,否则拒绝"。它不做这件事。IsSorted 只按反方向调用比较符:对每一对相邻元素问 less(i, i-1)——“这个元素是不是比前一个小”——一旦成立就判定为无序。对于两个相等的元素,这个问题的答案是否。没有逆序,所以有序。

这里有一个必须说准的推论,因为它正是这类误用能一路通过评审的原因:任何严格比较符都不可能让 IsSorted 拒绝重复。唯一可行的写法是非严格的那个——把 <= 作为 less 传进去,让相邻相等被判成逆序。也就是说,想要"严格递增",你必须写下那个读起来"不严格"的运算符。所有检查过"这里写的是 < 没错"的评审者,检查的都是正确的字符,只是在错误的函数里。

我们的替换直接放弃 IsSorted,而不是去把它拼对:

for i := 1; i < len(complMultipartUpload.Parts); i++ {
	if complMultipartUpload.Parts[i-1].PartNumber >= complMultipartUpload.Parts[i].PartNumber {
		writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidPartOrder), r.URL)
		return
	}
}

拒绝集的差异恰好是一类:含有相邻相等对的清单。此前被拒的仍然被拒,此前被接受的除重复外仍然被接受。非相邻的重复是白送的——严格递增蕴含全局互异,所以 [1,2,1][1,3,2,3] 必然含有一个逆序对而被拦下。

两次重构都忠实地保留了它

考古部分是这次事件里最可迁移的内容。

时间 形状 变更
2016-08 sort.IsSorted(CompletedParts(parts)) server: Move all the top level files into cmd folder (#2490) 时就已存在
2017-11 同一调用,Less 挪到导出类型上 Add public data-types for easier external loading (#5170)
2023-04 sort.SliceIsSorted(parts, func(i,j) bool { … < … }) simplify sort.Sort by using sort.Slice (#17066)

两次重构作为重构都是正确的:它们精确保留了行为,而这正是重构该做的事。2023 那次是一次全仓库范围的清理,跟 multipart 语义毫无关系。它原样搬运了 <,而 < 本身从来没错——CompletedParts.Less 必须是 < 才是合法的 sort.Interface

缺陷活在"比较符"与"接收它的函数"之间的关系里,而一次搬运比较符的重构看不见这层关系。 十年,三种形状,同一个行为:一个回答着"与它看上去要回答的问题相邻的另一个问题"的顺序检查。

它实际做了什么

在两个 erasure 后端上、经由真实的签名 HTTP handler 实测:

已上传 完成清单 响应 生成的对象 ETag 后缀
一个 5 MiB part [1,1] 200 OK 10,485,760 字节 -2
两个 5 MiB part [1,2,2] 200 OK 15,728,640 字节 -3
一个 5 MiB part(编号 10000) [10000,10000] 200 OK 10,485,760 字节 -2

ETag 后缀是服务端认为自己拼装的 part 数量。这里不存在任何可供发现的内部矛盾:元数据、大小、ETag 三者互相自洽,而且一起错。对象就是不等于用户上传的内容。

有两点让它比"错误码不对"严重得多。

upload 被消耗掉了。 拼装完整执行并清理了 multipart upload,所以用正确清单重试返回 NoSuchUpload。客户端即使发现大小不对,也无法通过重发正确清单挽回,只能整个重传——前提是数据还在。

它可以被无意触发。 不需要攻击者。任何把某个 part 在完成清单里追加了两次的客户端——断点续传封装、重试路径、拼接生成的清单,都是常见的出错方式——拿到的不是 400,而是一个静默翻倍的对象。

为什么这不是 CVE

它进入这个编年史,是因为它是一次静默的服务端正确性失效,而我们把这类事件记在这里。它不是漏洞,我们也不打算把它包装成漏洞。

请求必须携带调用者自己的凭据、指向调用者自己的 upload,受损的对象也是调用者自己的。没有跨租户影响,没有权限变化,没有信息泄露,也没有通往其他账户数据的路径。被打破的是"完成后的分段对象等于你上传的字节"这条保证——很严重,但它是一条正确性保证,不是访问控制边界。

编年表里它左右两边是认证绕过和路径穿越。把它挂上同一个标签,会让表里每一个标签都贬值一点。

边界选择,以及它的代价

对象层 完全没有重复防护erasureObjects.CompleteMultipartUpload请求 长度分配输出切片(cmd/erasure-multipart.go:1249),然后逐个把请求里的 part number 拿去现有元数据里解析(:1255)。同一个编号解析两次成功,写出两条相同的 ObjectPartInfo,尺寸也累加两次。AddObjectPart 确实按 part number 去重,但它去重的是元数据切片,不是请求。5 MiB 最小尺寸规则同样帮不上忙,因为被重复的那一份本身就合法。

我们修了 handler,没动这里。理由:

  • 它是 唯一存在客户端控制清单的入口。另外四个调用方——batch、restore、decommission、rebalance——都在服务端用 oi.Parts1..n 构造清单,构造上就严格递增。
  • 需要产出的是 S3 错误码,属于 API 层的关注点。对象层的错误词汇映射到另一个错误码,在更低层拦截反而给客户端更差的诊断。
  • 最小化。这个 fork 只发窄修复,而改动拼装循环不算窄。

代价明确记录,而不是暗示:唯一性不变量现在只有一个执行点,而没有任何东西去执行"必须有这个执行点"。 谁给对象层添上第五个调用方,编译器不会报错,测试也不会变红,他会拿到一个静默损坏的对象。这与上一篇记录的 getVolDir 那张欠条是同一类,写下来的理由也一样:一个没有记录的刻意省略,半年后与疏忽无法区分。

我们刻意没有加的约束

part number 不必从 1 开始,也不必连续。[1,3][5,9][3] 都是合法 S3,也都仍然能成功完成。

这件事比听上去重要。“顺手要求清单必须从 part 1 开始"是一行改动,看起来像收紧,能通过一次随意的评审,而且会打断合法客户端——任何在某个 part 上传失败后放弃它、用剩下的部分完成上传的实现。诱惑之所以真实存在,恰恰因为隔壁那个修复也在校验同一份清单。

所以有两个测试用例存在的唯一目的,就是让这种改动失败。我们通过注入该约束验证了它们真的会咬:恰好那两个用例转红,其余一个都没有。 一条从未被打响过的护栏只是一个猜测。

唯一一处我们没打算要的行为变化

用 14 组输入对修复前后做差分,除了重复被拒之外只有一处行为变化:[0,0][-1,-1]——既重复又越界的清单——从 InvalidPart 变成了 InvalidPartOrder,两者同为 HTTP 400。

我们接受它,依据是格式错误应当优先于状态错误:顺序违规不需要读取任何存储即可判定,而 part 是否存在需要。而且它只影响本来就注定失败的请求,不存在"原本成功现在失败"的客户端。

至于 S3 保真度本身,我们给出的是一个有据可依的推断,不是一次测量。AWS 把 InvalidPartOrder 定义为 parts 清单未按升序排列,并且明确 part number 可以不连续;重复不满足升序。我们没有对真实 AWS 端点实测,两位独立评审者是沿着同一条文档路径得出同一结论的——那是一致,不是证据。

证伪,以及一条写错的注释

两个变异实验,遵循上一篇主张的纪律:一个你从没看它失败过的测试,还不算测试。

注入"必须从 part 1 开始”。 恰好两个跳号用例转红,四个从 1 开始的正向用例保持通过。护栏是精确定位的,不是碰巧覆盖。

删掉相邻的 len(Parts) == 0 守卫。 预期结果是空清单会得到某个"错但有序"的错误。实际结果是 进程 panic:空清单一路抵达一个存储装饰器,那里在不检查长度的情况下取了 part 路径切片的第 0 个元素,而且发生在 recover 够不着的 goroutine 上。S3 面被那一行守卫挡住——它 2022 年就在那里,且没有任何地方记载它是承重的。该问题作为一个尚未修复的节点级缺陷单独跟踪,本文因此暂缓发布。

以及这段里最值得自曝的部分:我们为那个守卫写的注释是错的。 它写的是"删掉长度检查会让空清单 成功"——与事实方向相反,而且恰好是低估危险的那个方向。它在评审中被抓出并在提交前更正。一条把"某个检查为什么存在"说错的注释,正是三年后这个检查被人顺手清理掉的方式。

三次验收,零阻断发现

改动在提交前经过三道独立关卡:

关卡 方法 结果
作者 回退修复,看着测试在实测的 10 MiB 上转红,再打回,看着它转绿 红/绿成立
独立评审者 在自己的 detached worktree 里重建红态,而不是采信报告;14 组输入差分 无阻断发现
外部模型(不同厂商) 只读沙箱,独立推导拒绝集论证与 AWS 语义 带条件通过;条件是它在自己的沙箱里编译不了

直说,因为诚实的版本没有上面这张表好看:三方都没有在修复里找到缺陷。 评审真正产出的是那条被更正的注释,以及顺着它触发的变异实验挖出的那个无关 panic。这仍然是不错的回报,但它和"在补丁里抓到 bug"不是一回事,记录应当说清发生的是哪一种。

其中最值得抄走的细节是"重建红态"。复跑作者测试的评审者,检查的是作者的算术;独立重建损坏状态的评审者,检查的是作者的论断。

被否决的与留待处理的

刻意否决:

  • 两条测试补强——在已存在的目标对象上完成、以及让每个 part 内容各不相同从而验证拼接顺序而非仅验证总大小。两条都是真实的改进,都依据一条长期规则被否决:这个 fork 发的是正确性与安全修复,不是测试扩张;而且核心不变量已经被"被拒请求没留下对象、且 upload 仍可重试"钉住了。
  • XML 根元素名不做校验。 根元素写错、但 <Part> 子元素正确的文档会被接受。这不是绕过——同一份清单仍然要过同一个检查——它是既有行为,且收紧它有因 namespace 处理差异而打断真实 SDK 的风险。记录,不修。

留待处理,截至 2026-08-03 全都不在已发布版本中:

  • 对象层的 part 唯一性纵深防御(见上文)。
  • 存储装饰器里的空清单 panic,作为节点级缺陷单独跟踪。
  • XML 严格性,包括 <PartNumber>abc</PartNumber> 返回 500 而正确答案是 400 MalformedXML

并行进行的完成路径 checksum 工作(#46#48#50)与本次改动完全隔离,不共享任何代码。

结语

比较符是严格的,动词不是。十年间所有的阅读都在看那个比较符——包括重写了这一行的那两次提交。

如果只留下一句:要看这个函数拿这个比较做了什么,而不只是看这个比较写了什么;以及,当你决定让下面那一层保持不设防时,把它写在下一个人会绊到的地方,而不要指望他会自己重新推导出你的理由。