跳转到主要内容

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

返回本页常规视图.

SILO 安全编年史

PGSTY SILO 的 CVE 与 SN-2026 安全公告、根因分析、修复提交及升级指南。

本栏按时间从新到旧收录 PGSTY SILO 的安全技术公告。每篇公告列出受影响版本、触发条件、漏洞根因、代码修复与升级要求。

全部 CVE-* 与 SN-2026-* 编号、修复提交及依赖安全更新的单页汇总,见安全公告台账。

1 - 十月安全更新:Object Lock、管理凭据与 JWK 解析

SN-2026-015/016 交付边界、SN-2026-017 管理授权、依赖更新及 Server 20261018 的具体升级影响。

SILO Server 20261018 正式交付了 SN-2026-015、SN-2026-016 与 SN-2026-017 三项安全修复、细粒度 STS 令牌撤销机制,以及跨 silo、mcli 与 silo-console 的 Go 工具链与依赖库安全更新。Server 20260916 与 20260903 均受影响(20261015 为已被替代的预发布草稿)。

Object Lock:SN-2026-015 与 SN-2026-016

SN-2026-015 与 SN-2026-016 专题公告及配套修复设计方案详细记录了受影响的保留期修改与原地覆盖路径、复现步骤与缓解措施。PR #234(14042b5aa)与 PR #235(0d3ba5d4c)要求在读取对象前完成 s3:PutObjectRetention 入口鉴权,在削弱未到期 GOVERNANCE 锁时同时校验绕过请求头与 s3:BypassGovernanceRetention 权限,拒绝非复制请求传入 ?versionId= 覆写版本,并在对象写锁内保护 null 版本原地覆写。

所有接收 S3 写入的集群节点与复制站点均须升级至 20261018。

SN-2026-017:管理凭据授权边界

SN-2026-017 涵盖 PR #242(提交 2616d43fb)及批量访问密钥列举后续提交 995439260 修复的管理端(Admin API)凭据授权边界缺陷。

权限边界 20261018 修复前 20261018 修复后行为
自助查询与清理 “目标归属调用者”可覆盖显式 Deny 或插件拒绝;查询自身服务账号甚至可能跳过鉴权 保留普通用户的自助管理能力(DenyOnly),但严格执行用户/组显式 Deny、受限会话策略与外部授权插件的拒绝决定
受限凭据二次委派 获准执行 create/update 的受限派生凭据可创建继承父账号更宽权限的子密钥,或移除目标服务账号的策略限制 在内置授权模式下,带有效内联会话策略限制的调用者不得创建或更新服务账号(含 LDAP 创建及仅修改元数据的请求)
root 派生凭据保护 拥有宽泛 admin:* 权限的非 root 管理员可操作 root 账号派生的服务账号 仅经过授权的 root 凭据或不受限的 root 派生身份可管理 root 归属的派生密钥(内部站点复制专用账号除外)

传入 IAM 策略评估的操作主体始终为当前已认证的访问密钥(cred.AccessKey),父账号解析(ParentUser)仅用于归属关系比对。各 Handler 的完整契约与性能基准数据见 IAM Admin API 授权边界修复。

与安全修复同步落地的兼容性改进

  • 新签发服务账号的语义空策略归一化:创建服务账号时会过滤父凭据保留的策略/过期声明,并将语义为空的策略文档(null、{}、{"Statement":null})归一化为“继承父账号权限”,同时保留带版本号或显式空语句列表({"Version":"2012-10-17","Statement":[]})的封禁语义,防止新签发的带过期时间密钥因携带空策略而无法使用。
  • 损坏的非 root 服务账号清理:授权通过后的删除与查询路径利用已持久化的身份元数据处理损坏、禁用或已过期的非 root 服务账号,同时防止损坏的令牌隐藏 root 父身份。
  • 批量访问密钥列举(995439260):将统一的自助授权辅助函数扩展至批量访问密钥列表接口(ListAccessKeysBulk);省略 users、传入调用者自身密钥或父账号均解析为同一父账号清单,同时严格保留显式 Deny 与跨用户校验。
  • 错误码约定:被拒绝的自助操作返回 403 AccessDenied;无权删除其他用户或 root 服务账号的请求保持返回 404。

运维操作建议

  1. 使用生产环境中的真实普通用户、服务账号、STS 会话与外部插件身份验证管理操作。若自动化脚本此前依赖受限子凭据继续派生下级服务账号,或使用非 root 管理员管理 root 派生密钥,请改用具备相应权限的父身份或 root 身份。
  2. 审查受影响版本期间创建的子服务账号与策略变更,撤销异常密钥;多站点 IAM 维护请遵循 IAM 升级与恢复指南。

STS 撤销的后续修复

最终发布提交 0cd388c66 修复了 RevokeTokens 仅枚举本地内存缓存以及撤销状态未完整传播至同集群兄弟节点与远端站点的问题。服务端现为集群已持久掌握、匹配父身份/类型及操作时间截点的令牌保留删除墓碑,并通过节点通知与删除自愈机制同步传播。详情参见细粒度 STS 撤销设计说明与 Server 20261018 发布说明。

依赖安全与登录兼容性

Server 20261018 在 go.mod 中采用 Go 1.27.2、golang.org/x/net v0.61.0、golang.org/x/crypto v0.58.0、golang.org/x/text v0.43.0、silo-pkg v3.14.3 与 go-jose v4.1.6:

  • go-jose v4.1.6(GHSA-mgcc-cww5-fp2q):修复解析恶意构造的 RSA JWK 时消耗过多 CPU 的问题,并在 OIDC 路径上拒绝超过 4096 位的 RSA JWK 密钥。若外部身份提供者(IdP)使用了超过 4096 位的 RSA 签名密钥,请在升级前检查 OIDC 发现端点、JWKS 与登录流程并在 IdP 侧轮换密钥。
  • 独立 Console:独立部署版 Console 的依赖更新见 Console v2.4.2 发布说明。

验证与覆盖范围

cmd/ 下的回归测试覆盖了自助操作显式拒绝、受限会话委派拦截、root 凭据保护、空策略签发归一化、损坏密钥清理、解密后的批量列表身份内容以及 Object Lock WORM 不变量。源码与测试索引详见 IAM 授权设计笔记、Object Lock 修复设计笔记及组件版本矩阵。

2 - SN-2026-015 与 SN-2026-016:S3 Object Lock 治理模式保留锁绕过与指定版本号原地覆盖漏洞分析

深入解析 PutObjectRetention 治理保留锁绕过(SN-2026-015)与指定版本号原地覆盖漏洞(SN-2026-016)的根因、复现路径与修复设计,已于 SILO Server 20261018 完整修复。

SN-2026-015 与 SN-2026-016 涵盖了在 SILO Server 20261018 中通过 PR #234(14042b5aa)与 PR #235(0d3ba5d4c)修复的两组 S3 Object Lock 合规保留与不可变性漏洞(配套修复设计方案)。虽然删除路径此前已按 CVE-2023-25812 正确校验 s3:BypassGovernanceRetention,但保留期修改路径(PutObjectRetention)与四条原地版本覆写路径仍存在绕过缺陷。

漏洞范围:SN-2026-015 与 SN-2026-016

SN-2026-015:PutObjectRetention 鉴权失效与治理保留锁绕过(#231)

在 PutObjectRetentionHandler(cmd/object-handlers.go)中,enforceRetentionBypassForPut 与 isPutRetentionAllowed 存在三处相互叠加的逻辑缺陷,彻底击穿了 S3 Object Lock 的保留锁修改鉴权:

  • F1(携带绕过请求头即可无权限调用 PutObjectRetention):当请求携带 x-amz-bypass-governance-retention: true 且目标对象尚未加锁(或保留期已过)时,代码直接跳过了权限检查——导致一个既没有 s3:PutObjectRetention 也没有 s3:BypassGovernanceRetention 权限的普通已认证账号,只需加上该请求头就能对任意未加锁对象施加不可逆的 COMPLIANCE 合规保留锁!
  • F2(无 s3:BypassGovernanceRetention 权限即可缩短或清空 GOVERNANCE 保留锁):由于 isPutRetentionAllowed 返回了 byPass || retSet,仅拥有 s3:PutObjectRetention(retSet == true)但没有任何 s3:BypassGovernanceRetention 权限(byPass == false)的调用方,只需在请求头中加上 x-amz-bypass-governance-retention: true,即可将处于有效 GOVERNANCE 保留期内的对象锁直接清空(<Retention/>)或缩短到期时间,随后将该受保护版本彻底删除(对应社区报告 GHSA-67mv-7hc9-wj88,由 szopi00 披露)。
  • F3(绕过 s3:PutObjectRetention 上的 s3:object-lock-remaining-retention-days 条件限制):对已处于 GOVERNANCE 锁定的对象携带 x-amz-bypass-governance-retention: true 时,只要 s3:BypassGovernanceRetention 通过(byPass == true),即便 s3:PutObjectRetention 因不满足 NumericGreaterThanEquals: {"s3:object-lock-remaining-retention-days": 30} 等最短保留天数条件而失败(retSet == false),byPass || retSet 依然返回 true,从而绕过保留期下限策略。

SN-2026-016:指定版本号与 null 版本原地覆盖绕过(#234)

在全面排查所有能够原地改写已有对象版本的写入路径时,我们进一步发现并封堵了四条写路径不可变性绕过通道(14042b5aa):

  1. 在 PutObject 与 CopyObject 中由客户端指定 ?versionId=:非复制流量的普通客户端可在 PutObject 或 CopyObject 的查询参数中传入 ?versionId=<existing-uuid>(或在 Snowball PutObjectExtract 的 tar PAX 头中传入 minio.versionId),直接原地覆盖已有历史版本,而未对目标版本执行 WORM 保留期与法律冻结(Legal Hold)拦截。
  2. 目录对象(key/)覆盖 null 版本:上传以 / 结尾的目录对象时,即使在开启了版本控制与 Object Lock 的桶中也会固定写入 nullVersionID,从而在未检查 null 版本现有保留锁的情况下原地覆盖数据。
  3. CompleteMultipartUpload 原地覆盖 null 版本:当桶挂起版本控制(Versioning Suspended)且存在受保护的 null 版本对象时,通过 CompleteMultipartUpload 完成分段上传会原地覆盖该 null 版本而未检查其 WORM 锁状态。
  4. 内部复制请求头伪造:未持有 s3:ReplicateObject 权限的普通 s3:PutObject 调用方不应被允许伪造 X-Minio-Source-Replication-Request 等内部复制头。

S3 Object Lock 的标准权限语义

按照 AWS S3 Object Lock 规范,PutObjectRetention 必须同时守护两条相互正交的权限边界:

目标对象当前状态 请求提交的 <Retention> 变更 必需的 IAM 权限 必需的请求头
未加锁,或原保留期已过期 设置未来的 GOVERNANCE 或 COMPLIANCE 保留期 s3:PutObjectRetention 无
处于有效 GOVERNANCE 锁定期 在同模式下将 RetainUntilDate 延长至更晚时间 s3:PutObjectRetention 无
处于有效 GOVERNANCE 锁定期 清空(<Retention/>)、缩短 RetainUntilDate,或将模式改为 COMPLIANCE s3:PutObjectRetention 且 s3:BypassGovernanceRetention x-amz-bypass-governance-retention: true
处于有效 COMPLIANCE 锁定期 在 COMPLIANCE 模式下将 RetainUntilDate 延长至更晚时间 s3:PutObjectRetention 无
处于有效 COMPLIANCE 锁定期 清空、缩短保留期,或降级为 GOVERNANCE 任何身份均绝对禁止 禁止(返回 AccessDenied)

复现路径与验证对比

在 20261018 之前的版本上(基线提交 b0a540190),F1~F3 均可稳定复现:

复现 1:无 s3:BypassGovernanceRetention 权限清空治理锁并删库(F2)

假设用户 nobypass 仅被授予 s3:PutObjectRetention 与 s3:DeleteObjectVersion,没有 s3:BypassGovernanceRetention 权限:

步骤 nobypass 发起的请求 20261018 之前结果 Server 20261018 结果
1 对象 doc.txt(版本 v1)处于 GOVERNANCE 锁定状态,保留至 2030-01-01 受保护状态 受保护状态
2 DELETE /bucket/doc.txt?versionId=v1 403 AccessDenied 403 AccessDenied
3 PUT /bucket/doc.txt?retention&versionId=v1,附带请求头 x-amz-bypass-governance-retention: true 与空请求体 <Retention/> 200 OK(治理锁被直接清空!) 403 AccessDenied
4 再次执行 DELETE /bucket/doc.txt?versionId=v1 204 No Content(受保护版本被永久删除!) 对象保持锁定,拒绝删除

复现 2:零保留权限账号对未加锁对象强加合规锁(F1)

假设用户 zeroperm 既没有 s3:PutObjectRetention 也没有 s3:BypassGovernanceRetention 权限:

步骤 zeroperm 发起的请求 20261018 之前结果 Server 20261018 结果
1 PUT /bucket/unlocked.txt?retention(不带 bypass 头),请求体设为 <Mode>COMPLIANCE</Mode><RetainUntilDate>2099-01-01T00:00:00Z</RetainUntilDate> 403 AccessDenied 403 AccessDenied
2 发送完全相同的请求,仅额外附带请求头 x-amz-bypass-governance-retention: true 200 OK(成功打上不可撤销的 2099 年合规死锁!) 403 AccessDenied

复现 3:绕过 s3:object-lock-remaining-retention-days 最短保留天数条件(F3)

假设用户 govadmin 拥有无条件的 s3:BypassGovernanceRetention,但其 s3:PutObjectRetention 权限带有条件限制 NumericGreaterThanEquals: {"s3:object-lock-remaining-retention-days": 30}(要求设置的保留期至少为 30 天)。在已加 GOVERNANCE 锁的对象上携带 x-amz-bypass-governance-retention: true 并将保留期改为 5 天,修复前由于 byPass == true 直接满足了 byPass || retSet,30 天下限条件被完全绕过。


根本原因剖析

在 cmd/object-handlers.go 中,PutObjectRetentionHandler 在入口处仅调用了验证签名的 authenticateRequest,而将全部权限判断委托给了 enforceRetentionBypassForPut 及其内部调用的 isPutRetentionAllowed:

// 20261018 之前存在缺陷的 isPutRetentionAllowed
func isPutRetentionAllowed(ctx context.Context, r *http.Request, bucket, object string,
    retDays int, retDate time.Time, retMode RetentionMode, byPassSet bool,
    cred auth.Credentials, owner bool, claims map[string]interface{}) (retSet, byPass bool) {

    if !byPassSet {
        return isReqAuthenticated(ctx, r, iampolicy.PutObjectRetentionAction, ...), false
    }

    byPass = isReqAuthenticated(ctx, r, iampolicy.BypassGovernanceRetentionAction, ...)
    retSet = isReqAuthenticated(ctx, r, iampolicy.PutObjectRetentionAction, ...)
    return retSet, byPass || retSet
}

而在 enforceRetentionBypassForPut 中:

  1. 当目标对象当前没有有效保留锁(opts.Mode == "")时,代码未正确要求 retSet 必须为真,只要请求头带了 x-amz-bypass-governance-retention: true 就会放行(触发 F1);
  2. 当目标对象处于 GOVERNANCE 模式且请求携带了 x-amz-bypass-governance-retention: true 时,isPutRetentionAllowed 的第二个返回值竟被写成了 byPass || retSet(逻辑或)!于是只要 retSet == true(仅有 s3:PutObjectRetention),第二个返回值就成了 true(触发 F2);反过来只要 byPass == true(仅有 s3:BypassGovernanceRetention),即便 retSet == false 也会放行(触发 F3)。

上游五年演进历史

通过 git log 追溯历史可以清楚看到这三个漏洞是如何在五年间的三次提交中逐步成型的:

  • 2020-04(43a3778b4,minio/minio#9259):首次引入 enforceRetentionBypassForPut 与 isPutRetentionAllowed,埋下了 byPass || retSet 将两项正交权限做逻辑或运算的隐患。
  • 2025-02(437dd4e32,minio/minio#20929):重构了 PutObjectRetentionHandler 中的保留锁检查分支。
  • 2025-04(8c7097528,minio/minio#21103):将 PutObjectRetentionHandler 入口的 checkRequestAuthType 替换为了仅验签不鉴权的 authenticateRequest,使 enforceRetentionBypassForPut 成为唯一的鉴权关口,从而让 F1 彻底暴露给任何零权限的已认证用户。

修复方案设计

五条核心安全不变量

我们的修复方案在 PutObjectRetention(0d3ba5d4c)与全部对象写入路径(14042b5aa)上严格建立并锁死了五条安全不变量:

  1. 不变量 1(s3:PutObjectRetention 必检):任何 PutObjectRetention 请求,无论是否携带 x-amz-bypass-governance-retention 请求头,都必须通过 s3:PutObjectRetention 权限校验(包含 s3:object-lock-remaining-retention-days、s3:object-lock-retention-mode、s3:object-lock-retain-until-date 与 s3:ExistingObjectTag/<key> 条件)。
  2. 不变量 2(削弱有效 GOVERNANCE 锁必须叠加 s3:BypassGovernanceRetention):若目标版本当前处于未过期的 GOVERNANCE 锁定期,且请求试图清空保留锁(Mode == "")、缩短保留截止时间(!RetainUntilDate.After(existing.RetainUntilDate))或跨模式变更,则请求必须同时携带 x-amz-bypass-governance-retention: true 并通过 s3:BypassGovernanceRetention 权限校验。
  3. 不变量 3(COMPLIANCE 绝对不可削弱):处于未过期 COMPLIANCE 锁定期的对象版本,任何身份均不可清空、缩短保留期或降级为 GOVERNANCE。
  4. 不变量 4(禁止非复制请求指定 ?versionId= 覆盖写):非复制 S3 客户端不得在 PutObject、CopyObject 或 PutObjectExtract 中指定已有或自定义的 versionId 原地覆写对象。
  5. 不变量 5(原地覆盖 null 版本必须执行写锁检查):任何会原地替换已有 null 版本的写入路径(挂起版本控制桶上的 PutObject、目录对象 key/、以及 CompleteMultipartUpload),在覆写前必须对已有 null 版本执行完整的 WORM 保留期与法律冻结检查。

鉴权检查的位置与职责划分

在 PutObjectRetentionHandler 与 enforceRetentionBypassForPut 中:

  • s3:PutObjectRetention(retOK)始终基于解析后的目标保留参数(retDays、retDate、retMode)与对象现有标签求值,若为 false 立即返回 ErrAccessDenied;
  • s3:BypassGovernanceRetention(bypassOK)独立求值,且仅在修改动作确实需要突破现有 GOVERNANCE 锁时作为附加必要条件强制要求。

PutObjectRetention 完整判定真值表

行号 现有锁状态 请求的变更动作 x-amz-bypass-governance-retention retOK (s3:PutObjectRetention) bypassOK (s3:BypassGovernanceRetention) 最终结果
1 任意状态 任意变更 false 或 true false 任意 403 AccessDenied(封堵 F1 与 F3)
2 未加锁或已过期 设置未来保留期 false 或 true true 不要求 200 OK
3 有效 GOVERNANCE 延长 RetainUntilDate false 或 true true 不要求 200 OK
4 有效 GOVERNANCE 清空(<Retention/>)或缩短 RetainUntilDate false true 任意 403 AccessDenied
5 有效 GOVERNANCE 清空(<Retention/>)或缩短 RetainUntilDate true true false 403 AccessDenied(封堵 F2)
6 有效 GOVERNANCE 清空(<Retention/>)或缩短 RetainUntilDate true true true 200 OK
7 有效 COMPLIANCE 清空、缩短或降级为 GOVERNANCE false 或 true 任意 任意 403 AccessDenied

五个被否决的替代方案

在设计复核阶段,我们评估并否决了五个看似更简单的改法:

  1. 仅把 isPutRetentionAllowed 中的 byPass || retSet 改成 byPass && retSet:这样虽能修掉 F2 与 F3,但未加锁分支上的 F1 依然存在;而且如果一个仅有 s3:PutObjectRetention 的用户在正常延长保留期时顺手带上了 x-amz-bypass-governance-retention: true,byPass && retSet 会错误地拒绝该合法延长请求。
  2. 直接在 PutObjectRetentionHandler 最顶部加一行 checkRequestAuthType(..., PutObjectRetentionAction):在 Handler 入口处,请求体 XML(<Retention>)尚未解析,磁盘上的对象现有标签也尚未读取,此时求值会导致所有依赖 s3:object-lock-remaining-retention-days、s3:object-lock-retain-until-date、s3:object-lock-retention-mode 与 s3:ExistingObjectTag/<key> 的 IAM 策略条件全部失效!
  3. 只要请求头带了 x-amz-bypass-governance-retention: true 就一律要求 s3:BypassGovernanceRetention:不符合 S3 规范——只有当操作确实需要突破有效 GOVERNANCE 锁时才需要检查 s3:BypassGovernanceRetention。
  4. 直接禁止传入空 <Retention/> 请求体:通过 <Retention/> 清空治理锁是 mcli retention clear 长期依赖的公开扩展能力;只要调用方同时具备 s3:PutObjectRetention 与 s3:BypassGovernanceRetention 且显式传入 x-amz-bypass-governance-retention: true,就应当继续支持。
  5. 仅在开启了 Object Lock 的桶上拦截 ?versionId= 写入(SN-2026-016):允许普通客户端通过 ?versionId= 原地篡改已有历史版本违背了所有版本化桶的不可变语义,因此在所有桶上统一拦截非复制流量的 ?versionId= 写入。

实现细节补充

  • 空 <Retention/> 与超长年份下的 s3:object-lock-remaining-retention-days 计算:当调用方发送 <Retention/> 清空治理锁时,retDate 为零值 time.Time{}。旧代码直接对零值时间调用耗时差计算,由于 time.Duration 最多只能表示约 290 年,算出了约 -106751 天的荒谬负数!提交 0d3ba5d4c 将零值 retDate 的剩余天数明确归零,并使用不溢出的日历秒差计算未来保留天数,使 2400 年等远期保留时间也不会因 time.Duration 290 年上限而发生饱和截断。
  • s3:ExistingObjectTag/<key> 完整透传:s3:PutObjectRetention 与 s3:BypassGovernanceRetention 两次鉴权均完整传入目标版本的现有对象标签。

兼容性影响

  • 标准 S3 客户端与 mcli retention 工作流:完全兼容。使用 mcli retention clear --governance 清空或缩短治理模式保留锁的账号,必须按规范同时具备 s3:PutObjectRetention 与 s3:BypassGovernanceRetention 权限。
  • 跨站点/双向桶复制(Replication):需要保留源端 versionId 的合法复制流量必须持有 s3:ReplicateObject 权限(标准复制策略已默认包含)。

回归测试矩阵

PR #234 与 PR #235 在 cmd/object-handlers-lock_test.go 与 cmd/bucket-versioning_test.go 中固化了完整回归测试矩阵:

  • 覆盖 PutObjectRetention 判定真值表的全部 7 行组合(含 F1 零权限调用方、F2 仅 PutObjectRetention 调用方、F3 仅 BypassGovernanceRetention 且带最短保留天数条件的调用方);
  • 覆盖超过 time.Duration 290 年上限的远期保留时间(如 2400-01-01T00:00:00Z);
  • 覆盖 PutObject、CopyObject 与 PutObjectExtract 携带 ?versionId= / minio.versionId 的拦截;
  • 覆盖目录对象(key/)与 CompleteMultipartUpload 针对受锁 null 版本的原地覆盖拦截。

缓解措施与版本升级

交付状态

SN-2026-015(0d3ba5d4c,PR #235)与 SN-2026-016(14042b5aa,PR #234)均已随 SILO Server 20261018 正式发布。所有依赖 S3 Object Lock WORM 治理模式或合规模式保护数据的生产环境应尽快升级至 20261018。

3 - 九月安全修复:载荷完整性、IAM 撤销与 Console 分享

SN-2026-012 至 SN-2026-014,分别注明 Server 源码与 Console 发布边界。

本文汇总随 Server 20260916 与 Console v2.4.1 发布的三项安全修复(SN-2026-012 至 SN-2026-014)及底层依赖安全更新。完整台账索引见安全公告台账。

编号与问题 修复源码 发布状态
SN-2026-012:预签名载荷完整性 Server c4b5e1cb4,PR #177 已随 Server 20260916 发布(RELEASE.2026-09-03T13-18-01Z 及更早版本受影响)
SN-2026-013:持久 IAM 撤销 Server PR #191 / PR #192 已随 Server 20260916 发布;需跨节点与跨站点协调升级
SN-2026-014:匿名 Console 分享代理 Console PR #56;Server PR #209 已在独立 Console v2.4.1 与内嵌该版本的 Server 20260916 中发布

预签名载荷完整性 — SN-2026-012

此前通用认证路径在验证预签名请求时,若调用方通过 X-Amz-Content-Sha256 提供载荷哈希,服务端在完成签名比对后可能遗漏对实际请求体字节的 SHA-256 摘要校验,导致持有合法预签名写入 URL 的调用方可以替换请求体内容(回归测试以预签名 PutBucketPolicy 复现了该缺陷)。

PR #177 中的提交 c4b5e1cb4 将请求体摘要校验严格绑定到实际参与签名的载荷哈希上;正文摘要不匹配时立即返回 XAmzContentSHA256Mismatch(400 Bad Request)。显式签入的 UNSIGNED-PAYLOAD 保持原有语义不变。同批修复还确保策略条件求值始终读取经过认证的签名字段(查询串中的签名值优先于后备请求头),并移除了内部暂存头 X-Amz-Signature-Age,与 SN-2026-011 形成互补。

升级前请检查自定义签名客户端,确保带具体载荷哈希签名的预签名上传发送与之匹配的正文内容。

持久 IAM 撤销 — SN-2026-013

在多站点复制或频繁变更场景下,延迟到达的旧复制事件、父身份同名重建或缺失的删除历史,此前可能导致管理员已经撤销的身份或策略重新恢复。PR #191 与 PR #192 引入了持久化的单调递增修订号、删除墓碑(tombstone)以及签入子凭据的父身份撤销代次,确保延迟到达的旧事件与撤销前签发的子凭据无法越过已持久化的撤销边界。

由于该修复涉及持久化 IAM 元数据状态:

  • 共享同一 IAM 后端的所有节点以及参与复制的所有站点必须协调一致升级,不支持新旧版本混跑或滚动降级;
  • mcli admin cluster iam export 仅导出当前活动记录而不包含删除墓碑,升级前请保留完整的底层备份并遵循 IAM 升级与恢复流程;
  • 修订排序规则、监控指标与组成员关系边界详见 IAM 撤销设计记录。

匿名 Console 分享代理 — SN-2026-014

Jiri Pejchal 报告:无需认证的 Console 分享下载代理接受了指向已配置 Server 源站、但超出预签名对象下载范围的同源 URL。

Console #56 严格限定目标 URL 的协议、主机与端口必须匹配已配置的 S3 源站,要求路径必须为合法的 /{bucket}/{object},拒绝系统保留前缀、路径穿越片段以及改变操作类型的查询参数,禁止跟随上游重定向,并透传客户端取消信号;合法的预签名对象下载 URL 则原样保留原始签名字节。

独立部署的 Console 请升级至 Console v2.4.1;使用内嵌 Console 的服务端部署(通过 Server #209 集成)请升级至 Server 20260916。

相邻依赖加固

Server 20260916 将 github.com/rabbitmq/amqp091-go 升级至 v1.14.0 以修复 GHSA-6c5v-hqjr-5xxp(上游于 v1.13.0 修复:配置了 AMQP 桶事件通知目标时,恶意 AMQP 对端可触发过量内存分配),并同步升级至包含 JWX JSON 编码修复的 pkg v3.14.1。

4 - SN-2026-011:修复与发布状态

SN-2026-011 签名头校验修复(123325430),已随 Server 20260916 正式发布。

SN-2026-011 于 2026-09-13 在 Server 主分支修复(起始提交 123325430),并已随 Server 20260916 正式发布。Server RELEASE.2026-09-03T13-18-01Z 及更早版本均受影响(同期发布的 silo-pkg v3.14.0 与 mcli 20260913 属于依赖库与客户端版本,不包含服务端二进制修复)。

在未修复版本中,持有合法签名 PUT 请求的调用方可以追加未签名的 x-amz-copy-source 请求头,将该写入操作转化为复制签名密钥有权读取的其他对象。若目标位置允许匿名读取,被复制对象的私有内容便会对外暴露。预签名 URL 与 Authorization 请求头签名均受影响,攻击者无需获知签名密钥本身。

修复方案在分发请求前校验实际收到的 x-amz-* 请求头是否均已包含在签名头集合中(仅保留协议规范明确允许的例外项)。后续的签名与校验和边界补强详见签名头评审笔记。

修复建议

请将 SILO Server 升级至 Server 20260916 或更高版本。在完成升级前,应将签发上传请求的凭据限制在必要的最小对象前缀范围内,避免为签发公开桶上传链接的凭据授予过宽的读取权限。

感谢 Oren Yomtov 报告此问题。更多详情请参阅安全公告台账、9 月 16 日版本加固公告及组件版本矩阵。

5 - CVE-2025-62506:Session Policy 权限提升

SILO 在首个社区版本发布前已原样继承上游修复 c1a49490(PR #21642)及两组回归测试;本文记录上游提交映射、回归覆盖与运维边界。

状态: 已继承并发布
首个 SILO 社区版本: RELEASE.2025-12-03T12-00-00Z
上游首个修复版本: RELEASE.2025-10-15T17-29-55Z
GitHub Advisory: GHSA-jjjj-jwhf-8rgr
上游修复: minio/minio#21642

带有受限内联会话策略(Session Policy)的服务账号(Service Account)或 STS 临时凭据,原本可以借助“操作自身账号”的管理 API 例外创建一个不带相同限制的新服务账号。由于自身账号操作将策略评估标志设为 DenyOnly = true,只要 Session Policy 中没有显式 Deny 规则,权限检查就会放行,使新建的子服务账号直接继承更宽泛的父账号权限。

SILO 无需单独回移补丁:在切出首个社区版本(RELEASE.2025-12-03T12-00-00Z)的源码历史中,上游修复提交及其两组回归测试均已在列。

Commit 映射

角色 仓库 Commit 证据
上游修复 minio/minio c1a49490 minio/minio#21642 的合并提交
SILO 继承 pgsty/silo c1a49490 保留完全一致的 Git commit 对象与 SHA;RELEASE.2025-12-03T12-00-00Z 及后续所有 SILO 版本均为其后代

相同的提交 SHA 直接证明了源码历史的继承关系。安全公告台账保留了同一组提交映射。

回归证据

上游 PR #21642 新增了两组回归测试,每组均覆盖 root 与非 root 父账号场景:

两组测试均接在标准 cmd 测试套件中,执行 go test ./cmd 时会自动运行。

修复与运维边界

只要认证凭据附带了 Session Policy(子策略),策略评估器在评估该子策略前会先清除 DenyOnly 标志。这样,“操作自身账号”的例外不再将“未被显式拒绝”视同授权,受限 Session Policy 必须显式包含目标动作的 Allow 规则。

所有 SILO 正式版本均已包含 c1a49490。若从早于 RELEASE.2025-10-15T17-29-55Z 的上游 MinIO 构建迁移,应先升级至 SILO,随后审查升级前由受限服务账号或 STS 身份创建的子服务账号,并撤销可疑账号。

关联公告:CVE-2026-32285

部分外部漏洞源后续发布的 CVE-2026-32285 将文字描述写为 github.com/buger/jsonparser 漏洞,但引用的修复提交同样是 c1a49490。关于该编号的依赖版本核查与可达性研判过程,见 CVE-2026-32285:最终无需补丁的 jsonparser 通告。

6 - SILO 20260903 安全说明:SN-2026-006 至 010

SILO 20260903 修复的五项继承服务端授权与加密问题:SSE-C 密钥认证、复制 header 信任、管理状态授权与显式版本删除。

本公告汇总随 SILO Server 20260903(RELEASE.2026-09-03T13-18-01Z)发布的五项服务端安全修复,涵盖客户端提供密钥加密(SSE-C)、客户端可控复制请求头、管理端账户/组状态变更鉴权以及显式对象版本删除。SN-2026-006 至 SN-2026-010 为 SILO 安全说明编号,对应从上游 minio/minio 代码基线继承并在发布前审计中修复的权限与密码学边界缺陷。

摘要

编号 触发前置条件 缺陷表现 20260903 修复后行为
SN-2026-006 对 SSE-C 零字节对象具备读取权限 因无数据块需要解密,传入错误的客户密钥也会返回成功 强制解封并校验对象密钥;错误密钥返回 403 AccessDenied
SN-2026-007 对 SSE-C 对象具备读取权限,或伪造复制标记请求头 GetObjectAttributes 未校验客户密钥即返回受保护元数据 普通请求必须提供正确密钥;仅通过鉴权的复制对端可走副本元数据分支
SN-2026-008 可调用相关读/写/删接口的任意已认证身份 伪造内部复制请求头即可在无复制权限时触发复制专用语义 必须携带精确标记且通过 s3:ReplicateObject 或 s3:ReplicateDelete 鉴权
SN-2026-009 仅持有单向状态管理权限的已认证管理 API 主体 仅持启用权限即可禁用账户/组,仅持禁用权限反而被拒 根据请求的目标状态分别校验对应的启用或禁用权限
SN-2026-010 持有 s3:DeleteObject 但无 s3:DeleteObjectVersion 的已认证主体 缺少 s3:DeleteObjectVersion 权限仍可永久删除指定历史版本 带显式 versionId 的删除必须具备 s3:DeleteObjectVersion 权限

SN-2026-006:零字节 SSE-C 密钥认证

影响操作: SSE-C 零字节对象的 GetObject、HeadObject、CopyObject 源处理与 GetObjectAttributes。
跟踪: issue #82。
修复: b73581b05、c4fd97d0b。

SSE-C 在元数据中保存密封的对象密钥(sealed object key),并要求调用方在读取时再次提供匹配的客户密钥。常规读取在初始化加密数据流时解封并校验密钥;但零字节对象没有数据块,旧版快速返回分支在解封对象密钥前就提前返回。

结果具有该对象读取权限的调用方即使传入错误的客户密钥也能收到成功响应,甚至可以在不知道当前密钥的情况下用新密钥复制该对象。

修复后,服务端无论对象长度是否为零均强制解封校验 SSE-C 密钥。正确密钥行为不变;错误密钥统一返回 403 AccessDenied(覆盖 null 版本与密钥轮换场景)。

SN-2026-007:SSE-C GetObjectAttributes

影响操作: SSE-C 对象的 GetObjectAttributes。
跟踪: issue #84。
修复: 474cd5801、74c97d005、21870fa2e。

GetObjectAttributes 会返回对象大小、ETag、校验和、存储级别与分片元数据。原有处理函数未验证调用方提供的 SSE-C 密钥即返回上述属性,且只要请求中出现 X-Minio-Source-Replication-Request 请求头就会直接跳过密钥检查。

修复将两类调用路径明确分离:

  • 普通 S3 调用方必须提供正确的 SSE-C 密钥(密钥缺失或错误返回 403 AccessDenied);
  • 站点/桶复制对端必须在通过身份认证并具备 s3:ReplicateObject 权限后,方可走副本免解密元数据分支。

SN-2026-008:复制请求头信任边界加固

影响面: 对象读写、分片上传完成、对象与版本删除、Snowball 解压、Object Lock 时间戳、校验和元数据与桶事件通知。
跟踪: PR #101。
主要修复范围: 938603458 至 04b097fd9,以及随后的 Snowball 与规则前缀加固。

CVE-2026-34204 阻止了普通 PUT 与 COPY 请求导入部分复制加密元数据,但全面排查发现,多处下游代码仍将“请求中带有内部复制头”直接视为“请求来自受信任复制节点”。

视具体接口而定,具备普通读写权限的客户端此前可以通过伪造复制请求头:

  • 在不提供客户密钥的情况下读取 SSE-C 对象的免解密路径;
  • 强行保留客户端指定的源端 ETag 或修改时间(MTime);
  • 注入源端校验和、实际大小、保留期、法律冻结或 REPLICA 状态元数据;
  • 触发仅限复制使用的删除语义或抑制桶事件通知;
  • 在 Snowball 归档解压时跨条目传递复制信任状态。

修复方案在接收端建立了统一的复制信任判定流程:

  1. 先按原始签名请求完成 SigV4 认证;
  2. 要求携带唯一且精确小写的 X-Minio-Source-Replication-Request: true 标记头;
  3. 在目标资源上校验 s3:ReplicateObject 或 s3:ReplicateDelete 权限;
  4. 仅在复制状态同时为 REPLICA 时派生更严格的 replicaTrusted 状态;
  5. 将判定结论存入私有的 req.Context() 键中;
  6. 验签完成后在请求克隆上剥离未受信任的内部复制头,同时保留底层 Trailer 映射以确保流式校验和正常验证。

完整的协议矩阵、CORS 交互、双站点 TLS 黑盒复制验证与被否决方案详见鉴权前不做 I/O,Header 不产生权限。

SN-2026-009:用户与组状态管理授权

影响操作: 管理 API SetUserStatus、SetGroupStatus。
跟踪: PR #73 / PR #85。
修复: 58735ee38、229fe2b3c。

原有处理函数在检查目标状态参数前固定校验 admin:EnableUser 或 admin:EnableGroup。这导致仅被授予启用权限的受限管理员同时也能禁用账户/组,而仅持有禁用权限的管理员反而无法执行禁用操作。

修复后由请求的目标状态决定所需权限:

目标操作 所需权限
启用用户 admin:EnableUser
禁用用户 admin:DisableUser
启用组 admin:EnableGroup
禁用组 admin:DisableGroup

admin:* 与内置 consoleAdmin 策略不受影响;仅依赖单一启用权限执行双向操作的自定义最小权限策略需要补齐对应权限。

SN-2026-010:显式对象版本删除鉴权

影响操作: DeleteObject 与 DeleteObjects 中携带显式 versionId 的删除条目。
跟踪: issue #58、PR #104。
修复范围: 75a6734e4 至 d2d47a41f。

原有 S3 删除路径将携带显式 versionId 的永久版本删除按普通 s3:DeleteObject 授权,仅将 s3:DeleteObjectVersion 作为次级 Deny 检查。这与 AWS S3 规范不一致,使仅具备普通删除权限的调用方也能永久删除指定的历史版本。

Server 20260903 要求删除显式版本必须具备 s3:DeleteObjectVersion 权限;不带 versionId 的普通删除继续校验 s3:DeleteObject,复制目标端继续使用 s3:ReplicateDelete。升级后:

  1. 仅持有 s3:DeleteObject 的主体无法再删除显式版本;
  2. 通过 Allow s3:* 配合 Deny s3:DeleteObject 阻止永久删除的策略,需同步将 s3:DeleteObjectVersion 加入 Deny 列表。

运维升级建议

升级前:

  1. 梳理需要删除显式对象版本的业务策略,显式授予或拒绝 s3:DeleteObjectVersion;
  2. 核查涉及用户/组启停的自定义管理策略;
  3. 确认复制专用账号具备所需的 s3:ReplicateObject / s3:ReplicateDelete 权限,且普通业务账号未误配这两项权限;
  4. 确认 SSE-C 客户端在 GET、HEAD、GetObjectAttributes 与 CopyObject 源读取(含零字节对象)时始终传入正确的客户密钥。

升级后:

  • 在测试用 SSE-C 对象上分别验证错误密钥(返回 403)与正确密钥访问;
  • 验证常规对象复制与删除复制;
  • 分别验证受限管理角色的启用与禁用操作;
  • 分别使用仅含 s3:DeleteObject 与包含 s3:DeleteObjectVersion 的身份验证历史版本删除。

验证边界

上述五项修复均配有单元、Handler、鉴权、流式 Trailer、Snowball 及跨站点复制回归测试。基于 ebac0ca73 的本地全量验收通过了完整的 cmd 竞态测试套件以及六种部署拓扑下的 174 项集成验收(174 PASS / 0 FAIL),并完成了发布前复审记录的 RELEASE.2026-09-03T13-18-01Z 构建、签名、SBOM 与镜像发布门禁。

相邻问题跟踪

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

一次无需代码改动的安全研判:通告引用的上游提交早已随 CVE-2025-62506 继承,当前解析的 jsonparser v1.1.2 已包含修复,且可达性分析未发现漏洞路径。

状态: 无需代码改动,问题已关闭
GitHub Issue: pgsty/silo#26
关联上游提交: c1a49490(CVE-2025-62506)

对 CVE-2026-32285 的初始排查假设仓库可能仍携带未修复的 github.com/buger/jsonparser,团队甚至一度评估过替换依赖或维护独立分支。但在逐一核对通告引用的上游提交、Go 模块实际解析版本以及符号可达性后,结论完全改变:

  1. 外部漏洞库将 CVE-2026-32285 的摘要写成了 jsonparser 漏洞,但其绑定的修复提交 c1a49490 实际上是 CVE-2025-62506(Session Policy 权限提升)的上游修复,自 RELEASE.2025-12-03T12-00-00Z 起已包含在所有 SILO 版本中。
  2. 针对 github.com/buger/jsonparser 本身,当前代码树已解析至包含修复的 v1.1.2,且 govulncheck 未发现任何可达的漏洞符号。

因此,我们没有制造一次形式上的依赖升级,而是完整记录核查证据并关闭了 pgsty/silo#26。

初始假设为什么站不住脚

问题最初被表述为“该依赖尚无可用的修复版本”。如果未核对模块图与提交溯源就直接按该前提改动代码,会带来多重副作用:

  • 在没有任何安全收益的情况下变动依赖图;
  • 为一个并不存在的漏洞冒着引入 JSON 解析兼容性回归的风险;
  • 背上长期维护额外第三方库分支的负担;
  • 误导运维人员以为既有的 SILO 发行版曾暴露于该漏洞之下。

当证据表明代码已经安全时,保持代码不变本身就是明确的安全决策。

四层核查过程

本次研判按四层证据逐步收敛:

  1. 核对提交溯源:检查通告链接的上游提交 c1a49490,确认其为已原样继承的 Session Policy 修复(CVE-2025-62506)。
  2. 核对模块解析版本:检查 go.mod 与 go.sum 中实际选中的版本,确认 github.com/buger/jsonparser 已处于包含上游补丁的 v1.1.2。
  3. 符号可达性分析:运行 govulncheck,确认编译后的调用图中不存在可达的漏洞符号。
  4. 归因外部通告偏差:确认告警源于外部漏洞数据库的条目错配与信息滞后,而非当前源码树遗留漏洞。

评估依赖类安全通告时,必须严格区分四件事:某个历史版本曾被标记受影响、某个包被项目导入、漏洞符号在程序调用图中可达、以及外部不可信输入能通过部署入口真正触发该路径。

为什么不做“预防性盲目升级”

既然当前选中的版本已经包含修复,再随意滚动到其他提交或替换解析库并不会提升安全性。在庞大的 Go 模块依赖图中,无明确目标的依赖变更只会扩大变更面,使后续回归问题更难定位。

最终处置包含四项动作:

  • 不提交拼凑出来的“修复提交”;
  • 在 pgsty/silo#26 中归档提交溯源、v1.1.2 版本与 govulncheck 核查证据;
  • 由 CI 中的版本门禁与 govulncheck 持续拦截未来的依赖回退;
  • 在安全公告台账中将 CVE-2026-32285 与 CVE-2025-62506 对照登记,方便用户核对安全扫描器报告。

验证边界

2026-04-15 的核查证明:SILO 当前模块图(github.com/buger/jsonparser v1.1.2)与继承提交历史(c1a49490)无需为 CVE-2026-32285 修改代码。后续若模块选择或 jsonparser 调用方式发生变化,仍由常规 CI 版本门禁与 govulncheck 重新校验。

工程原则

面对依赖型 CVE,应依次核实四个问题:通告实际指向哪个提交或版本、go.mod 最终解析到哪个版本、漏洞符号是否进入编译调用图、以及运行时入口是否可利用。只有当这些核查证明源码确实存在未闭合路径时,才应当产出代码变更。

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

旧版 OIDC 验证器混用了依赖方 client_secret 与 JWKS 公钥;我们在实现显式 HMAC 兼容分支后又主动将其删除,在 d24f449 中彻底恢复为纯 JWKS 非对称验签。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
影响入口: AssumeRoleWithWebIdentity、AssumeRoleWithClientGrants
修复提交: d24f449
GitHub Issue: pgsty/silo#22

internal/config/identity/openid 的旧实现将 OIDC client_secret 放进了本地 JWT 验签密钥环(keyring),同时允许 HMAC 签名算法(HS256、HS384、HS512)。攻击者一旦获知 client_secret,即可自行使用 HMAC 签发伪造的 ID Token,并通过 STS(AssumeRoleWithWebIdentity 或 AssumeRoleWithClientGrants)换取临时 S3 凭据。

最终修复(d24f449)强制执行基于 JWKS 的非对称验签,宁可明确放弃 HS256/HS384/HS512 兼容性,也不保留一个会再次混淆密钥信任语义的配置开关。

为什么根因是密钥角色混淆而非漏掉验签

旧代码在签发 STS 凭据前确实执行了 JWT 签名验证。真正失效的边界在于:验证器接受了哪一种密钥,以及令牌头部的 alg 字段能否选择与该密钥原本职责完全不符的算法语义。

在 OIDC 协议中,client_secret 的职责是让依赖方(SILO)向身份提供商(IdP)的 Token Endpoint 证明客户端身份,它是依赖方与 IdP 之间的共享凭据,而不是 IdP 用来签署用户身份声明的密钥。旧代码把 client_secret 与 JWKS 公钥装进同一个本地 token.Parse 密钥环后,单次客户端密钥泄露就会直接升级为任意身份伪造:

  1. 攻击者通过配置泄露等途径获取 OIDC client_secret。
  2. 攻击者构造包含任意用户或角色声明的 JWT,并以 client_secret 作为 HMAC 密钥签发 HS256、HS384 或 HS512 令牌。
  3. 验证器在密钥环中找到 client_secret 并通过 HMAC 验签。
  4. 伪造令牌进入 AssumeRoleWithWebIdentity 或 AssumeRoleWithClientGrants 流程,成功换取临时 STS 凭据。

先写出兼容路径,再主动将其删除

在修复初期,我们曾实现过一版带有算法白名单与 allow_hmac 兼容开关的原型(fc47fa0)。表面上看,这种折中方案很稳妥:默认强制安全配置,同时允许确有 HMAC 需求的部署显式开启。

但在设计复核中,这个方案被推翻了。只要继续允许 client_secret 进入本地 JWT 验签密钥环,管理员一旦打开该开关,就会在不知情中把整个 STS 信任边界降级为“持有客户端密钥即可签发任意用户令牌”;同时,未来主验签路径与 JWKS 刷新重试路径的算法白名单只要发生任何漂移,漏洞就可能默认重现。

方案 收益 风险 结论
在密钥环中保留 client_secret,仅过滤部分算法 改动极小,保留 HMAC IdP 兼容 密钥环仍然混用 Token Endpoint 客户端认证密钥与本地 JWT 验签密钥 否决
增加 allow_hmac 显式配置项(fc47fa0) 将兼容性风险显式化 运维方极难评估开启后的信任边界扩张,且解析与重试分支的测试面翻倍 实现后删除
仅允许 JWKS 非对称公钥验签(d24f449) 密钥职责彻底隔离,首次验签与 JWKS 刷新重试共用同一解析策略 仍使用 HS256/384/512 的 IdP 必须先迁移 接受

主动删掉已经写完的兼容代码,换来了更清晰、更容易审计的信任边界。

最终不变量

提交 d24f449 在 internal/config/identity/openid 中确立了四条不变量:

  • 验签密钥仅从 IdP 的 JWKS 端点(jwks_uri)加载。
  • OIDC client_secret 绝不进入本地 JWT 验签密钥环。
  • 无条件拒绝 HS256、HS384 与 HS512。
  • 常规首次验签流程与 JWKS 缓存刷新重试流程强制共用同一套非对称算法白名单(RS256、RS384、RS512、ES256、ES384、ES512)。

同时,本次安全修复刻意保持了最小的算法支持面,未顺带扩展 PS256 或 EdDSA。

验证与发布

internal/config/identity/openid 的测试覆盖了 HS256 拒绝、RS256 放行以及密钥轮换时的 JWKS 刷新重试回归测试。原型阶段的临时兼容辅助函数、配置项与测试均已在最终提交 d24f449 中彻底移除,并随 RELEASE.2026-04-17T00-00-00Z 发布。

go test ./internal/config/identity/openid -count=1

兼容性代价

这是一项明确的不兼容变更(Breaking Change)。仍在签发 HS256、HS384 或 HS512 ID Token 的身份提供商必须先迁移至基于 JWKS 的 RSA 或 ECDSA 非对称密钥,再升级 SILO。若怀疑升级前 client_secret 曾发生泄露,请同步在 IdP 与 SILO 中轮换该密钥。

9 - CVE-2026-33419:LDAP STS 用户枚举与限流链演进

从统一认证失败响应,到修正成功退额、代理源地址解析与移除共享用户名分桶:经历三轮迭代收敛的 LDAP STS 加固记录。

状态: 已发布,并经历两轮后续修正
首个包含版本: RELEASE.2026-04-17T00-00-00Z
完整修正版本: RELEASE.2026-06-18T00-00-00Z
修复提交: 3b950f8、18b712d、9e10f6d、f441108、5e40665
GitHub Issue: pgsty/silo#23

AssumeRoleWithLDAPIdentity 的初始漏洞非常直观:LDAP STS 对“用户不存在”与“密码错误”返回不同的错误信息,构成了无需认证即可利用的用户名探测信标(username oracle)。第一版补丁统一了对外的认证失败响应,并按客户端源 IP 与用户名同时设置了双重限流桶。

然而随后的连续设计复核表明:认证成功退额、可伪造的代理转发头、配额预留核算以及跨源共享的用户名分桶,反而会把安全限流器本身变成精确拒绝服务的新攻击面。6 月份的最终方案(5e40665)果断删除了会导致定点账户锁定的用户名分桶,仅保留按源 IP 限流,并将反向代理源地址识别确立为显式的部署契约。

初始威胁模型

受影响入口为 AssumeRoleWithLDAPIdentity。攻击者无需持有任何已有的 S3 或 LDAP 凭据,只要网络可达 LDAP STS 端点,就能通过对比未知用户与错误密码返回的 HTTP 状态码及错误文案,逐一枚举出有效的目录用户名,再配合密码喷洒(Password Spraying)或社工手段发起攻击。

与此同时,修复不能简单把所有异常都伪装成“密码错误”:LDAP 连接断开、查询账号 Bind 失败或目录服务超时必须继续作为 HTTP 500 基础设施故障返回,否则运维人员将失去故障诊断能力。

第一轮:统一外部响应并引入双维度限流

2026-04-15 的第一版修复(3b950f8)完成了三件事:

  • 将“用户不存在”与“密码错误”的外部响应统一为相同的 400 InvalidParameterValue STS 认证错误;
  • LDAP 基础设施故障继续返回 HTTP 500,并在服务端日志中保留底层详细原因;
  • 新增单节点内存限流器,同时按客户端源 IP 与归一化后的 LDAPUsername 两个维度分桶限流。

这一版关闭了响应内容侧信道并抬高了暴力破解成本,但对限流器状态机与客户端 IP 提取逻辑的深入复核很快暴露出新的缺陷。

第二轮:修正成功退额、代理溯源与令牌核算

4 月 16 日的连续补丁(18b712d、9e10f6d、f441108)修复了第一版限流器的三类缺陷:

  1. 显式的预留/提交/取消/退还生命周期(reserve/commit/cancel/refund):认证成功以及 LDAP 基础设施故障必须退还预留令牌,避免正常的高频登录耗尽失败配额。
  2. 默认不信任代理转发头:默认仅使用 TCP 套接字对端地址(RemoteAddr),绝不因为请求携带了 X-Forwarded-For、X-Real-IP 或 Forwarded 就无条件采信,除非直连对端在显式配置的可信代理白名单中。
  3. 退额与容量硬边界:取消与退还逻辑必须严格受限于桶容量上限,防止高并发取消时凭空多退令牌。

第三轮:为什么删掉了 username 分桶

6 月份的第二轮安全复核推翻了“源 IP + 用户名双维度限流一定比单源 IP 限流更安全”的直觉。

由于按用户名划分的限流桶在节点内被所有客户端源地址共享,知道受害者 LDAPUsername 的攻击者可以从任意源 IP(或轮换多个源 IP)持续发送低频错误请求,把目标用户名的配额桶耗尽。这样一来,合法用户的请求在真正进入 LDAP Bind 之前就会被限流器直接拦截,形成低成本的精确定点账户锁定(Account Lockout DoS)。

因此,随 RELEASE.2026-06-18T00-00-00Z 发布的提交 5e40665 做出了五项关键调整:

  • 彻底删除 per-username 分桶,阻断跨源定点锁死特定 LDAPUsername 的攻击路径;
  • 从右向左剥离 X-Forwarded-For 中的可信代理跳数,选取第一个非可信地址作为客户端源 IP;
  • 拒绝 0.0.0.0/0 与 ::/0 等全通配可信代理配置,防止错误配置导致任意客户端伪造源地址;
  • 不再将 RFC 7239 Forwarded 头用于安全敏感限流分桶;
  • 仅在可信代理覆盖写入而非透传客户端输入的契约下使用 X-Real-IP。

这套可信代理源地址解析机制随后被推广至全站 S3 与 Admin 接口,形成 SN-2026-018(MINIO_API_TRUSTED_PROXIES)。

被否决的候选方案

候选方案 否决原因
为未知用户执行一次虚拟 LDAP Bind(dummy bind) 响应内容侧信道已经关闭;额外发起虚拟 Bind 会白白放大上游 LDAP 目录负载并引入第二条易错路径
将所有 IPv6 客户端统一按 /64 前缀分桶 会导致同一园区网或运营商 /64 前缀下的正常用户互相消耗配额、彼此误伤
直接取 X-Forwarded-For 最左侧地址 最左侧字段由客户端任意填写,攻击者可逐次伪造不同 IP 彻底绕过限流
当 X-Forwarded-For 与 X-Real-IP 不一致时回退至套接字对端地址 攻击者可故意注入冲突的请求头强制触发回退,将同一反向代理后的所有合法用户压入同一个限流桶
完整解析 RFC 7239 Forwarded 头 语法解析状态机复杂且边界歧义多,相较 X-Forwarded-For 与 X-Real-IP 无实际安全收益

验证与发布

单元测试套件完整覆盖了限流器 reserve/commit/cancel/refund 状态机、并发安全、认证成功与基础设施错误的配额退还、未知用户与密码错误外部响应等价性,以及直连 RemoteAddr、伪造转发头、单跳/多跳可信代理和拒绝全通配 CIDR(0.0.0.0/0、::/0)等场景。

需要注意,LDAP 安全端到端测试在未设置 _MINIO_LDAP_TEST_SERVER 环境变量时会自动跳过(skip)真实 LDAP Bind 分支;完整验证 LDAP 目录交互时需配置测试 LDAP 服务实例。

最终取舍与残余风险边界

  • 仅按源 IP 限流的代价:移除 username 分桶消除了跨源定点锁死合法账户的隐患,但也意味着不再跨不同源 IP 实施单账号全局失败硬上限。
  • 单节点内存级限流:该限流器为单节点、内存态防护,并非跨集群全局密码防线;针对分布式僵尸网络、IPv6 地址轮换或 LDAP Bind 耗时侧信道,仍需结合上游 WAF 或 LDAP 目录自身的账户保护策略。
  • 反向代理配置要求:部署在反向代理后方时,必须正确配置可信代理白名单(MINIO_API_TRUSTED_PROXIES);若仅使用 Forwarded 头,限流器将回退至按代理对端地址分桶。
  • 统一响应才是防枚举的核心:限流的作用是压低单一来源的尝试速率,而真正隐藏“用户名是否存在”的是统一的外部认证失败响应。

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

普通 PUT 与 COPY 请求可将 X-Minio-Replication-* 请求头混入内部 SSE 与复制元数据;提交 56fa63b 默认剥离不可信请求头,仅在通过 ReplicateObjectAction 授权的复制路径中显式恢复。

状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
修复提交: 56fa63b
GitHub Issue: pgsty/silo#24

在 56fa63b 之前,普通 PutObject 与 CopyObject 请求可以把客户端传入的 X-Minio-Replication-* 请求头混入内部 X-Minio-Internal-* SSE 与复制元数据,写出复制状态与真实授权路径不符、甚至后续无法读取的对象。

最终修复在默认元数据提取路径中不再接受复制专用元数据,仅在通过 ReplicateObjectAction(s3:ReplicateObject)授权的可信复制流程中显式恢复,并在 CopyObject 的所有前置请求头消费者执行之前统一完成清洗。

威胁模型

触发该漏洞只需要普通的对象写入权限(s3:PutObject),不需要节点间凭据或复制专用权限。攻击输入完全来自客户端可控的 X-Minio-Replication-* 请求头,但旧版元数据提取函数在未校验调用方是否具备复制权限的情况下,直接将它们转换成了内部复制或 SSE 状态。

当后续 GetObject 或 HeadObject 请求读取该对象时,服务端会按照伪造的内部状态解析或解密对象,从而导致读取失败,造成对象完整性与可用性破坏。几乎所有接受不可信写入的生产集群都受此影响。

问题的核心在于:来自不可信客户端的请求头在未通过复制授权检查前,就获得了内部特权语义。

为什么选择精确清洗,而非直接拒绝整条请求

最直观的修法是:只要普通请求携带了 X-Minio-Replication-* 请求头,就直接报错拒绝。但这样做会把客户端过去夹带的多余请求头从“被服务端忽略”变成硬失败,无谓地扩大兼容性影响面。

因此我们采用了“默认剥离、授权后恢复”的模型:

  • cmd/handler-utils.go 中的默认元数据提取路径不接受任何复制专用元数据;
  • 普通 PutObject 与 CopyObject 请求在构造元数据前先剥离这些字段;
  • 只有通过 ReplicateObjectAction 授权的代码分支才会显式恢复复制专用元数据;
  • 副本状态(replica status)写入共用同一可信授权条件;
  • 合法的分片上传与 Snowball 复制流程在通过授权后显式恢复所需的 SSE 元数据。

这样既彻底阻断了未授权注入,又不会破坏携带多余请求头的普通客户端。

为什么 CopyObject 必须在 cleanMetadata 之前提前清洗

在 CopyObject 中,请求头并非只在最后构造目标对象元数据字典(cleanMetadata)时才被读取,前置条件(Precondition)检查与 SSE-C 源对象密钥处理都会更早读取请求头。

如果等到即将写入目标对象前才剔除不可信的 X-Minio-Replication-* 请求头,早期的消费者就已经被伪造字段污染。把请求头清洗前移到 CopyObject 入口、位于所有前置条件与 SSE-C 处理逻辑之前,才能将“不可信复制请求头绝不进入内部语义”收敛为单一不变量,而不必依赖下游每个函数各自重复防御。

实现与验证

提交 56fa63b 修改了 cmd/handler-utils.go、对象处理器与分片上传处理器,并配套了多层回归测试:

  • 辅助函数层的可信与不可信元数据提取单元测试;
  • 处理器层携带恶意 X-Minio-Replication-* 请求头的 PutObject 与 CopyObject 用例;
  • 验证 CopyObject 前置条件与 SSE-C 路径不会读到未授权复制请求头的入口清洗测试;
  • 端到端验证:确认恶意请求头不再产生不可读对象,同时合法复制、分片上传与 Snowball 复制流程保持正常。
go test ./cmd -run 'Test.*Replication.*Metadata|Test.*CopyObject.*' -count=1

设计取舍与后续接收端全局加固

  • 默认静默剥离:普通客户端夹带的内部复制请求头会被直接剥离并忽略。
  • 安全的失效默认方向:由于复制专用元数据必须在授权分支内显式恢复,未来若新增复制入口时漏掉恢复调用,只会表现为功能测试失败,而不会静默放开未授权注入。
  • 后续接收端全局加固:
    • SN-2026-008(RELEASE.2026-09-03T13-18-01Z) 与 CORS 及复制信任设计记录 将这一模式推广为接收端统一的认证后信任边界,覆盖全部内部复制请求头(X-Minio-Source-Etag、X-Minio-Source-Mtime、X-Minio-Source-Replication-Request、复制 SSE 密钥头与 X-Amz-Bucket-Replication-Status)以及 SSE-C 读取、源时间戳、分片合并、对象锁、删除与事件通知。
    • 副本元数据规范化(RELEASE.2026-09-16T00-00-00Z,PR #194) 进一步确保:即便在已授权的可信复制 CopyObject 路径中,服务端也会规范化仅属于传输层的 aws-chunked Content-Encoding 并剔除已废弃的敏感元数据键,防止原始请求头被重新注入落盘对象。

11 - CVE-2026-39414:S3 Select 超大记录与 SIMD 快路径绕过

第一轮修复为 CSV 与 JSON Lines 加上 1 MiB 单记录上限;第二轮调用链复核发现支持 SIMD 的 CPU 会走 simdjson 快路径从而完全绕过有界读取器。

状态: 已发布,6 月完成第二轮闭环
初始修复版本: RELEASE.2026-04-17T00-00-00Z
完整修复版本: RELEASE.2026-06-18T00-00-00Z
修复提交: 3252d5b、5dc705936(fd69c89)
GitHub Issue: pgsty/silo#25

4 月份的第一轮修复(3252d5b)复用既有的 1 MiB maxCharsPerRecord 常量,同时限制 S3 Select 中的 CSV 与标准 JSON Lines 单条记录长度,防止在遇到记录分隔符前无界缓冲内存,并向客户端返回明确的 OverMaxRecordSize 错误码。

6 月份的调用链复核却发现了一个与硬件特性绑定的绕过缺口:在支持 AVX2/AVX-512(即 simdjson.SupportedCPU() 返回 true)的 CPU 上,JSON Lines 会进入另一条 simdjson 快路径,完全绕过第一轮修复的 json.PReader。最终方案(5dc705936 / fd69c89)让 JSON Lines 在所有 CPU 上统一走有界读取器,并同步修正了流式错误码映射、解析器错误包装以及终止错误事件前的已完成记录刷出逻辑。

威胁模型

具备 s3:GetObject 权限的调用方可以通过 S3 Select(SelectObjectContent)查询包含超长无换行单条记录的对象。如果读取器在遇到记录分隔符前持续向内存追加缓冲而没有上限,单次查询即可耗尽节点内存与 CPU。

更隐蔽的风险在于硬件特性分流:同一份输入对象在不同指令集特性的机器上会进入完全不同的解析实现。在不支持 SIMD 的测试环境中跑通的边界检查,并不自动保护支持 AVX2/AVX-512 的生产服务器。

第一轮:复用既有的 1 MiB 单记录不变量

第一版补丁(3252d5b)没有引入新的配置项,而是直接沿用代码库已有的 maxCharsPerRecord = 1 MiB(1048576 字节)规则:

  • CSV 切分器与标准行分隔 JSON 读取器在继续缓冲或解析前拒绝超过 1 MiB 的单条记录;
  • 保留最早发生的切分器错误,避免被后续不完整解码产生的次生错误覆盖;
  • 将错误准确透传为 OverMaxRecordSize,而不再折叠成笼统的 InternalError,使客户端与监控告警能够区分“触发安全长度上限”与“服务端内部故障”。

第二轮:与 CPU 指令集相关的 SIMD 绕过

6 月份顺着 S3 Select 的 JSON 分发链路继续下钻时,我们发现了下面这条分支:

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

当 simdjson.SupportedCPU() 为真时,JSON Lines 直接进入 simdj.NewReader,完全绕过了有界的 json.PReader。而在 simdjson.ParseNDStream 内部,第三方解析器会跨数据块持续读取,直到找到换行符为止。如果在底层 io.Reader 外面再套一层字节计数包装器,根本无法同时满足“不截断同一数据块内已完成的合法记录”与“严格限制下一条记录不超过 1 MiB”两个条件。

因此,提交 5dc705936 与 fd69c89 没有采用脆弱的外层包装器,而是停用了 JSON Lines 的 SIMD 分支,让所有 CPU 统一走有界的 json.PReader。未来若要重新启用 SIMD 加速,SIMD 解析器自身必须原生执行相同的单记录长度边界,并通过同一套与 CPU 特性无关的回归测试。

同一轮修正的流式协议语义

6 月份的复核同时修复了 S3 Select 流式管道中的四处相邻问题:

  • 基于接口识别错误类型:改用 errors.As 透传所有实现 SelectError 接口的错误,而非仅匹配单一具体结构体类型。
  • 包装 JSON 语法错误:在 JSON worker 中将语法解析失败统一包装为 JSONParsingError。
  • 终止错误前刷出已完成记录:在写出终止错误事件(terminal error event)之前,先将输出队列中已完成解析但尚不足一个 batch 大小的结果刷出给客户端。
  • 按输入顺序保留错误优先级:当输入流中前一行先发生语法错误、后一行又出现超长记录时,保留最早发生的解析错误而不被后续超长记录覆盖。

刻意排除在本 CVE 之外的问题

为避免在安全补丁中混入未经充分兼容性求证的行为变更,以下三项议题被明确留在本次修复范围之外:

  • CSV AllowQuotedRecordDelimiter 与外层物理换行切分器之间的历史语义偏差;
  • CRLF 中的 \r 是否计入 1 MiB 记录长度;
  • 在 simdjson 内部尚未具备原生记录长度裁剪机制前恢复 SIMD NDJSON 快路径。

验证与发布

internal/s3select 的回归测试覆盖了超长 CSV 与 JSON Lines 记录、OverMaxRecordSize 与 JSONParsingError 错误码保留、错误前队列刷出以及不依赖本机 SIMD 能力的行为一致性验证:

go test ./internal/s3select/... -count=1

初始修复随 RELEASE.2026-04-17T00-00-00Z(3252d5b)发布,消除 SIMD 绕过的完整修复随 RELEASE.2026-06-18T00-00-00Z(5dc705936 / fd69c89)发布。

最终取舍

  • 吞吐量与一致边界的取舍:停用 SIMD NDJSON 快路径会降低支持 AVX2/AVX-512 主机上的 JSON Lines 解析吞吐量,但换取了所有 CPU 架构上严格一致的 1 MiB 单记录内存上限。
  • 1 MiB 单记录硬上限:包含超过 1 MiB 单行或单记录的输入在升级后会被 OverMaxRecordSize 拒绝,客户端需在查询前切分超长记录。
  • 带引号的多行 CSV:带引号多行 CSV 的分隔符语义作为独立兼容性问题另行跟踪。

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

Snowball unsigned-trailer 请求原本可在完成认证前进入解包器;提交 efb6e5b00 将 SigV4 验签与 newUnsignedV4ChunkedReader 初始化前移到任何 tar 字节进入 untar() 之前。

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

Snowball 自动解包处理器 PutObjectExtractHandler(X-Amz-Meta-Snowball-Auto-Extract: true)此前漏掉了 authTypeStreamingUnsignedTrailer 认证分支。携带伪造签名的 STREAMING-UNSIGNED-PAYLOAD-TRAILER tar 数据流可以在未完成认证的情况下直接进入 untar(),使单次未认证请求扇出为多个对象写入。

最终修复(efb6e5b00)在任何 tar 归档字节进入解包器之前,初始化 newUnsignedV4ChunkedReader()、校验解码后内容长度并完成完整 SigV4 签名验证。

为什么提交标题使用了临时编号

2026-04-16 编写并合入补丁时,GitHub Security Advisories 尚未公布正式 CVE 编号,因此 Git 提交标题使用了临时占位编号 fake CVE-2026-40028。GHSA-9c4q-hq6p-c237 正式发布后,该漏洞编号确定为 CVE-2026-40344。我们保留了不可变的 Git 提交对象 efb6e5b00,并在安全台账与本文中统一映射为正式编号。

从一个遗漏的认证分支到批量对象写入

受影响入口为处理 Snowball 归档自动解包的 PutObjectExtractHandler(X-Amz-Meta-Snowball-Auto-Extract: true)。当请求使用 STREAMING-UNSIGNED-PAYLOAD-TRAILER(authTypeStreamingUnsignedTrailer)模式时,旧处理器没有像普通 PutObjectHandler 那样覆盖该认证类型。

这一遗漏的危害远超单次普通对象误放行:一旦未认证的 tar 数据流进入 untar(),单个请求即可在目标桶内创建或覆盖任意多个由攻击者指定的对象,把一次认证分支遗漏直接放大为批量写入原语。

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

设计复核为修复确立了一条硬性不变量:

只要认证最终失败,untar() 读取到的字节数必须为零。

这条规则直接排除了“边解包边校验、失败后再回滚”的思路。由于 untar() 解包出的对象写入会流经多条内部存储路径,事后证明回滚完整远比在入口拦截困难。唯一可靠的收口点,就是在任何字节进入 untar() 之前完成认证决断。

实现细节

提交 efb6e5b00 将 PutObjectExtractHandler 对 authTypeStreamingUnsignedTrailer 的处理与标准 PutObjectHandler 对齐:

  • 在认证分发 switch 中显式识别 authTypeStreamingUnsignedTrailer;
  • 读取并校验 X-Amz-Decoded-Content-Length;
  • 使用 newUnsignedV4ChunkedReader() 包装请求流,在向调用方产出任何数据字节之前完成完整 SigV4 请求验签(与 CVE-2026-41145 共用同一底层 reader 边界);
  • 完整保留合法签名 Snowball 自动解包请求与尾部 CRC32 校验和(trailer)工作流。

验证与发布

cmd 包中的回归测试覆盖了完整边界:

  • 拒绝携带伪造签名的 Snowball unsigned-trailer 请求,且确认桶内零对象落盘;
  • 拒绝针对非公开桶的匿名 Snowball 自动解包写入;
  • 验证携带合法签名与尾部 CRC32 校验和的 Snowball 归档可正常流式解包。
go test ./cmd -run 'Test.*Snowball.*' -count=1

兼容性与运维边界

  • 拦截未验签请求组合:此前依赖未实际验签的 unsigned-trailer Snowball 请求客户端,在升级后会在解包开始前收到 AccessDenied。
  • 共享 reader 边界:Snowball 自动解包与普通 PutObject 的 unsigned-trailer 路径现在共用 newUnsignedV4ChunkedReader(),后续对该分块读取器的修改必须同时回归两条路径。
  • 解包器内部边界独立:将认证前移至 untar() 之前解决了未授权进入解包器的问题,而已授权归档内部的条目路径规范化、归档体积与对象数量控制仍属于独立的防御面。

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

查询字符串 SigV4 凭据进入 STREAMING-UNSIGNED-PAYLOAD-TRAILER 数据流后未被验签;提交 f444b6f37 将预签名拒绝与 SigV4 验签统一收口至共享的 newUnsignedV4ChunkedReader()。

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

在 f444b6f37 之前,查询字符串(query-string)形式的 SigV4 凭据可以进入 STREAMING-UNSIGNED-PAYLOAD-TRAILER 上传流,但旧处理器仅在存在 HTTP Authorization 请求头时才执行签名验证。因此,攻击者只要知道一个有效的 Access Key ID,无需构造正确签名即可完成对象或分片写入。

最终修复将预签名请求拒绝与 SigV4 签名验证直接收口进 newUnsignedV4ChunkedReader()(cmd/streaming-v4-unsigned.go),让所有消费该分块数据流的调用方强制共用同一道认证边界。

提交编号说明

2026-04-16 编写补丁时正式 CVE 编号尚未分配,Git 提交标题临时写为 fake CVE-2026-40027。GHSA-hv4r-mvr4-25vw 发布后,正式编号确定为 CVE-2026-41145。历史提交对象 f444b6f37 保持原样,台账与文档统一使用正式编号。

根因:将签名验证绑定到了凭据传输形态

受影响入口包括 PutObject 与 PutObjectPart(UploadPart)。当请求指定 X-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER,并将 SigV4 凭据与签名(X-Amz-Credential、X-Amz-Signature 等)放在 URL 查询字符串而非 Authorization 请求头中时,旧代码的两处假设发生了碰撞:

  1. 处理器用“Authorization 请求头是否非空”来决定是否调用 reqSignatureV4Verify,误以为请求头为空就代表纯匿名请求;
  2. 身份提取与 newUnsignedV4ChunkedReader() 仍然照常读取分块请求体,并将该次写入归属到查询参数 X-Amz-Credential 指定的 Access Key 名下。

结果是:携带查询字符串凭据的 unsigned-trailer 请求跳过了签名校验,静默降级为“只要报出有效 Access Key 标识即可写入”。

为什么把守卫下沉到共享 Reader,而非逐个修补 Handler

候选方案 风险 结论
在 PutObject 与 PutObjectPart 中各自修补请求头/查询参数判断 仅闭合当前两个入口;其他或未来新增调用 newUnsignedV4ChunkedReader() 的入口极易再次漏检(正如 CVE-2026-40344 所示) 否决
额外设计兼容预签名查询参数的 STREAMING-UNSIGNED-PAYLOAD-TRAILER 协议 在没有既有 S3 协议支持契约的情况下大幅扩张流式鉴权状态机与测试面 否决
在 newUnsignedV4ChunkedReader() 入口统一拒绝预签名凭据并强制验签 所有消费该数据流的调用方强制经过同一道认证门禁 接受

需要注意,纯匿名 STREAMING-UNSIGNED-PAYLOAD-TRAILER 请求并未被一刀切禁用:当请求未携带任何认证凭据、且目标桶策略(Bucket Policy)显式允许匿名 s3:PutObject 时,它仍按匿名授权路径正常工作。真正被禁止的是“携带了查询凭据,却未对该凭据执行验签”的混合状态。

实现与验证

提交 f444b6f37 在 cmd/streaming-v4-unsigned.go 的 newUnsignedV4ChunkedReader() 入口完成预签名拒绝与 SigV4 签名验证,同时移除了 PutObject 与分片上传处理器中依赖 Authorization 请求头是否存在的条件判断。

cmd 包新增的回归测试覆盖:

  • 拒绝 STREAMING-UNSIGNED-PAYLOAD-TRAILER 模式下伪造查询签名的 PutObject 与 PutObjectPart 请求;
  • 拒绝同时混用请求头与查询参数凭据的请求;
  • 验证标准请求头认证的 unsigned-trailer 上传以及桶策略允许的纯匿名写入继续正常工作。
go test ./cmd -run 'Test.*UnsignedTrailer.*|Test.*StreamingUnsigned.*' -count=1

兼容性与运维边界

  • 明确拒绝预签名 unsigned-trailer:预签名/查询字符串 SigV4 凭据与 STREAMING-UNSIGNED-PAYLOAD-TRAILER 的组合现已明确不受支持,属于有意的不兼容收紧(Breaking Change)。
  • 下沉至 Reader 的结构性收益:当多个处理器共享同一种认证数据流时,将认证门禁放进 newUnsignedV4ChunkedReader() 能够从结构上防止 PutObject、PutObjectPart 或 PutObjectExtract(CVE-2026-40344)再次因外层分支不一致而漏检。

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

从前置全量路径校验到彻底删除整条 RPC 接口:为什么一个自 2024 年起已无生产调用方的内部文件读取端点在 73ac524 中被直接移除。

状态: 已发布
首个包含版本: RELEASE.2026-06-18T00-00-00Z
修复提交: 73ac524
GitHub Advisory: GHSA-xh8f-g2qw-gcm7
影响范围: 仅分布式纠删码(distributed erasure)部署;需持有集群 root / 节点间 JWT

节点间 storage-REST 路由 /rmpl(ReadMultiple)的 msgpack 请求体中包含 Bucket、Prefix 与 Files 字段,旧代码未做磁盘根目录包含(containment)检查便直接将它们拼接成主机文件系统路径。

我们的第一版修复保留了该接口并实现了完整的前置路径校验;但在继续审计全仓调用链时发现,这条内部 API 自 2024 年 9 月起就已经没有任何生产调用方。最终方案(73ac524)因此从“原地保留并加固”转为彻底删除路由、处理器、客户端封装、StorageAPI 接口方法以及 msgpack 生成代码——删除约一千行废弃代码看似改动更大,却永久消除了这一高权限攻击面。

威胁模型

/rmpl 路由仅在分布式纠删码模式下注册,单机(single-node)部署不受影响。触发该漏洞需要攻击者持有由集群 root 密钥派生的节点间 JWT、已控制某个集群节点,或能够截获未加密的节点间通信流量。

由于受控的 Bucket、Prefix 与 Files 字段位于 msgpack 请求体内部,而非 URL 路径或查询参数(r.Form)中,上层 HTTP 路径校验中间件完全看不到这些字段。xlStorage.ReadMultiple 直接将它们与磁盘根目录拼接并读取文件,使持有节点间凭据的调用方能够跳出磁盘根目录(drive root),读取节点进程有权访问的任意主机文件。

第一版方案:保留接口并执行路径包含校验

第一版补丁选择在 xlStorage.ReadMultiple 内部原地加固:

  • 拒绝绝对路径、. 与 .. 路径段、反斜杠、Windows 盘符前缀以及 NUL 字节;
  • 校验 drive -> volume -> prefix -> file 的最终路径包含关系;
  • 兼容空 Bucket 与 .minio.sys/multipart 的历史调用约定;
  • 在打开任何文件或开始流式输出之前返回错误。

这套方案足以拦截直接路径穿越,但代码审查很快指出了读取循环与 MaxResults 交互时的一个提前返回漏洞。

MaxResults 暴露了“边读边校验”的缺陷

初版补丁在读取循环中逐项打开 Files 元素前才对该元素做路径校验。如果请求传入 Files = [good, "../../etc/passwd"] 且同时指定 MaxResults = 1,函数在读取第一个合法文件后就会因达到 MaxResults 上限而提前返回,第二个恶意路径永远不会被检查。

虽然该次请求并未实际读取第二个恶意文件,但它破坏了“包含非法路径的整包请求必须在执行任何读取前被拒绝”的安全不变量。为此,我们将所有路径与长度校验前移为流式响应开始前的全量前置(preflight)检查。

最终转折:为什么直接删掉整条 API

在合入前置校验补丁前,我们进一步追问了一个更根本的问题:现在究竟还有谁在调用 ReadMultiple?

全仓调用链与 Git 历史清查证实:

  • 上游早在 2024 年 9 月就已移除最后一个生产调用方,分片读取已全面切换至 ReadParts;
  • 当前 SILO 代码树中没有任何生产路径调用 ReadMultiple;
  • 上游针对 GHSA-xh8f-g2qw-gcm7 的正式处置同样是直接删除 ReadMultiple。
候选方案 短期代码改动 长期维护与攻击面 结论
原地保留并做前置校验 Diff 较小,保留 /rmpl 端点 在 StorageAPI 中永久维护一个无人使用的高权限批量读文件入口 放弃
彻底删除 ReadMultiple 涉及接口与 msgpack 生成代码,删除行数较多 彻底消除 /rmpl 攻击面,同时保持 storageRESTVersion 协议兼容 接受

提交 73ac524 移除了 /rmpl 路由、storage-REST 处理器、客户端包装、StorageAPI / xlStorage 方法、监控指标项以及 msgpack 请求/响应数据类型。

验证与发布

在原地校验阶段,我们编写并执行了覆盖 xlStorage、storage-REST 客户端、msgpack 编解码、路径穿越边界与 MaxResults 前置校验的单元测试。切换为彻底删除方案后,提交 73ac524(随 RELEASE.2026-06-18T00-00-00Z 发布)验证了路由、接口、生成代码与 go test ./cmd 全量通过。

兼容性与后续全局路径审计(SN-2026-002)

  • 外部 S3 API:无任何变化。
  • 集群升级建议:在分布式集群滚动升级期间,建议将所有节点同步升级至相同版本。
  • 其余内部节点接口的全量路径审计:针对其余在 msgpack 请求体或 Grid RPC 帧中传递文件路径的内部节点接口,全面清查与加固已在 SN-2026-002:节点间路径与内存分配边界收敛(随 RELEASE.2026-08-04T00-00-00Z 发布)中完成,覆盖全部剩余的 storage-REST、storage Grid RPC 与 peer-S3 Grid RPC 端点。

15 - SN-2026-002:内部节点路径包含审计——补完 CVE-2026-42600 欠下的那笔账

SN-2026-002 在 CVE-2026-42600 之后对全部四个节点间协议面开展系统审计,闭合 12 项继承自上游的路径穿越、卷根别名、除零崩溃与无界内存分配缺陷,并完整记录开发过程中自造并拦截的 4 次回归。

发布状态: 已随 Server 20260804 发布(发行注记)
安全编号: SN-2026-002(安全公告台账)
修复提交: ca7baa670、b6f70ab08、80e8eaa42、1af351a70、a36fd8fff
影响范围: 仅分布式纠删码(distributed erasure)部署;需持有集群 root / 节点间 JWT
前置阅读: CVE-2026-42600 · ReadMultiple

在 CVE-2026-42600 移除 /rmpl(ReadMultiple)端点之后,SN-2026-002 进一步审计了其余所有在 msgpack 请求体或 Grid RPC 帧中传递文件路径的内部节点接口。本文记录跨越四个内部协议面的完整审计结果、检出的 12 项继承缺陷,以及我们在修复过程中引入并在发版前拦截的 4 次回归。

结论先行

  • 三个无保护协议面共检出 12 项缺陷,全部继承自上游:经逐函数校验和比对,相关存储文件此前在分支上的改动仅为删除废弃代码,未新增任何逻辑。
  • 与 CVE-2026-42600 同源:ReadMultiple 只是踩中这一架构断层的其中一个端点;删掉它之后,同一根因下仍敞开着三个协议面。
  • 修复过程中自造并拦截了 4 次回归:所有回归全部出现在我们“自己新发明的规则”(isVolumeRootAlias 与 guardErasureParams)上;直接复用对象层既有语义(hasBadPathComponent)的部分则是零回归。

为什么“数端点个数”是错误的分析框架

早期的局部排查一直在数 handler 个数,先后数出 19、21、22 个端点,却每次都漏掉整片协议面。按参数到达底层 xlStorage 的通道来划分,内部节点通信共有四个协议面:

协议面 入口数 是否受全局 HTTP 查询参数中间件保护
Storage-REST HTTP 查询参数(query) 9 是——此前曾被误判为无保护
Storage-REST HTTP msgpack 请求体 4 否;r.Form 从不读取 msgpack 请求体
Storage Grid RPC(/minio/grid/v1) 18 否;完成一次 WebSocket 升级后,后续帧不再进入 HTTP 中间件链
Peer-S3 Grid RPC 5 否;不仅绕过 HTTP 中间件,还绕过 getStorage() 直达底层磁盘

第一行纠正了此前的误判:只在 handler 函数体内部 grep 校验函数的排查方式,忽略了 9 个 query 参数接口其实已被全局 HTTP 中间件(url.ParseQuery(RawQuery) 填充 r.Form)保护。而第四行(Peer-S3 Grid RPC)则说明:任何只修补 storage-rest-server.go 的方案都碰不到直接操作底层磁盘的 DeleteBucket 等跨节点桶级 RPC。

三层历史设计叠加出的结构性盲区

三个各自看似合理的设计事实叠加在一起,构成了整类漏洞的根因:

  1. 路径校验只发生在 HTTP 表层:全局中间件只检查 r.Form,而 r.Form 仅由 url.ParseQuery(RawQuery) 填充,永不解析请求体(2017 年定型)。
  2. Grid RPC 绕过了 HTTP 中间件:/minio/grid/v1 在建立连接时完成一次 WebSocket 升级,之后的 msgpack 帧直接分发给 handler,不再经过 HTTP 中间件链(2023 年引入)。
  3. 存储层默认调用方已完成校验:getVolDir 仅在 volume 精确等于 ""、. 或 .. 时拒绝,而 pathJoin 会直接调用 path.Clean(2018 年定型)。

换句话说:上层以为下层会校验,下层以为上层已校验,而中间新增的二进制帧通道两边都看不见。

时间线上还有一处值得单独指出的叠加效应:ShardFileSize 的除零风险早在 2020 年就存在,但直到 2024 年 10 月为了修复大对象超时将其挪进 xioutil.WithDeadline 后,它才从“单次请求报错”升级为“整个节点进程崩溃退出”——因为 WithDeadline 在未挂载 recover() 的裸 goroutine 中执行工作函数,Go 无法跨 goroutine 捕获 panic。

经实测复现的攻击向量

以下所有向量均在真实 xlStorage 实例上,通过真实的 REST / Grid 客户端配合植入哨兵文件实测复现:

攻击向量 协议面 实测结果
WriteAll("vol", "../../x") Storage Grid RPC 磁盘根目录(drive root)外 任意文件写入
RenameFile(".minio.sys", "", "bucket", "x") Storage Grid RPC 将整个 .minio.sys 系统卷(IAM、集群配置)搬入普通可读 S3 桶,全程无需 ..
DeleteBucket("../victim", force) Peer-S3 Grid RPC 磁盘根目录外 整棵目录树递归删除
DeleteBulk("vol", "") Storage-REST 请求体 整个卷被移入回收站目录
ReadAll(volume: "../") 任意协议面 追加一个尾斜杠即可绕过 getVolDir 的 ".." 精确匹配检查
CheckParts 传入零值 Erasure(DataBlocks = 0) Storage Grid RPC WithDeadline 内除零 panic,节点进程直接终止
AppendFile 声明 Content-Length: 64 GiB Storage-REST HTTP 空请求体直接触发 68,719,574,840 字节内存分配
DeleteVersions 声明 1 亿条元素 Storage-REST 请求体 10 字节 msgpack 头部 触发 10.4 GB 切片预分配
分片元数据 Size = -2 Storage Grid / AddVersion 被截断的损坏分片被报告为健康,后台自愈静默跳过修复

其中两条此前从未被发现的向量尤其关键:

  • 空源路径的 RenameFile(srcPath = ""):空路径会直接命中卷根目录本身。针对 .minio.sys 调用 RenameFile(".minio.sys", "", "bucket", "x") 会把整个系统元数据卷搬进普通 S3 桶,攻击者随后发一次普通 S3 GetObject 就能读走集群 IAM 与配置。整个载荷不含任何 ..,任何只盯着 .. 的路径审计都会漏掉它。
  • 负数分片尺寸(Size = -2):负数 Size 会让 ShardFileSize 的计算结果向下取整为 0。而 checkPart 的唯一完整性判据是 st.Size() < expectedSize,于是任何存在的文件(哪怕是被截断的 0 字节文件)都会因为 0 < 0 == false 被判定为完好。更棘手的是,自愈流程信任的 FileInfo.IsValid() 并不拒绝负数分片尺寸,导致合法的后台自愈读到毒化元数据后会误判“分片完好”并静默跳过修复。

修复设计:两处收口覆盖 36 个入口

需要恢复的安全不变量非常明确:内部节点载荷中的每个文件路径必须解析在其指定的 volume 之内,每个 volume 必须解析在 drive root 之内。

由于绝对路径与反斜杠前缀会被 pathJoin 的 path.Clean 折回 volumeDir 之下,能够突破前半句的只有 .. 路径段,能够突破后半句的只有卷根别名(空串、纯斜杠、. 以及 Windows 下的尾随空格/句点组件)。提交 ca7baa670 在两处收口点执行这两条规则:

收口点 源码位置 覆盖范围
卷名轴(Volume axis) getVolDir + NSScanner 显式守卫 全部 xlStorage 调用方,含 Peer-S3 Grid RPC
路径轴(Path axis) getStorage() 装饰器(guardPaths、isVolumeRootAlias) 31 个远程 storage-REST / Storage Grid RPC 方法及其嵌套结构体字段

核心逻辑不到 40 行,不修改具体 handler,也不触碰本地纠删码热路径。有两处实现细节必须在未来的重构中保持:

  • 校验必须在 pathJoin 之前、针对原始参数执行:pathJoin 会对拼接后的绝对路径 drivePath 执行 path.Clean,把前导 .. 彻底抹掉(例如 /drive/../../etc 清理后变成 /etc)。如果把检查放在 pathJoin 之后,看到的永远是干净路径,形同虚设。
  • NSScanner 的单行守卫是承重的:NSScanner 是唯一不经过 getVolDir 就直接访问文件系统的 xlStorage 方法,因此必须保留其独立守卫。

我们也评估过“在 xl-storage.go 的全部 33 处 pathJoin(volumeDir, ...) 调用点逐一加检查”,但那意味着要在全仓最性能敏感的文件里改动 33 处,且每一处都要单独判断卷根目录本身是否为合法目标。采用两处收口点配合 StorageAPI 反射护栏测试,以小得多的风险换取了同等的抗漂移能力。

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

在开发与审查过程中,我们自己的初版代码曾先后制造出四次回归:

  1. 误杀空格对象键(" " 与 " "):第一版路径守卫把空白字符当成了非法路径分隔符,直接拒绝了 " " 与 " "——而它们是完全合法的 S3 对象键。由于 PutObject 最终通过 RenameData 提交数据,上传名为 " " 的对象会在所有远程磁盘上同时失败,直接打崩写入 quorum。
  2. 误杀 Unix 下的纯反斜杠键("\\"):同一函数还拒绝了 "\\"。在 Unix 上,path.Clean 从不把 \ 当作目录分隔符,因此 "\\" 只是一个普通文件名。在节点间装饰器中拒绝它,会导致分布式集群拒绝单机部署接受的合法 S3 写入。
  3. Windows 下的空格与句点卷根别名(" " 与 "..."):Win32 路径规范化层会自动剥离路径组件末尾的空格与句点,因此完全由空格或句点组成的路径组件会凭空消失并退化为父目录。这意味着在 Windows 上 " " 与 "..." 都是卷根别名——而在审查指出这一问题前,"..." 正好躺在我们的“合法对象名”正向测试白名单里。
  4. 负数分片尺寸(Size < 0):初版 guardErasureParams 只拦截了“正数尺寸 + 不可用纠删码参数”,把“非零”写成了 Size > 0,漏掉了负数尺寸通过另一条计算路径同样会让 ShardFileSize 归零。

两次“假绿”测试与一处 Fuzz 盲区

编写复现与验收测试时,我们还踩中了三个测试陷阱:

  1. REST 客户端静默覆盖 Content-Length:最初通过 storageRESTClient 发送伪造的 Content-Length: 64 GiB(配合空 *bytes.Reader)测试 AppendFile 时,客户端库对 *bytes.Reader 做了类型特判并根据其实际长度重写了 Content-Length: 0,导致测试拿到了毫无意义的绿灯。
  2. Go 标准 http.Client 拒绝发送截断请求体:换成不透明 io.Reader 后,Go 自己的 HTTP 客户端传输层又因为请求体实际长度短于声明的 Content-Length 而拒绝发包。最终改用 httptest 直接驱动服务端 handler,才真实复现出 64 GiB 分配。
  3. Fuzz 属性测试中的提前返回盲区:初版 Fuzz 测试把“仅由 / 和 \ 组成的字符串”写成了例外并提前 return。这一行提前返回亲手把 Fuzz 挡在了纯反斜杠键门外,哪怕跑上百万次也搜不到 "\\"。随后我们将属性测试改写为对 IsValidObjectName 的严格包含断言,彻底消除了手写例外。

复用既有语义与自造规则的回归对比

守卫组件 语义来源 开发期间引入的回归数
guardPaths 复用对象层既有的 hasBadPathComponent(IsValidObjectPrefix) 0
getVolDir 守卫 复用对象层既有的 hasBadPathComponent 0
isVolumeRootAlias 新编写的规则 3
guardErasureParams 新编写的规则 1

hasBadPathComponent 作为对象层自身的校验规则(经 IsValidObjectPrefix),早已在多年真实 S3 流量下验证过,结构上不可能拒绝任何可通过 S3 API 合法创建的对象名;而全部 4 次回归都出在新造规则与 S3 / 操作系统边角语义不一致的地方。

护栏测试

为防止未来改动破坏收口边界(避免重演此前 CVE-2026-39414 单点修补后再次出现同类缺口的问题),测试套件从三个方向建立了自动化护栏:

  • 可证伪性(变异测试验证):逐一临时注释掉守卫逻辑,确认测试真正变红(移除路径穿越守卫后 191 个子测试失败;移除内存分配守卫后 64 GiB 与 10.4 GB 分配立即复现)。
  • 合法 S3 流量 Fuzz 等价性:断言凡是 IsValidObjectName 接受的对象键,guardPaths 必须全部放行(196 万次执行零违例);凡是合法桶名,getVolDir 必须全部放行(81 万次执行零违例)。
  • StorageAPI 方法级反射护栏:通过反射扫描 StorageAPI 接口的所有方法;一旦未来新增了接收路径参数却未在装饰器守卫表中登记的方法,测试直接按方法名报错失败。
go test ./cmd -run 'Test.*Containment.*|Test.*Guard.*|Fuzz.*' -count=1

独立代码审查记录

本次修复在合入前经过五轮独立代码审查,依次定位出五处遗漏缺陷:空白键误杀 -> 纯反斜杠键误杀 -> AppendFile 64 GiB 内存分配 -> Windows 空格与句点卷根别名 -> 负数分片尺寸毒化。将实现与独立验收分离,使这 4 处兼容性或完整性回归在发版前被全部拦截。

Server 20260804 同步发版的五项后续加固

除主提交 ca7baa670 外,以下五项配套修复已随 Server 20260804 一并发布:

  • ReadFileHandler 读取长度上限(b6f70ab08):提交 b6f70ab08 拒绝超过 5 GiB(单 S3 分片最大尺寸)的声明读取长度,消除由调用方任意声明数十 GiB 触发的预分配。
  • 拒绝负数分片尺寸(80e8eaa42):提交 80e8eaa42 在 AddVersion 写入收口点以及 CheckParts、VerifyFile、节点间 guardErasureParams 中统一拒绝负数分片尺寸,同时覆盖新写入与历史落盘元数据。
  • 拒绝非正数纠删码块大小(80e8eaa42):提交 80e8eaa42 在 NewErasure 构造时校验 blockSize > 0,并在 rebalance 的独立除法边界补充校验。
  • ReadParts 存储错误正常透传(1af351a70):提交 1af351a70 修复了 ReadPartsHandler 在存储调用结束前就过早调用 done(nil) 的历史 bug;此前 ReadParts 的任何错误(包括 errFileNotFound)都会被塞进保活流并被客户端当作 msgpack 解析,报出 msgp: attempted to decode type "int" with method for "map"。修复后真实存储错误正常进入 quorum 归约。
  • WithDeadline 协程 panic 恢复与日志告警(a36fd8fff):提交 a36fd8fff 在 xioutil.WithDeadline 内捕获 panic,将原本会导致进程退出的异常转化为一行 stderr 日志(panic in deadline-bounded work: ...,单进程生命周期内最多输出 10 次完整调用栈)并仅使当前请求失败。建议运维人员为日志关键字 panic in deadline-bounded work: 配置监控告警。

已知限制

以下两项边界需在运维评估时知悉:

  • Windows 平台验证边界:SILO 发布 Windows 二进制构建,但自动化 CI 运行在 Ubuntu 环境;Windows 卷根别名规则(" " 与 "...")系根据 Win32 路径规范化文档语义实现。
  • 符号链接词法校验:路径包含校验采用词法级(lexical)分析,与上游 xlStorage 一致,不解析磁盘根目录内部指向外部的操作系统符号链接。

16 - SN-2026-003:s3:versionid 条件求值缺陷与请求条件隔离加固

解析 s3:versionid 上的 Null 条件为何发生逻辑反转、为何仅修改 getConditionValues 会捅出 DeleteObjects XML 绕过漏洞,以及 SN-2026-003 与 SN-2026-005 的完整加固方案。

SN-2026-003 与 SN-2026-005 涵盖了随 Server 20260804(提交 80c2c9d03、2f55347f7、eee05a17c 以及 silo-pkg v3.12.0 提交 2b087a1;pgsty/silo#40、pgsty/silo#53)发布的一组 S3 IAM 策略求值加固:

  1. SN-2026-003(80c2c9d03):当请求未携带 versionId 时,getConditionValues 依然无条件写入了 args.ConditionValues["versionid"] = []string{""},导致针对 s3:versionid 的 IAM Null 条件恒判定为“键已存在”;而如果只修改 getConditionValues,又会导致批量删除 DeleteObjects 请求体中的 XML <VersionId> 绕过版本条件策略,必须配合 authorizeRequestWithTags 中的逐条重绑定一并修复。
  2. 条件来源隔离(2f55347f7):严格隔离 URL 查询参数、HTTP 请求头、服务端内部派生键以及 s3:ExistingObjectTag/<key> 与 s3:RequestObjectTag/<key>,防止不可信查询串或请求头伪造内部条件键。
  3. SN-2026-005(2b087a1 / eee05a17c):新增 ParseConfigStrict,在策略写入路径上拒绝退化的裸 ARN 前缀("arn:aws:s3:::" 与 "*arn:aws:s3:::")。

结论先行

  1. 无条件写入空字符串切片导致 Null 判空反转:在 silo-pkg 中,nullFunc.evaluate 通过检查键不存在或 len(rvalues) == 0 来判定条件键为空(Null)。由于 getConditionValues 无条件执行 args.ConditionValues["versionid"] = []string{vid}(当 vid == "" 时生成长度为 1 的 []string{""}),len(rvalues) 永远为 1——导致 Null: {"s3:versionid": "true"} 永远为 false,而 Null: {"s3:versionid": "false"} 永远为 true。
  2. 仅修改 getConditionValues 会在 DeleteObjects 上打开绕过口子:在批量删除 DeleteObjects(POST /?delete)中,待删除的 <VersionId> 位于 XML 请求体的每个 <Object> 条目内,而非 URL 查询参数中。如果仅仅在 URL 没有 versionId 时省略 "versionid",那么在 DeleteObjects 逐条鉴权时,用于禁止删除指定版本的策略(如 Condition: {"Null": {"s3:versionid": "false"}})将因看不到 XML 中的 <VersionId> 而彻底失效!因此必须在 authorizeRequestWithTags 中根据 ReqInfo.VersionID 动态重绑定 ConditionValues["versionid"]。
  3. 空白字符归一化消除 Deny 绕过:业务 Handler 在读取 versionId 时调用了 strings.TrimSpace(r.URL.Query().Get("versionId")),而 getConditionValues 却未做 TrimSpace。攻击者只需传入 ?versionId=V%20(带尾随空格),即可在策略求值阶段绕过 Deny StringEquals {"s3:versionid": "V"},随后在 Handler 阶段被去除空格并成功操作受保护的版本 V。

为什么 s3:versionid 上的 Null 条件完全反了

在 silo-pkg/bucket/policy/condition/nullfunc.go 中,IAM Null 条件算子按如下逻辑检查请求上下文中是否缺失某个条件键:

func (f nullFunc) evaluate(values map[string][]string) bool {
    rvalues, ok := values[http.CanonicalHeaderKey(f.k.Name())]
    if !ok {
        return f.v
    }
    return f.v && len(rvalues) == 0
}

然而在 cmd/signature-v4-utils.go 中,getConditionValues 在函数末尾未判断 vid 是否为空便直接写入了 "versionid":

var vid string
if v := r.Form.Get("versionId"); v != "" {
    vid = v
} else if u, err := url.Parse(r.Header.Get(xhttp.AmzCopySource)); err == nil {
    vid = u.Query().Get("versionId")
}
args["versionid"] = []string{vid}

当调用方发起不带 versionId 的普通请求(如 DELETE /bucket/key)时,vid 为 "",args["versionid"] 被赋值为 []string{""}(切片长度为 1)。结果:

  • 通过 "Null": {"s3:versionid": "true"} 仅允许非版本化请求的策略,因 len(rvalues) == 0 为 false 而错误拒绝了所有正常请求;
  • 通过 "Null": {"s3:versionid": "false"} 限制版本级操作的策略,因 len(rvalues) > 0 恒成立而错误命中了不带版本号的请求。

隐藏陷阱:为什么只省略空 vid 会捅出 DeleteObjects 绕过漏洞

乍看之下,修复似乎只需一行代码——仅当 vid != "" 时才写入 args["versionid"] = []string{vid}。但在审查调用链时,我们发现如果在 getConditionValues 里只做这一处改动,就会在 DeleteMultipleObjectsHandler(POST /{bucket}?delete)中打开一个致命的鉴权绕过口子。

与单对象删除 DELETE /{bucket}/{object}?versionId=V 不同,批量删除 DeleteObjects 请求的 URL 查询串里根本没有 versionId;真正的目标版本号写在 XML 请求体的每个 <Object><Key>...</Key><VersionId>V</VersionId></Object> 元素里。在 DeleteMultipleObjectsHandler 内部,服务端会遍历每条 XML 对象,将 obj.VersionID 写入请求上下文(ReqInfo.VersionID = obj.VersionID),然后针对 DeleteObjectAction 调用 authorizeRequestWithTags。

试想管理员配置了如下策略,允许普通删除(创建删除标记),但通过 Null: false 条件严禁永久删除任何历史版本:

{
  "Effect": "Deny",
  "Action": ["s3:DeleteObject", "s3:DeleteObjectVersion"],
  "Resource": ["arn:aws:s3:::mybucket/*"],
  "Condition": {
    "Null": {"s3:versionid": "false"}
  }
}

由于 authorizeRequestWithTags 内部调用的 getConditionValues(r, ...) 只检查 HTTP 请求 URL 上的 r.Form.Get("versionId"),根本不看 ReqInfo.VersionID——如果只改 getConditionValues 使其在 URL 无 versionId 时省略 "versionid",那么在 DeleteObjects 逐条鉴权时,args["versionid"] 将永远缺失,上述 Deny 策略将完全失效!更进一步,攻击者甚至可以在批量删除 URL 上故意挂一个诱饵参数(POST /mybucket?delete&versionId=decoy),同时在 XML 请求体里填入受保护的 <VersionId>target-v1</VersionId> 来欺骗基于具体版本号匹配的策略!


完整的双边修复(80c2c9d03)

提交 80c2c9d03 将两端协同闭环:

  1. 在 getConditionValues 中仅当 vid != "" 时写入 "versionid":
    if vid != "" {
        args["versionid"] = []string{vid}
    }
  2. 在 authorizeRequestWithTags(cmd/auth-handler.go)中针对 DeleteObjectAction 从 ReqInfo.VersionID 重绑定 "versionid":
    if action == iampolicy.DeleteObjectAction {
        reqInfo := logger.GetReqInfo(ctx)
        if vid := strings.TrimSpace(reqInfo.VersionID); vid != "" {
            conditionValues["versionid"] = []string{vid}
        } else {
            delete(conditionValues, "versionid")
        }
    }

为什么只在 action == iampolicy.DeleteObjectAction 时用 ReqInfo.VersionID 覆盖 conditionValues["versionid"],而不是对所有动作一刀切覆盖?因为在 CopyObjectHandler 中,ReqInfo.VersionID 存放的是目标对象的查询参数,而针对复制源执行的 GetObjectAction 鉴权必须校验来自请求头 x-amz-copy-source 的源版本号(?versionId=...)。将 ReqInfo.VersionID 重绑定限定在 DeleteObjectAction,既保证了单对象 DeleteObjectHandler 与批量 DeleteMultipleObjectsHandler 始终校验当前真正要删除的版本号,又在 reqInfo.VersionID 为空时显式执行 delete(conditionValues, "versionid"),彻底废掉了在批量删除 URL 上挂载 ?versionId=query-level-decoy 诱饵参数的混淆手段。


消除空白字符归一化差异

在排查所有读取 versionId 的入口时,我们注意到各对象 Handler(GetObjectHandler、HeadObjectHandler、DeleteObjectHandler、PutObjectRetentionHandler 等)均通过以下方式提取版本号:

versionID := strings.TrimSpace(r.URL.Query().Get(xhttp.VersionID))

然而 getConditionValues 在读取 r.Form.Get("versionId") 及 x-amz-copy-source 中的 u.Query().Get("versionId") 时却没有调用 strings.TrimSpace。

这意味着,如果策略通过 "StringEquals": {"s3:versionid": "v1"} 拒绝访问特定版本 v1,调用方只需请求 ?versionId=v1%20(末尾带一个空格),策略求值时看到的条件值就是 "v1 "——从而绕过 StringEquals 拒绝规则——随后业务 Handler 将 "v1 " 去除空格还原为 "v1" 并成功读取或删除该受保护版本。在 getConditionValues 中对查询串 versionId 与 x-amz-copy-source 的 versionId 统一调用 strings.TrimSpace 彻底消除了这一解析偏差。


调用链影响与请求条件来源隔离(2f55347f7)

操作类型 s3:versionid 来源 20260804 之前行为 Server 20260804 行为
GetObject / DeleteObject(未传 versionId) 无 ["versionid"] = [""](Null:true 失败) 省略该键(Null:true 正常命中)
GetObject / DeleteObject(?versionId=v1%20) URL 查询参数 versionId 策略看到 "v1 ",Handler 实际操作 "v1" 策略与 Handler 均归一化为 "v1"
CopyObject(x-amz-copy-source: /b/k?versionId=v1) x-amz-copy-source 查询参数 未去除首尾空白 统一去除首尾空白
DeleteObjects(POST /?delete 含 <VersionId>) 逐条 ReqInfo.VersionID 忽略 XML <VersionId>,误读 URL 查询串 按 XML 条目逐条重绑定,忽略 URL 诱饵参数

在配套提交 2f55347f7(pgsty/silo#53)中,我们还对 getConditionValues 中的其余请求条件键实施了来源隔离加固:

  • 保护内部保留键:客户端传入的 URL 查询参数与 HTTP 请求头不再被允许覆盖服务端内部派生的条件键(username、userid、principaltype、authtype、signatureversion、tlsVersion、cipherSuite)。
  • s3:signatureAge:直接基于已验签的 SigV4 时间戳计算,不再信任客户端传入的同名参数。
  • 对象标签条件隔离:s3:ExistingObjectTag/<key> 严格只从已存储的对象元数据标签填充,而 s3:RequestObjectTag/<key> 严格只从当前请求的 x-amz-tagging 请求头填充。

SN-2026-005:拒绝退化的裸 ARN 前缀

在同一轮 IAM 策略审计中,我们还排查了 SN-2026-005(silo-pkg v3.12.0 提交 2b087a1,并在 Server 20260804 提交 eee05a17c 中接入):当策略中的资源字符串写成了既无桶名也无对象后缀的退化前缀(如 "arn:aws:s3:::" 或 "*arn:aws:s3:::")时会发生什么?

由于旧解析器在剥离 arn:aws:s3::: 前缀后直接接受了空资源模式 "",该模式无法匹配任何实际的桶或对象。在普通 Allow 语句中它只是一条无效死规则;但在使用 Resource 的 Deny 语句或使用 NotResource 的 Allow 语句中,退化的 arn:aws:s3::: 会导致拒绝规则或排除集合完全轮空(Fail-Open)!

为了防止管理员误写入会导致策略静默失效的裸前缀,同时绝不让存量集群因历史数据在升级启动时崩溃:

  • silo-pkg v3.12.0 新增了 policy.ParseConfigStrict,明确拒绝退化的裸 ARN 前缀(arn:aws:s3::: 与 *arn:aws:s3:::)。
  • Server 20260804(eee05a17c)在所有策略写入路径(桶策略、具名 IAM 策略、服务账号及 STS 内联策略)上调用 ParseConfigStrict;而持久化策略的加载路径继续使用宽容模式的 ParseConfig,确保存量集群平滑启动。

为什么 SN-2026-003 作为安全公告而非 CVE 跟踪

触发 s3:versionid 条件求值缺陷的前提是调用方已通过认证,且目标策略显式依赖了 s3:versionid 条件算子(Null、StringEquals 或 StringNotEquals)。不带该条件的标准 s3:DeleteObjectVersion 动作权限检查不受影响。我们将条件求值与 DeleteObjects 重绑定修复归入 SN-2026-003,将裸 ARN 前缀写入拦截归入 SN-2026-005。


边界说明与关联修复

  1. DeleteObjects 的 Object Lock 治理模式绕过检查(75a6734e4 / #104):在审查 DeleteMultipleObjectsHandler 的过程中,我们同步梳理了批量删除中请求头与逐条保留锁的交互;随后在 Server 20260903(75a6734e4,SN-2026-008)中收紧了 x-amz-bypass-governance-retention 的逐条鉴权边界。
  2. Snowball PutObjectExtract 的 minio.versionId PAX 扩展头:在本次 versionId 审计中记录归档,并已在 Server 20261018(SN-2026-016,14042b5aa)中彻底封堵。
  3. 其余身份条件键(username、userid、signatureversion、authType):80c2c9d03 有意将空切片省略限定在 s3:versionid,而不改变已认证请求上恒有定义的身份类条件键语义。

回归验证

cmd/signature-v4-utils_test.go、cmd/auth-handler_test.go 与 cmd/bucket-policy_test.go 中的回归测试覆盖并锁定了以下场景:

  • 在带版本号与不带版本号的 GetObject、DeleteObject、CopyObject 及 DeleteObjects 请求下,验证 Null: {"s3:versionid": "true"} 与 Null: {"s3:versionid": "false"} 的求值结果;
  • 验证 ?versionId=v1%20 尾随空格在 Deny StringEquals {"s3:versionid": "v1"} 下被正确拦截;
  • 验证携带 POST /bucket?delete&versionId=query-level-decoy 诱饵参数时,XML 请求体内的版本化与非版本化 <Object> 条目均严格按条目自身重绑定;
  • 验证 ParseConfigStrict 对 arn:aws:s3::: 与 *arn:aws:s3::: 的拦截。

源码参考

  • 80c2c9d03 — s3:versionid 空值省略、空白字符归一化与 DeleteObjects 逐条重绑定(SN-2026-003)
  • 2f55347f7 — 请求条件键来源隔离(#53)
  • eee05a17c / silo-pkg@2b087a1 — ParseConfigStrict 拒绝退化裸 ARN 前缀(SN-2026-005)

总结

随 Server 20260804 发布的 80c2c9d03 与 eee05a17c(配合 silo-pkg v3.12.0)修复了单对象与批量 DeleteObjects 路径上的 s3:versionid 条件求值语义,并在策略写入入口拦截了退化的裸 S3 ARN 前缀。

17 - SN-2026-004:对象级策略授权越界命中桶级变更操作

解析当 ObjectName 为空时 arn:aws:s3:::bucket/* 为何会命中桶级变更操作,以及 SN-2026-004 如何阻断该提权路径。

SN-2026-004 修复了 silo-pkg v3.11.0(提交 3c24ad1、1f97549、d8b1fa7)与 Server 20260804(97b7d2804;上游参考 minio/minio#20449)中的一处 IAM 资源匹配缺陷:在评估桶级 S3 请求(args.ObjectName == "")时,Statement.IsAllowed 会固定在桶名末尾拼接 '/' 生成 "bucket/" 作为待匹配资源字符串,导致原本仅面向桶内对象的通配授权(如 Allow s3:* on arn:aws:s3:::bucket/*)能够越界放行 s3:PutBucketPolicy、s3:DeleteBucket 等高危桶级变更操作。

结论先行

  1. 根本原因:当 args.ObjectName 为空时,Statement.IsAllowed 无条件向桶名追加了 '/'(得到 "bucket/"),从而被 "arn:aws:s3:::bucket/*" 的末尾通配符 * 匹配命中。
  2. 多租户提权风险:在多租户场景下,被授予 Allow s3:* on arn:aws:s3:::tenant/*(常见的“允许操作该桶内所有对象”简写写法)的租户凭据,可以调用 PutBucketPolicy 为该桶写入 Principal: "*" 公开策略,或调用 DeleteBucket、PutReplicationConfiguration、PutBucketLifecycle、PutBucketVersioning 与 PutBucketObjectLockConfiguration。
  3. 为何不能一刀切去掉末尾斜杠:直接对所有空对象名请求去掉 '/' 会立刻破坏 11 个现有的 STS ListBucket 兼容性测试,并且会导致 Deny s3:* on arn:aws:s3:::bucket/* 拒绝策略失效。
  4. 精准收敛边界:针对 Allow 语句上的 12 项高危桶级变更操作(!legacyBucketResourceMatch && statement.Effect == Allow && isSensitiveBucketMutation(args.Action)),资源模式必须同时匹配 "bucket" 与 "bucket/"。若存量环境短期内依赖旧行为,可通过 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 临时恢复 20260804 之前的匹配逻辑。

为什么 arn:aws:s3:::bucket/* 会命中桶级请求

在 silo-pkg/bucket/policy/statement.go(以及 iam/policy/statement.go)中,Statement.IsAllowed(args Args) 原本按如下逻辑构造待与 Statement.Resources 比对的资源路径:

resource := args.BucketName
if args.ObjectName != "" {
    if !strings.HasPrefix(args.ObjectName, "/") {
        resource += "/"
    }
    resource += args.ObjectName
} else {
    resource += "/"
}

由于 else 分支无条件追加了 "/",每一个不带对象名的桶级请求(args.ObjectName == "")都会被构造为 resource = "mybucket/"。当它与资源模式 "arn:aws:s3:::mybucket/*" 进行通配比对时,* 会匹配 "/" 之后的空后缀并返回 true。


多租户环境下的越权影响

在 AWS IAM 与 S3 桶策略语义中,arn:aws:s3:::mybucket 代表桶本身,而 arn:aws:s3:::mybucket/* 代表桶内的对象。管理员在为租户或业务团队分配“桶内对象全权读写”权限时,经常将动作简写为 s3:* 并将资源限定在对象 ARN 上:

{
  "Effect": "Allow",
  "Action": ["s3:*"],
  "Resource": ["arn:aws:s3:::tenant-a/*"]
}

由于 s3:* 同时涵盖了对象级动作与桶级动作,而 "arn:aws:s3:::tenant-a/*" 又命中了 resource = "tenant-a/",持有该对象级策略的调用方便可在 tenant-a 桶上执行高危桶控制面变更:

  • s3:PutBucketPolicy / s3:DeleteBucketPolicy:将桶策略篡改为 "Principal": "*" 以公开暴露整个桶,或直接为自己写入显式的桶级权限;
  • s3:DeleteBucket / s3:ForceDeleteBucket:直接删除整个桶;
  • s3:PutReplicationConfiguration:将桶复制目标指向攻击者控制的外部端点以窃取数据;
  • s3:PutBucketLifecycle:配置生命周期规则使桶内所有对象与历史版本自动过期销毁;
  • s3:PutBucketVersioning / s3:PutBucketObjectLockConfiguration:挂起版本控制或篡改默认合规保留配置。

为什么不直接全局移除末尾斜杠

我们的第一版原型曾尝试直接删掉 else { resource += "/" } 分支,让所有 args.ObjectName == "" 的请求一律拿裸桶名 "mybucket" 去匹配。但运行测试集与代码审查后,我们发现这种一刀切改法会引发两个严重回归:

  1. 破坏存量 ListBucket 兼容性:cmd/sts-handlers_test.go 中有 11 个继承自上游的 STS 与 IAM 测试用例专门在 arn:aws:s3:::mybucket/* 上授予 s3:ListBucket(或 s3:*),而并未同时列出裸桶 ARN arn:aws:s3:::mybucket。如果对读取/列举操作也去掉 "/",许多仅配置了 arn:aws:s3:::mybucket/* 的存量业务将无法列举桶。
  2. 削弱 Deny 拒绝策略:如果管理员编写了 Deny s3:* on arn:aws:s3:::mybucket/* 期望封禁该桶上的所有操作,把 args.ObjectName == "" 的匹配目标从 "mybucket/" 改为 "mybucket" 会导致该 Deny 语句不再命中桶级调用——原本用于收紧安全的补丁反而会变成 Deny 绕过漏洞!

修复方案:按动作收敛的双重资源匹配

为了在不破坏 ListBucket 兼容性且不削弱 Deny 语句的前提下阻断提权路径,Statement.IsAllowed 将严格检查精准限定在命中高危桶级变更动作的 Allow 语句上(silo-pkg v3.11.0,并在服务端提交 97b7d2804 中接入):

resource := args.BucketName
if args.ObjectName != "" {
    if !strings.HasPrefix(args.ObjectName, "/") {
        resource += "/"
    }
    resource += args.ObjectName
} else {
    resource += "/"
}

if args.ObjectName == "" && !legacyBucketResourceMatch &&
    statement.Effect == Allow && isSensitiveBucketMutation(args.Action) {
    return statement.Resources.Match(args.BucketName, args.ConditionValues) &&
        statement.Resources.Match(resource, args.ConditionValues)
}
return statement.Resources.Match(resource, args.ConditionValues)

对于 Allow 语句上的这些高危桶级变更动作,资源集合必须同时匹配裸桶名 "mybucket"(从而拒绝仅包含 arn:aws:s3:::mybucket/* 的策略,同时放行包含 arn:aws:s3:::mybucket 或 arn:aws:s3:::* 的策略)以及 "mybucket/"(原因见下文为什么单调性要求同时匹配两者)。


第二轮动作审计:哪些桶级写操作被纳入保护

初版草稿仅拦截了 PutBucketPolicy 与 DeleteBucketPolicy,遗漏了 DeleteBucket 以及生命周期、复制等变更操作。在第二轮审查中,我们逐一盘点了所有桶级写动作(args.ObjectName == ""),并将它们明确划分为两组。

纳入 isSensitiveBucketMutation 保护的 12 项高危桶级变更动作包括:

  1. s3:PutBucketPolicy
  2. s3:DeleteBucketPolicy
  3. s3:DeleteBucket
  4. s3:ForceDeleteBucket
  5. s3:PutBucketLifecycle
  6. s3:PutReplicationConfiguration
  7. s3:PutBucketVersioning
  8. s3:PutBucketObjectLockConfiguration
  9. s3:PutBucketQuotaConfig
  10. s3:PutBucketRetentionConfig
  11. s3:PutBucketCors
  12. s3:DeleteBucketCors

(注:Server 20260804 发布时桶级 CORS 接口尚未启用;当 Server 20260903 在 #71 / #80 中正式实现桶级 CORS 时,s3:PutBucketCors 与 s3:DeleteBucketCors 已提前在 isSensitiveBucketMutation 中受控。)

另有 4 类低风险或自动化建桶常用的桶级操作被有意保留为历史匹配行为,以免破坏将自动化凭据限定在 arn:aws:s3:::app-bucket/* 的存量部署流水线:

  • s3:CreateBucket(使用前缀受限策略自动创建桶本身);
  • s3:PutBucketTagging / s3:DeleteBucketTagging;
  • s3:PutBucketEncryption;
  • s3:PutBucketNotification。

为什么单调性要求同时匹配 "bucket" 与 "bucket/"

我们在设计修复时要求满足严格的安全单调性(Monotonicity)不变量:当 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=off(默认值)时,任何在旧逻辑下被拒绝的请求,绝不能在新逻辑下反而被 Allow 语句放行。

在代码审查中,这一单调性证明经历了两次迭代才最终严密:

  1. 第一次尝试(在 NotResource 上翻车):最初我们只检查了 statement.Resources.Match(args.BucketName, ...) 而未同时检查 "mybucket/"。此时如果策略使用了反向排除 NotResource: ["arn:aws:s3:::mybucket/*"],在旧逻辑下 "mybucket/" 命中排除集因此被拒绝;而只检查 "mybucket" 时它不再命中排除集,结果反而从拒绝变成了放行!
  2. 第二次尝试(在单字符通配符 ? 上翻车):即便不使用 NotResource,仅检查 args.BucketName 也会在定长通配符 ? 上破坏单调性!考虑策略 Allow s3:PutBucketPolicy on Resource: ["arn:aws:s3:::mybucke?"]:在旧逻辑下,待匹配串 "mybucket/" 长度为 9 个字符,因此 8 个字符的 mybucke? 不匹配,请求被拒绝;如果新逻辑只拿 8 个字符的 "mybucket" 去匹配,mybucke? 就会突然匹配成功,从而放行了一个旧模式下原本拒绝的请求(1f97549)。

因此,要求 statement.Resources.Match(args.BucketName, ...) 与 statement.Resources.Match(resource, ...)(即同时匹配 "mybucket" 和 "mybucket/")从数学上保证了新规则下通过的 Allow 集合必然是旧规则的真子集(NewAllow = LegacyAllow ∧ MatchesBareBucket,提交 d8b1fa7),彻底消除了在任何 Resource 或 NotResource 组合下出现反向放行的可能。这也意味着像 arn:aws:s3:::mybucke? 这样只匹配 8 字符 "mybucket" 却不匹配 9 字符 "mybucket/" 的人工边界模式将无法授权高危桶变更;管理员应规范编写 arn:aws:s3:::mybucket 或 arn:aws:s3:::mybucket*。


运维影响与兼容逃生开关

  • 规范编写的策略无需任何改动:凡是在 arn:aws:s3:::mybucket、arn:aws:s3:::mybucket* 或 arn:aws:s3:::* 上授予桶管理权限的策略,升级后行为完全不变。
  • 仅含 arn:aws:s3:::mybucket/* 的策略:依然可以正常读写对象并调用 ListBucket、CreateBucket、PutBucketEncryption、PutBucketTagging 和 PutBucketNotification,但在调用上述 12 项高危桶级变更动作时会收到 AccessDenied,除非在 Resource 中显式补上 arn:aws:s3:::mybucket。
  • 兼容逃生开关:如果存量自动化流程在迁移过渡期内确实依赖 arn:aws:s3:::mybucket/* 执行上述 12 项高危桶变更,可临时设置环境变量 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on,待策略修正后再关闭。

留白与后续演进

  • 不做启动期存量策略自动改写:SILO 在升级时不会自动修改已存储的 IAM 策略或桶策略;运维人员可通过 mcli admin policy info 检查是否有管理角色依赖 arn:aws:s3:::bucket/* 执行桶级变更。
  • 保持标准 AccessDenied 响应:遵循 S3 安全惯例,鉴权拒绝响应不会向调用方披露具体是哪条语句或哪个 ARN 模式未命中。

总结

随 Server 20260804(5ff00073a 及 silo-pkg d8b1fa7 / v3.12.0)发布后,12 项高危桶级变更操作默认要求策略 Resource 同时匹配桶级 ARN(除非显式开启 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on)。

18 - SN-2026-018:客户端源地址信任边界、转发头欺骗与 aws:SourceIp 加固

解析为何设置 _MINIO_API_XFF_HEADER=off 后仍会无条件信任 X-Real-IP 与 Forwarded、为何单纯部署反向代理不足以防御伪造,以及 MINIO_API_TRUSTED_PROXIES 如何闭环源 IP 归属(SN-2026-018)。

SN-2026-018 修复了 silo-pkg v3.12.0(提交 62f326d)与 Server 20260804(00a8171cb,pgsty/silo#41)中影响 aws:SourceIp IAM 条件求值、审计日志、桶事件通知及管理端 HTTP 追踪的客户端源 IP 欺骗问题:handlers.GetSourceIP 此前默认无条件信任任意 TCP 对端发来的 X-Forwarded-For、X-Real-IP 与 RFC 7239 Forwarded 请求头;即便管理员显式设置了 _MINIO_API_XFF_HEADER=off,该函数也只是跳过了 X-Forwarded-For,随后立刻落入并无条件采信客户端伪造的 X-Real-IP 与 Forwarded。

结论先行

  1. _MINIO_API_XFF_HEADER=off 漏掉了另外两个转发头:设置 _MINIO_API_XFF_HEADER=off 仅跳过了 X-Forwarded-For,但 handlers.GetSourceIP 紧接着就会读取 X-Real-IP 和 Forwarded。任何直连客户端只需伪造 X-Real-IP: 10.0.0.1 即可绕过 aws:SourceIp 策略限制。
  2. “在前面挡一层反向代理”往往并不够:即便部署了反向代理,如果代理采用追加模式(如 nginx 默认的 proxy_add_x_forwarded_for)而服务端固定取 X-Forwarded-For 最左侧的第一个 IP,或者集群内 Pod 可以绕过 Ingress 直连 ClusterIP,攻击者依然可以伪造 aws:SourceIp。
  3. MINIO_API_TRUSTED_PROXIES 提供显式的从右向左信任链校验:Server 20260804 引入了 MINIO_API_TRUSTED_PROXIES(支持 off / none、授信代理 IP/CIDR 列表、以及兼容旧行为的 all / *),并从右向左逆序回溯 X-Forwarded-For / Forwarded,在遇到第一个不属于授信代理集合的跳点时立即停下并将其作为真实客户端地址。

handlers.GetSourceIP 的四个核心消费方

在 SILO 中,handlers.GetSourceIP(r)(位于 silo-pkg/handlers/proxy.go)是从入站 *http.Request 提取客户端 IP 地址的唯一入口。四个与安全及可观测性密切相关的子系统直接依赖其返回值:

消费方 代码位置 安全与运维职责
aws:SourceIp IAM 条件键 cmd/signature-v4-utils.go (getConditionValues) 评估桶策略与 IAM 策略中的 IpAddress 与 NotIpAddress 网段条件。
审计日志 (remotehost) cmd/logger/audit.go 在合规审计日志中记录发起请求的客户端源 IP。
桶事件通知 (Host) cmd/notification.go 填充 S3 事件通知载荷中的 requestParameters.sourceIPAddress。
管理端 HTTP 追踪 (mc admin trace) cmd/http-tracer.go 在实时诊断追踪输出中展示客户端源 IP。

值得一提的是,旧版 handlers.GetSourceIPRaw 在提取出第一个匹配的请求头字段后,甚至不会校验它是不是一个语法合法的 IP 地址(上游 silo-pkg 的单元测试里 literally 有一条断言验证传入 X-Forwarded-For: _gazonk 会原样返回 "_gazonk"!)。虽然 IpAddress 条件算子内部会用 net.ParseIP 解析,但审计日志和事件通知都会被直接注入未校验的原始字符串。


为什么 _MINIO_API_XFF_HEADER=off 防不住源 IP 伪造

在 silo-pkg/handlers/proxy.go 中,GetSourceIPRaw 原本按顺序依次检查三组请求头:

var xffHeaders = []string{"X-Forwarded-For", "x-forwarded-for"}
var realIPHeaders = []string{"X-Real-IP", "x-real-ip"}
var forwardedHeaders = []string{"Forwarded", "forwarded"}

2025 年 2 月上游 PR minio/minio#20977(及 minio/pkg#306)引入 _MINIO_API_XFF_HEADER=off 开关时,仅仅用它包住了第一组 xffHeaders 循环:

  • 当 _MINIO_API_XFF_HEADER=off 时,GetSourceIPRaw 跳过 X-Forwarded-For;
  • 紧接着下一行,它便毫无条件地继续读取 X-Real-IP(realIPHeaders)和 Forwarded(forwardedHeaders)!

结果,为了防止直连端点上 aws:SourceIp 被伪造而特意配置了 _MINIO_API_XFF_HEADER=off 的管理员,面对一条简单的 curl -H "X-Real-IP: 192.0.2.10" 或 curl -H "Forwarded: for=192.0.2.10" 依然毫无防备。

自 2017 年以来的上游演进历史

回顾 Git 历史可以看到,这一位置在过去八年里经历过多次反复:

  • 2017-07(minio/minio#4736):上游曾意识到客户端可伪造 X-Forwarded-For 绕过 aws:SourceIp,一度从 GetSourceIP 中移除了转发头。
  • 随后的重构:由于反向代理后面的部署抱怨 aws:SourceIp 和审计日志只能看到负载均衡器 IP,后续重构又把 X-Forwarded-For、X-Real-IP 与 Forwarded 加了回来,并且直接取 X-Forwarded-For 逗号分隔列表中的第一个(最左侧)地址。
  • 2023-08(minio/minio#17878)与 2025-02(minio/minio#20977):社区多次反馈直连场景下的伪造风险后,上游加入了 _MINIO_API_XFF_HEADER,却遗漏了同函数的 X-Real-IP 与 Forwarded。

为什么“前面挡一层反向代理”依然不够安全

一种常见的误解是:“只要我的对象存储前面挡了 nginx 或 Ingress 反向代理,客户端就没法伪造源 IP 了。”事实上,在以下两种极其普遍的生产拓扑中,即便有反向代理,旧逻辑依然会被轻易击穿:

  1. 取最左侧 X-Forwarded-For 与代理追加模式(proxy_add_x_forwarded_for)的冲突:nginx 等反向代理的标准配置通常是 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;,它的语义是在客户端发来的 X-Forwarded-For 末尾追加真实客户端 IP。如果攻击者发送 X-Forwarded-For: 10.0.0.5,nginx 转发给后端的请求头就是 X-Forwarded-For: 10.0.0.5, 203.0.113.99。由于旧版 GetSourceIP 无条件取最左侧的第一个地址(10.0.0.5),攻击者伪造的内网 IP 依然直接胜出!
  2. Ingress 入口与集群内直连并存(Kubernetes ClusterIP):在 Kubernetes 环境中,外部流量经过 Ingress 控制器进入,但集群内的业务 Pod 往往直接访问 silo 的 ClusterIP Service。如果服务端无条件信任任何对端发来的转发头,集群内任意一个普通 Pod 只要直连 ClusterIP 并附上 X-Forwarded-For 或 X-Real-IP,就能冒充受信任子网调用管理或受限接口。

MINIO_API_TRUSTED_PROXIES 的设计方案

为了同时解决上述两类问题,silo-pkg v3.12.0(62f326d)与 Server 20260804(00a8171cb)引入了 MINIO_API_TRUSTED_PROXIES 环境变量:

配置值 对端信任策略 转发头解析行为
MINIO_API_TRUSTED_PROXIES=off(或 none) 不信任任何外部代理跳点 对所有外部连接一律采用 TCP 对端地址(r.RemoteAddr),完全忽略 X-Forwarded-For、X-Real-IP 与 Forwarded。
MINIO_API_TRUSTED_PROXIES=10.0.1.0/24,192.168.1.10 仅信任显式列出的 IP/CIDR 若 r.RemoteAddr 不在授信集合内,直接返回 r.RemoteAddr。若 r.RemoteAddr 受信任,则从右向左逆序扫描 X-Forwarded-For(或 Forwarded),返回第一个不在授信集合内的合法 IP。
未设置、all 或 * 历史兼容模式 保持 20260804 之前的行为(除非额外设置了 _MINIO_API_XFF_HEADER=off)。

注意:handlers.GetSourceScheme(读取 X-Forwarded-Proto)被有意保持不变,以确保在反向代理卸载 TLS 的场景下,HTTPS 重定向与预签名 URL 协议检测不受 MINIO_API_TRUSTED_PROXIES 影响。


七项关键工程权衡

1. 默认向后兼容 vs. 显式安全加固

如果在小版本更新中直接将未设置时的默认值改为 off,所有部署在反向代理之后、正在使用 aws:SourceIp 策略但尚未配置 MINIO_API_TRUSTED_PROXIES 的存量集群都会在升级瞬间因看到负载均衡器 IP 而全量拒绝合法业务请求。因此我们保持未设置时的默认兼容性,同时提供 MINIO_API_TRUSTED_PROXIES=off 与 CIDR 列表作为明确的一等安全控制项。

2. 为什么我们撤回了“直接扩大 _MINIO_API_XFF_HEADER=off 拦截范围”的初版改法

在第一版草稿中,我们曾直接让 _MINIO_API_XFF_HEADER=off 同时禁用 X-Real-IP 和 Forwarded。然而运行服务端测试集时,cmd/generic-handlers_test.go 中的 TestXFFDisabled 立刻报错失败!

仔细检查 TestXFFDisabled 揭示了原因:该测试专门模拟了这样一种 nginx 部署——nginx 用 X-Real-IP: $remote_addr 覆盖写入真实客户端 IP(单跳可信),同时用 X-Forwarded-For: $proxy_add_x_forwarded_for 追加列表(最左侧不可信)。在这种拓扑下设置 _MINIO_API_XFF_HEADER=off 的用户,恰恰指望服务端忽略 X-Forwarded-For 以便落入被 nginx 覆盖写死的 X-Real-IP!因此,当 MINIO_API_TRUSTED_PROXIES 未设置时,我们完整保留了 _MINIO_API_XFF_HEADER=off 的既有行为以兼容 TestXFFDisabled,而由 MINIO_API_TRUSTED_PROXIES=off 负责彻底关闭全部三类转发头(X-Forwarded-For、X-Real-IP 与 Forwarded)。

3. 为什么只支持环境变量而不提供 mc admin config 动态配置键

在 SILO 的启动时序中,HTTP 监听器与初始路由在分布式纠删码存储加载 api 子系统配置之前就已经就绪。如果将授信代理做成 mc admin config set api 配置项,节点启动早期在加载完持久化配置之前就会出现一段信任策略未定义的空窗期(要么短暂 Fail-Open,要么短暂拒绝请求),且运行时热修改还涉及跨包并发状态同步。将其固定为进程启动环境变量,可以从根本上消除启动空窗期。

4. 本地环回地址(Loopback)豁免:区分 TCP 对端与转发链中间跳点

SILO 内置的 FTP 与 SFTP 服务端(cmd/ftp-server.go、cmd/sftp-server.go)以及本机内部转发会通过本地环回地址(127.0.0.1 / ::1)连接 S3 层,并通过 X-Forwarded-For 传递外部 FTP/SFTP 客户端的真实 IP。因此,当直接 TCP 对端(r.RemoteAddr)是本地环回地址时,SILO 会信任该本地对端跳点,以确保 FTP/SFTP 的审计日志和 aws:SourceIp 能拿到真实客户端 IP。

但至关重要的一点是:本地环回地址绝不能在 X-Forwarded-For 请求头链条内部被无条件当成授信跳点! 否则,处于一层授信反向代理之后的外部攻击者只需发送 X-Forwarded-For: 10.0.0.1, 127.0.0.1,从右向左扫描器就会先跳过反向代理(r.RemoteAddr),再跳过请求头里的 127.0.0.1,最终采信攻击者伪造的 10.0.0.1!

5. 请求头优先级:为什么 X-Forwarded-For 优先于 X-Real-IP

当 MINIO_API_TRUSTED_PROXIES 配置为 CIDR/IP 列表时,GetSourceIP 先从右向左评估 X-Forwarded-For,再检查 X-Real-IP(经 parseIP 校验),最后从右向左评估 Forwarded。这里有意与 getSTSLDAPTrustedProxySourceIP(cmd/sts-handlers.go 中专用于单跳 LDAP STS 代理、优先读 X-Real-IP 的辅助函数)保持区分:在通用多跳 S3 代理链中,只有 X-Forwarded-For 完整记录了每一跳的追加轨迹以供从右向左验链,而 X-Real-IP 可能停留在外层或内层某一跳。

6. 避免配置过宽的内网 CIDR

如果管理员将 MINIO_API_TRUSTED_PROXIES 设为过宽的网段(例如 10.0.0.0/8),而普通业务容器也运行在 10.0.0.0/8 内,那么网段内的任意业务容器都会被视为授信代理并能伪造 X-Forwarded-For。在生产环境中应仅填入专用的负载均衡器/Ingress IP 或独立子网。

7. 多节点分布式集群的对等节点配置

在多节点分布式 SILO 集群(如 silo server http://node{1..4}/data)中,负载均衡器可能将请求打到节点 1,再由节点 1 在集群内部转发给节点 2~4:

部署拓扑 MINIO_API_TRUSTED_PROXIES 应包含的地址
单节点 + 反向代理 仅反向代理的 IP/CIDR
多节点分布式集群 + 反向代理 反向代理的 IP/CIDR 以及所有 SILO 集群节点的 IP/CIDR
客户端直连多节点集群(无外部反向代理) 所有 SILO 集群节点的 IP/CIDR(若无跨节点转发需求则可设为 off)

兼容性细节

  • 启用授信代理控制后拒绝非 IP 脏字符串:只要启用了 MINIO_API_TRUSTED_PROXIES(设为 off 或 CIDR 列表),所有候选请求头字段都必须通过合法 IP 地址解析;像 _gazonk 这样的畸形字符串会被直接丢弃而不会返回给下游。
  • 清理失效的 IPv4-mapped 字符串前缀拼接逻辑:旧版 proxy.go 中有一处针对 "::ffff:" 的字符串截取判断,无法正确处理十六进制形式的 IPv4-mapped IPv6 地址;新代码统一使用标准库 netip.Addr.Unmap() 将 ::ffff:192.0.2.1 规范化为 192.0.2.1。

多轮代码审查抓出的 9 个边界缺陷

在补丁合入前,我们跨 silo-pkg 与 silo 进行了三轮代码审查,先后抓出并修复了 9 个隐蔽的边界问题:

第二轮审查发现

  1. init() 与 MINIO_CONFIG_ENV_FILE 的启动加载时序倒置:初版代码仅在包级 init() 函数中读取环境变量;如果管理员将 MINIO_API_TRUSTED_PROXIES 或 _MINIO_API_XFF_HEADER 写在由 MINIO_CONFIG_ENV_FILE 指定的配置文件中,由于该文件在 serverHandleEnvVars 阶段才被解析注入进程环境,init() 早已执行完毕!我们在 serverHandleEnvVars 中显式补齐了这两个开关的二次解析与生效。
  2. 转发链内部的 127.0.0.1 跳板欺骗(X-Forwarded-For: spoofed, 127.0.0.1):区分了“TCP 对端 r.RemoteAddr 是环回地址”与“请求头字符串里出现 127.0.0.1”(详见权衡 4)。
  3. 带作用域标识的 IPv6 地址(fe80::1%eth0):链路本地(Link-Local)IPv6 对端的 r.RemoteAddr 包含 %zone 后缀;在匹配 CIDR 前先剥离 Zone 标识再调用 netip.ParseAddr,避免了合法 IPv6 链路本地地址解析失败。
  4. 多行同名 X-Forwarded-For 请求头(r.Header.Values):HAProxy 或 Envoy 等代理在追加转发信息时,可能直接追加第二行 X-Forwarded-For 请求头,而非用逗号拼到第一行末尾。若调用 r.Header.Get("X-Forwarded-For") 只会读到第一行(恰恰是客户端伪造的那一行!)。我们改为通过 r.Header.Values("X-Forwarded-For") 从最后一行向第一行逆序遍历。
  5. 内部 forwarder.go 请求头清理:当不可信对端发来的 X-Real-IP 或 Forwarded 被拒绝后,内部节点间转发不应将其原样透传给下游受信任节点。
  6. ::ffff:0:0/96 网段前缀的 Unmap 陷阱:若对 CIDR 的网络前缀地址无条件调用 Unmap(),会把 /96 的 IPv6 映射网段变成 0.0.0.0 并错误匹配所有 IPv4 地址;我们在 CIDR 解析中专门保护了映射网段的前缀长度语义。

第三轮审查发现

  1. 空列表必须默认安全关闭(MINIO_API_TRUSTED_PROXIES=","):如果管理员误配了 MINIO_API_TRUSTED_PROXIES=","(或纯空格),解析出的 CIDR 切片为空;若将空切片当作“未设置(信任所有)”就会导致严重 Fail-Open!现在只要显式设置了该环境变量但解析不出任何有效规则(且不为 off/none/all/*),服务端启动会直接报错退出。
  2. 区分“未设置”与“设为空串”:使用 env.Get(EnvAPITrustedProxies, "") 无法区分环境变量未设置与被显式设为 MINIO_API_TRUSTED_PROXIES="";我们改为显式检查环境变量是否存在。
  3. 零分配逆序扫描与 100 跳上限:如果在每个请求上直接调用 strings.Split(header, ","),攻击者只需发送一个包含上百万个逗号的 1 MB X-Forwarded-For 请求头,单次请求就会在堆上分配约 33 MB 的字符串切片。我们将 strings.Split 替换为原地从右向左扫描器,并将最大扫描跳数限制为 100 跳。

运维配置建议

  • 客户端直连 SILO(无反向代理):强烈建议设置 MINIO_API_TRUSTED_PROXIES=off。
  • 部署在 nginx / HAProxy / Envoy / Ingress 之后:将 MINIO_API_TRUSTED_PROXIES 设置为反向代理的 IP/CIDR 列表(多节点集群需一并包含各 SILO 节点 IP/CIDR)。

明确不做项

  • TCP 层 PROXY Protocol v1/v2:监听器层面的二进制 PROXY Protocol 与 HTTP 层的 X-Forwarded-For / Forwarded 解析属于不同层级,不在本补丁范围内。
  • GetSourceScheme:保持现有 X-Forwarded-Proto 识别行为不变,避免影响反向代理卸载 TLS 后的协议判断。
  • ExistingObjectTag/* 条件来源隔离:在关联提交 2f55347f7(同样随 20260804 发布)中独立修复,确保仅从已存储的对象标签填充该条件键,绝不采信客户端请求头。

严重度定级

本问题作为 SN-2026-018 记录于 SILO 安全台账(与 CVE-2026-42600 相同,上游 minio/minio 已归档;对于依赖 aws:SourceIp 实施访问控制的环境属于中至高危)。升级至 Server 20260804 并按拓扑配置 MINIO_API_TRUSTED_PROXIES 即可在 IAM、审计日志、事件通知与管理追踪中全面闭环源地址归属。

19 - SN-2026-019:通知子系统配置键注册缺失与校验报错脱敏

解析未注册的通知配置键如何导致配置校验失败、拖垮全局通知目标并在报错信息中回显敏感凭据(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。

结论先行

  1. 四处独立维护的代码面发生漂移:在通知配置子系统中,Default*KVS(合法键集合)、Help*(帮助文档与敏感级别分类)、GetNotify*(配置解析)与 SetNotify*(旧配置迁移)长期靠手工同步,缺乏一致性校验。
  2. 单个目标报错会拖垮全局通知目标:FetchEnabledTargets 按顺序校验所有通知子系统,一旦遇到首个错误便直接返回,导致全局通知目标列表保持为 nil,使正常的通知目标一并失效。
  3. 配置校验报错回显了敏感值:当 CheckValidKeys 拒绝未知或拼写错误的键时,旧代码使用 kv.String() 格式化错误,将完整的 key=value(含密码或凭据内容)直接输出到日志和客户端终端。
  4. 通过 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):

Error: Invalid key/value pair 'nkey_seed= tls_handshake_first=off' found in subsystem 'notify_nats:ONE'

为什么修改 FITCHECK 会报出 ONE 上从未手敲过的两个键?排查发现这是两个机制叠加的结果:

  1. mc admin config set 的分词器吞字效应:internal/config/parser.go 中的 kvFields 仅根据 DefaultKVS 中已注册的键名进行分词。当用户在命令行传入未注册的键时,kvFields 不会将其识别为新键的起点,而是将 "nkey_seed=..." 静默吞入前一个合法键(如 subject)的值中。
  2. 旧配置迁移逻辑向 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 旁边立刻发现了第二个隐患:

const (
    NATSAddress = "address"
    NATSSubject = "subject"
    ...
    EnvNATSAddress         = "MINIO_NOTIFY_NATS_ADDRESS"
    EnvNATSSubject         = "MINIO_NOTIFY_NATS_SUBJECT"
    NATSUserCredentials    = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"
)

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 的键空间缺陷:

  1. 拆分配置键常量与环境变量常量:target.NATSUserCredentials 修正为 "user_credentials",同时新增 target.EnvNATSUserCredentials = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"。
  2. 在 DefaultNATSKVS 与 DefaultAMQPKVS 中补齐四个键:将 user_credentials、nkey_seed、tls_handshake_first 注册到 DefaultNATSKVS,将 immediate 注册到 DefaultAMQPKVS。
  3. 补全 HelpNATS 与 HelpAMQP:为 user_credentials、nkey_seed 和 immediate 补齐帮助项。由于 user_credentials 和 nkey_seed 接收的都是服务端本地文件路径(与 client_key 一致),我们将二者标记为 config.Sensitive 而非 config.Secret。
  4. 修复 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 时规定了确定的优先级顺序:

  1. MINIO_NOTIFY_NATS_USER_CREDENTIALS 环境变量(最高优先级);
  2. user_credentials KVS 配置键;
  3. 历史遗留的 MINIO_NOTIFY_NATS_USER_CREDENTIALS KVS 配置键(仅当 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 中注册。

在对审计测试本身复核时,我们发现并堵上了初版测试的两个盲区:

  1. 反向覆盖与数量下界断言:如果只断言 parse.go ⊆ Default*KVS,万一未来语法树结构变化导致 AST 遍历器一个键都没提取出来,空集也是任何集合的子集,测试会假阳性通过!因此我们加上了反向检查,要求 Default*KVS 中注册的每一个键(除 enable、comment 等通用键外)都必须在 parse.go 中被读取,并为每个子系统设置了提取键数量下界。
  2. 无类型复合字面量(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。因此 Server 20260903 从 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 中,且非法键报错不再回显配置值。

20 - CompleteMultipartUpload 重复分片号漏洞分析与修复

解析 CompleteMultipartUpload 为何会接受升序中的重复分片号、sort.SliceIsSorted 为何漏掉 [1, 1],以及 Server 20260804 的修复方案。

在 S3 API 的 CompleteMultipartUpload 请求中,协议要求完成清单 XML 中的分片号(PartNumber)必须严格递增。直到 Server 20260725 为止,SILO 一直使用 sort.SliceIsSorted 配合严格小于号 < 来校验这一不变量——但这套写法实际上校验的是“非递减序”,会放行 [1, 1] 这类包含相邻重复分片号的列表,导致同一个底层分片在最终对象中被重复拼接。Server 20260804(pgsty/silo#49)将其替换为显式的相邻元素比较(Parts[i-1].PartNumber >= Parts[i].PartNumber),并顺带修复了突变测试中发现的 ReadParts 空切片 panic(2602177ef)。

结论先行

  1. 缺陷真实存在且边界清晰:CompleteMultipartUpload 使用 sort.SliceIsSorted 配合 < 比较器,实际验证的是非递减序(a[0] <= a[1] <= ...)而非严格递增序(a[0] < a[1] < ...)。传入 [1, 1] 这类重复分片号会返回成功,并在最终对象中将对应分片重复拼接。
  2. 属于数据完整性缺陷,而非 CVE 安全漏洞:触发该行为的前提是调用方已经拥有目标对象的 s3:PutObject 权限,并持有正在进行的分段上传的合法 uploadId。它不跨越租户、桶或凭据边界。
  3. 修复收敛在 HTTP Handler 边界:cmd/object-handlers.go 对满足 Parts[i-1].PartNumber >= Parts[i].PartNumber 的输入直接返回 InvalidPartOrder(HTTP 400)。跳号分片(如 [1, 3]、[5, 9]、[3])继续保持合法。

为什么 sort.SliceIsSorted 会放行重复分片号

在 cmd/object-handlers.go 中,CompleteMultipartUploadHandler 原本通过以下代码校验解析后的 <CompleteMultipartUpload> 分片列表:

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
}

在 Go 标准库中,sort.SliceIsSorted 会将下标 i 从 n-1 递减遍历到 1,并调用 less(i, i-1);只有当 less(i, i-1) 返回 true(即后一个元素严格小于前一个元素)时,才会判定切片无序。当两个相邻元素的分片号相等(Parts[i].PartNumber == Parts[i-1].PartNumber)时,Parts[i].PartNumber < Parts[i-1].PartNumber 的结果为 false,因此 SliceIsSorted 会认为 [1, 1] 已经有序。

将 sort.SliceIsSorted 替换为显式的相邻元素检查即可消除该盲区:

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
    }
}

两次重构为何都保留了这一缺陷

通过 git log -S 追溯该处理函数的演进历史可以看到,这一“非递减”判断在九年间的两次代码整理中被原样保留了下来:

时间 PR / 提交 变更内容 为何 [1, 1] 未被发现
2016-08 minio/minio#2490 引入 sort.IsSorted(completedParts(complMultipartUpload.Parts)),其 Less(i, j) 定义为 a[i].PartNumber < a[j].PartNumber。 sort.IsSorted 仅检查后一项是否小于前一项。
2017-11 minio/minio#5170 移除具名类型 completedParts,改用 sort.SliceIsSorted 与相同的 < 闭包。 属于比较器逻辑不变的机械重构。
2023-04 minio/minio#17066 将 ErrInvalidPartOrder 校验前移到 CompleteMultipartUploadHandler 入口(进入对象层之前)。 原样移动了校验代码块,未补充重复分片号用例。

现有的单元测试与 mint 测试在验证 ErrInvalidPartOrder 时均只构造了 [2, 1] 这类逆序输入,从未覆盖 [1, 1] 这类相邻重复输入。


实际影响与复现表现

在修复前,只需上传一个 5 MiB 的分片(partNumber=1),并在完成上传时提交两条同样引用 PartNumber=1 及其 ETag 的 <Part> 记录,即可复现如下行为:

步骤 请求 修复前结果 修复后结果
1 UploadPart(partNumber=1)(5 MiB) 200 OK,返回 ETag: "e1" 200 OK,返回 ETag: "e1"
2 使用 [(1, "e1"), (1, "e1")] 调用 CompleteMultipartUpload 200 OK,ETag: "...-2" 400 Bad Request(InvalidPartOrder)
3 HeadObject / GetObject Content-Length: 10485760(分片 1 被重复拼接两次) 对象未创建,分段上传保持打开状态
4 改用 [(1, "e1")] 重试 CompleteMultipartUpload 404 NoSuchUpload(上传状态已被消费并清理) 200 OK,Content-Length: 5242880

由于 CompleteMultipartUpload 一旦成功就会消费并清理该 uploadId 的暂存状态,如果客户端因重试逻辑缺陷(例如重试 UploadPart 后直接向内存切片 append 而未按分片号去重)在完成清单中混入了重复分片,服务端不仅会静默生成一个长度错误的损坏对象,而且客户端也无法再对该 uploadId 重新发起正确的 CompleteMultipartUpload,只能重传所有分片。


为什么不分配 CVE

我们评估了恶意调用方能否利用重复分片号制造存储空间放大攻击:

  1. 无直接物理落盘放大:在纠删码存储层,CompleteMultipartUpload 只是在 xl.meta 的分片元数据数组中记录条目并指向磁盘上已有的 part.1 数据文件,不会在完成阶段按条目复制底层数据块。虽然读取该对象会流式读取两次 part.1,且后续后台修复(healing)或复制会按逻辑大小物化数据,但拥有写权限的调用方本就可以通过正常上传两个分片或调用 UploadPartCopy 达到同样的效果。
  2. 仅限调用方自身权限范围:调用 CompleteMultipartUpload 必须具备目标对象的写权限并知晓 uploadId,未认证用户或只读凭据无法触发该路径。

因此,我们将该问题归类为数据正确性与 S3 协议合规性缺陷(#49),而非安全公告(SN-2026-*)。


Handler 层与对象层的职责边界

排查过程中,我们同步检查了 erasureObjects.CompleteMultipartUpload(cmd/erasure-multipart.go)是否包含分片顺序校验。答案是没有:对象层直接遍历 for i, part := range parts 并与该 uploadID 的分片元数据逐项比对。

我们最终选择将严格递增校验保留在 CompleteMultipartUploadHandler(cmd/object-handlers.go),而不是在每个 ObjectLayer 实现中重复植入,原因有两点:

  1. 单一不可信入口:由客户端控制的完成清单只会从 CompleteMultipartUploadHandler 进入。系统内部调用方(如 batch-replicate、restore、decommission、rebalance 等)均直接基于已持久化的 ObjectInfo.Parts 或按 1..n 顺序循环构造分片列表。
  2. 在获取命名空间锁之前快速失败:在 HTTP Handler 层直接拒绝畸形 XML,可以避免无谓地获取对象命名空间锁或读取磁盘上的上传元数据。

保持稀疏分片号的合法性

S3 规范只要求 CompleteMultipartUpload 中的分片号严格递增(1 <= PartNumber <= 10,000),并不要求分片号连续,也不要求必须从 1 开始。客户端上传分片 1、2、3 后仅用 [1, 3] 完成上传,或者直接用 [5, 9]、[3] 完成上传,均属于合法的 S3 用法。

为确保修复不会误伤合法客户端,我们在回归测试中显式固定了以下用例:

  • [1, 3](跳号间隙,合法通过);
  • [3](不从 1 开始的单分片,合法通过);
  • [1, 1] 与 [1, 2, 2, 3](相邻重复,返回 ErrInvalidPartOrder);
  • [1, 3, 2] 与 [2, 1](递减顺序,返回 ErrInvalidPartOrder);
  • [1, 2, 1](非相邻重复,返回 ErrInvalidPartOrder)。

越界且重复分片号的错误码优先级

一个值得记录的边界情形是:当输入同时违反分片号范围(PartNumber < 1)和严格递增要求时,例如 [0, 0] 或 [-1, -1],应当返回哪个错误码?

由于 CompleteMultipartUploadHandler 先在入口校验分片顺序,随后才进入对象层逐个查找分片(并在分片不存在或越界时返回 ErrInvalidPart),修复前 [0, 0] 会通过 sort.SliceIsSorted 并在对象层返回 InvalidPart(HTTP 400)。改为严格递增校验后,[0, 0] 与 [-1, -1] 会在 Handler 入口直接被拦截并返回 InvalidPartOrder(HTTP 400)。这两个错误码均对应 HTTP 400 Bad Request 且均不可重试;单个越界分片(如 [0] 或 [-1])则依然会进入分片查找流程并返回 InvalidPart。


突变测试与一条误导性注释

在验证 cmd/object-handlers-multipart_test.go 的单元测试时,我们对 Handler 做了两项手工突变实验(Mutation Testing):

  1. 植入“必须从分片 1 开始”的限制(要求 Parts[0].PartNumber == 1):立即被稀疏分片回归测试([3] 与 [5, 9])捕获。
  2. 删掉 len(complMultipartUpload.Parts) == 0 空列表守卫:起初我们在测试注释中写道“如果删掉该守卫,空分片完成请求将会成功”。然而实际运行该突变后发现,空 Parts 切片穿过 Handler 进入对象层后,会在 errgroup 协程调用的 ReadParts(cmd/storage-rest-client.go)中因为直接访问 partNumbers[0] 而触发数组越界 panic。

这次突变实验带来了两个直接收获:我们修正了测试文件中原本凭推理想象出来的误导性注释,并在提交 2602177ef(同样随 Server 20260804 发布)中加固了 ReadParts,使其在收到空 partNumbers 切片时直接安全返回,消除了该处隐患。


明确不做项与后续边界

  • 不提供存量对象自动改写工具:在 20260804 之前由重复分片清单拼接出的对象,其 xl.meta 元数据与逻辑大小自洽;盲目对存量对象做自动截断或去重,反而可能破坏业务侧有意拼接相同数据块的合法对象。
  • 不在对象层重复植入顺序校验:如职责边界所述,内部调用方均从已验证元数据构造分片列表,而突变测试暴露的 ReadParts 空切片边界已在 2602177ef 中闭环。

审查记录

三轮独立审查依次验证了 Handler 递增谓词、错误码优先级(InvalidPartOrder 与 InvalidPart)、稀疏分片号兼容性([3] 与 [5, 9])以及 ReadParts 空切片守卫(2602177ef)。

总结

随 Server 20260804(5df7b5466 与 2602177ef)发布后,CompleteMultipartUploadHandler 严格校验分片号单调递增(PartNumber[i-1] < PartNumber[i]),同时 ReadParts 对空分片切片执行安全早退保护。