这是本节的多页打印视图。 .
PGSTY SILO 博客
-
1: 文章
- 1.1: SILO 一年回顾:相比上游 MinIO,我们到底干了什么?
- 1.2: 续命 MinIO:承诺兑现
- 1.3: MinIO 已死,MinIO 复生
- 1.4: MinIO已死,谁能接盘?
- 1.5: MinIO已死
-
2: 发布注记
- 2.1: SILO 20261026:Server、mcli 与 KES 联合发布
- 2.2: SILO 20261018 发布说明
- 2.3: silo-pkg 3.14.3 发布
- 2.4: PGSTY KES 20261018 发布
- 2.5: SILO Console 2.4.2 发布
- 2.6: mcli 20261018 发布
- 2.7: silo-pkg 3.14.2 发布
- 2.8: SILO 20261015:已替代草稿记录
- 2.9: SILO Console 2.4.1 发布
- 2.10: silo-pkg 3.14.1 发布
- 2.11: SILO 20260916 发布注记
- 2.12: mcli 20260916 发布
- 2.13: mcli 20260913 发布
- 2.14: silo-pkg 3.14.0 发布
- 2.15: SILO Console 2.4.0 发布
- 2.16: Silo Console 2.2.0 发布说明
- 2.17: SILO Console 2.5:界面与管理功能更新
- 2.18: silo-pkg 3.15:Go 1.27.3 与 S3 SDK 修复
- 2.19: Silo Console 2.1.0 发布
- 2.20: Silo Console 2.0.0 发布
- 2.21: silo-pkg 3.13.0 正式发布
- 2.22: silo-pkg 3.12.0 正式发布
- 2.23: Silo Pkg 3.11.0 发布
- 2.24: Silo 20260903 正式发布
- 2.25: 显式版本删除现在要求 DeleteObjectVersion
- 2.26: mcli 20260903 正式发布
- 2.27: mcli 20260806 发布
- 2.28: Silo 20260806 发布
- 2.29: Silo 20260804 发布
- 2.30: mcli 20260804 发布
- 2.31: Silo 20260618 发布
- 2.32: Silo 20260417 发布
- 2.33: Silo 20260325 发布
- 2.34: Silo 20260321 发布
- 2.35: Silo 20260314 发布
- 2.36: Silo 20260214 发布
- 2.37: Silo 20251203 发布
-
3: SILO 安全编年史
- 3.1: 十月安全更新:Object Lock、管理凭据与 JWK 解析
- 3.2: SN-2026-015 与 SN-2026-016:S3 Object Lock 治理模式保留锁绕过与指定版本号原地覆盖漏洞分析
- 3.3: 九月安全修复:载荷完整性、IAM 撤销与 Console 分享
- 3.4: SN-2026-011:修复与发布状态
- 3.5: CVE-2025-62506:Session Policy 权限提升
- 3.6: SILO 20260903 安全说明:SN-2026-006 至 010
- 3.7: CVE-2026-32285:最终无需补丁的 jsonparser 通告
- 3.8: CVE-2026-33322:OIDC JWT 算法混淆
- 3.9: CVE-2026-33419:LDAP STS 用户枚举与限流链演进
- 3.10: CVE-2026-34204:复制元数据注入
- 3.11: CVE-2026-39414:S3 Select 超大记录与 SIMD 快路径绕过
- 3.12: CVE-2026-40344:Snowball 自动解包认证绕过
- 3.13: CVE-2026-41145:Unsigned-Trailer 查询认证绕过
- 3.14: CVE-2026-42600:ReadMultiple Storage-REST 路径穿越
- 3.15: SN-2026-002:内部节点路径包含审计——补完 CVE-2026-42600 欠下的那笔账
- 3.16: SN-2026-003:s3:versionid 条件求值缺陷与请求条件隔离加固
- 3.17: SN-2026-004:对象级策略授权越界命中桶级变更操作
- 3.18: SN-2026-018:客户端源地址信任边界、转发头欺骗与 aws:SourceIp 加固
- 3.19: SN-2026-019:通知子系统配置键注册缺失与校验报错脱敏
- 3.20: CompleteMultipartUpload 重复分片号漏洞分析与修复
-
4: 设计归档
- 4.1: SILO、KES 与 OpenBao:密钥归属和恢复
- 4.2: 写死 TLS 参数与握手兼容性
- 4.3: 桶配置复制:来源时间、删除与确定性收敛
- 4.4: SigV4 签名头覆盖边界:拒绝未签名的 x-amz-* 请求头
- 4.5: Object Lock 复制排序
- 4.6: SSE-C 副本完整性
- 4.7: 持久化 IAM 撤销
- 4.8: 多池对象一致性
- 4.9: 联邦 CopyObject:保持目标对象契约
- 4.10: 复制可靠性:删除完成、MRF 可见性与 resync 取消
- 4.11: FULL_OBJECT 校验和复制:在初始化上传时保留类型
- 4.12: IAM 策略重载:锁粒度、请求取消与超时预算隔离
- 4.13: LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界
- 4.14: 复制标签排序:修订时间戳、墓碑与复活
- 4.15: SILO Server 20260903 发布前复审
- 4.16: 多池覆盖亲和:为何最终没有采纳 #245
- 4.17: 副本元数据归一化:可信复制不得重新注入什么
- 4.18: 请求头截止时间:绝对头部上限与滚动的正文空闲超时
- 4.19: 条件 DELETE:一个条件对应一个逻辑对象
- 4.20: 只预览文本,绝不执行:SILO Console 文本预览设计
- 4.21: 数据库通知统一连接串:#53 的兼容性边界
- 4.22: 多池删除:失败时保住最新副本
- 4.23: Go 1.27 TLS 默认值与 OIDC Discovery 故障诊断
- 4.24: 鉴权前不做 I/O,Header 不授予权限
- 4.25: 一个端点,两种权限:彻底分离用户与组状态
- 4.26: IAM Admin API 授权边界修复
- 4.27: 配置环境文件不是 Shell 脚本
- 4.28: 两把 SSE-C 密钥,一份 CopyObject 响应
- 4.29: ListMultipartUploads 行为契约与设计权衡
- 4.30: 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
- 4.31: 总量未知时,进度条应该说什么
- 4.32: 列举不能丢掉仍有仲裁的 null 版本
- 4.33: ListObjects 快捷路径不能把不存在的桶伪装成空桶
- 4.34: 只读 Checksum 审计与可靠的 CLI 输出契约
- 4.35: 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
- 4.36: BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
- 4.37: 旧版元数据与扫描响应复用时的残留字段修复
- 4.38: LIST 漏项修复:仅对存疑对象补读确认并收敛锁等待开销
- 4.39: 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
1 - 文章
关于 MinIO、S3 兼容对象存储与 SILO 社区分支的文章与分析。
1.1 - SILO 一年回顾:相比上游 MinIO,我们到底干了什么?
2025 年 10 月 15 日,MinIO 发布了最后一个公开开源版本 RELEASE.2025-10-15T17-29-55Z(提交 9e49d5e7a648)。随后两个月里,上游先宣布进入“维护模式”,接着删除了 minio/console 仓库,最终在 2026 年 2 月将主仓库彻底归档,把所有后续 CVE 修复与维护留在商业版 AIStor 中。
到 2026 年 10 月 26 日,SILO 打下 RELEASE.2026-10-26T00-00-00Z 标签(提交 c3ad836aed0d)。整整一年(376 天),服务端主仓库合入了 604 次提交,连同配套的 silo-console、mcli 与 silo-pkg 一起,走完了一轮完整的工程演进。
很多用户问过同一个问题:从 MinIO 分叉出来的这一年,SILO 到底只是换了个名字打安全补丁,还是真的在修底层问题?跟上游最后那个版本相比,实质差异到底在哪?
先把核心结论摊在桌面上:SILO 没有改动 MinIO 的纠删码存储引擎、xl.meta 磁盘格式与基本读写仲裁规则;这一年的全部工程火力,都花在安全边界、S3 协议与校验和正确性、复制与生命周期收敛、失败写入与多池保护,以及恢复完整的管理控制台与客户端体验上。
| 领域 | 最重要的实质变化(2025-10-15 → 2026-10-26) |
|---|---|
| 安全与授权边界 | 修复分叉后披露的 7 个 CVE(OIDC 算法混淆、LDAP STS 枚举爆破、复制头注入、S3 Select 内存耗尽、无符号 Trailer 漏验签、Snowball 越权解压、/rmpl 路径穿越),并完成 18 项 SN-2026-* 安全边界加固(SigV4 操作头强制签名、策略条件键防伪造、桶级写权限收紧、服务账户授权上限、Object Lock 绕过校验、IAM 持久化撤销与防旧事件复活) |
| S3 协议与校验和 | 将上游返回 NotImplemented 的桶级 CORS 补齐为完整持久化与预检实现;支持条件 DELETE(If-Match)与跨池条件 PUT/CompleteMultipartUpload;补齐分段上传、CopyObject、SSE-C 与压缩对象的明文逻辑校验和及分片逻辑大小;将加密元数据篡改错误从 HTTP 206 修正为 500 |
| 复制与生命周期 | 修复分片上传复制丢失 FULL_OBJECT 校验和类型(#255);支持可信对端 SSE-C 原始密文与封套复制;为对象标签与 Object Lock 保留期引入修改时间戳以保证乱序与删除收敛;版本桶生命周期过期仅写入删除标记时保留远端分层数据(#253) |
| 存储正确性与多池 | 实现两阶段多池批量删除(#257)、迁移版本保护与盘间分歧下的 LIST 恢复(#239),转发覆盖回滚所需的存储 RPC 删除选项;保留既有超时与取消语义,迟到提交、延迟回滚和陈旧读取仍是故障边界 |
| Console 与 mcli | 恢复并重写完整管理控制台 SILO Console v2.5.1(<3 MB 零遥测前端、中英双语、游标分页、任务中心、Metrics V3、高延迟诊断流修复);发布 mcli 20261026,新增只读 checksum verify,修正 admin info --json、mirror、mv、retention、legalhold、sql 的错误退出码与重启行为 |
| 参数、运维与分发 | 让 --read-header-timeout / MINIO_READ_HEADER_TIMEOUT 真正生效(默认 30 秒绝对请求头期限);新增可信代理配置(MINIO_API_TRUSTED_PROXIES)与原生 silo healthcheck 子命令;彻底移除原地自更新与 SUBNET/call-home;构建 Go 1.27.3、UBI 9.8 / distroless 镜像、RPM/DEB/APK、Windows ZIP 归档及 GPG/SBOM/Sigstore 完整供应链 |
同时,有一说一,下面这些边界我们同样在文档与发布验收里写得明明白白:
- 没有引入分布式 fencing,也不能宣称解决了全部一致性问题。 租约过期或网络分区下的迟到写入仍可能发生;严格代际拒读、自动跨池读恢复、公开持久分段上传枚举(
#79)、访问频率池间分层与多池覆盖亲和(#245)在实测暴露容量或可用性代价后均已撤回或延期。 - 没有跑过两个固定版本的全集群对等性能基准。 我们有局部热路径的微基准与摊销开销实测,但绝不凭空编造“整体吞吐提升 X%”的数字。
- 磁盘格式兼容不等于支持任意混跑或无脑降级。 新的 IAM 持久化撤销状态(
siloParentRevocation)与复制墓碑需要参与节点协调升级。
下面按模块逐一拆解这一年里 SILO 具体改了什么、为什么这么改,以及每项改动背后的代码证据与使用边界。
1. 基线与产品栈:什么变了,什么没变
本次对比的精确基线是上游 MinIO 最后一次公开发布与 SILO 2026 年 10 月统一维护批次:
- 上游基线:
minio/minioRELEASE.2025-10-15T17-29-55Z(提交9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a,2025-10-15)。注:上游在停发 Release 到 2026-02-02 归档前还合入过 9 个未发版提交(如fa18589d1c的 POST Policy 标签约束、18f97e70b1的条件写读仲裁调整),这些提交不在2025-10-15正式版内,SILO 也通过独立路径完成了对应修复。 - SILO 目标版本:
pgsty/siloRELEASE.2026-10-26T00-00-00Z(源码c3ad836aed0d8a7d23c2e44a5eaa3009d08525e3),与 mcli / KES 20261026、Console v2.5.1、silo-pkg v3.15.2 联合发布。详见本批次发布日志与组件矩阵。
在 9e49d5e7a648..c3ad836aed0d 区间内,pgsty/silo 共有 604 个可达提交(涵盖功能与安全修复、回归测试、构建打包与文档工程)。下文按实际交付的运行时行为逐一归纳。
四组件协调维护栈
上游归档后,单靠一个服务端仓库无法交付完整的对象存储系统——因为服务端内嵌了 Console 前端与 mc 管理模块,又直接依赖共享策略与加密库。SILO 将维护范围收敛为四个协同演进的核心仓库(加可选外部密钥服务 KES):
| 组件 | 上游 2025-10-15 状态 |
SILO 20261026 状态 |
关键演进说明 |
|---|---|---|---|
Server (pgsty/silo) |
Go 1.24.0,minio 二进制 |
Go 1.27.3,silo 二进制(c3ad836aed0d) |
模块路径保留 github.com/minio/minio;修复 7 个 CVE + 18 项安全边界加固与多项存储/复制缺陷 |
Console (pgsty/silo-console) |
内嵌裁剪版 v1.7.7(仅剩对象浏览器) |
v2.5.1(fff1618d9abb) |
上游已删除 minio/console 仓库;SILO 恢复完整管理台,重构为 <3 MB 零遥测双语前端 |
Client (pgsty/mc) |
mc cf909e1063a9 |
mcli 20261026(0ceb298cdcd0) |
产物命名为 mcli(避免与 Midnight Commander mc 冲突),新增 checksum verify 并修复退出码 |
Shared Pkg (pgsty/silo-pkg) |
minio/pkg/v3 v3.1.3 |
v3.15.2(0416eb1f4505) |
独立模块路径 github.com/pgsty/silo-pkg/v3,承载 IAM 策略判定、条件键隔离、证书与 KMS 客户端 |
S3 SDK (minio/minio-go/v7) |
v7.0.91 |
v7.4.2-0.20261025165926-320af050c445 |
唯一直接沿用上游的模块,固定在经验证的提交(含分片截短流、负偏移与批量删除修复) |
KMS (pgsty/kes) |
外部可选依赖 | KES 20261026(e9b4f1acebc1) |
Go 1.27.3,验证 OpenBao KV v2 与本地 Moto Secrets Manager 协议;更新 AWS/GCP 客户端 |
刻意保持不变的底层基石
为了确保现有 MinIO 部署能够原地替换为 pgsty/silo 而不触发数据重算或客户端改造,以下核心架构与协议层保持不变:
- 纠删码与 Bitrot 保护:底层仍采用 Reed-Solomon 纠删码与 HighwayHash256 分块校验。
- 基本读写仲裁规则:读仲裁仍为数据分片数 $K$;写仲裁在数据分片与校验分片不等时为 $K$,相等(如 8+8)时为 $K+1$(例如 16 盘 12+4 配置下读写仲裁均为 12,8+8 配置下读仲裁为 8、写仲裁为 9)。
- 磁盘格式与系统命名空间:
xl.meta格式版本保持1.3,系统元数据目录仍为.minio.sys,已有数据盘无需格式转换即可直接挂载。 - 线上协议与配置兼容:完整保留
MINIO_*、MC_*、CONSOLE_*环境变量、/minio/health/*健康探针、/minio/v2/metrics/*与/minio/metrics/v3监控路径,以及X-Minio-*头部兼容(详情见兼容性总览与迁移指南)。
2. 安全、认证与授权:7 个 CVE 与 18 项安全边界加固
接手维护的第一道硬仗是安全漏洞。由于 SILO 保留了 github.com/minio/minio 模块路径,而上游发布 GHSA/CVE 时往往将受影响版本写成开放区间,许多安全扫描器会把所有历史漏洞一股脑报在 SILO 头上。
我们先把账算清楚:诸如 CVE-2025-62506(服务账户绕过)、CVE-2025-31489、CVE-2025-27414 在上游 2025-10-15 基线中已经修复;而以下 7 个 CVE 是在 2025-10-15 之后才披露、上游拒绝为开源版提供补丁、由 SILO 独立修复并验证的真实漏洞:
| CVE / 公告 | 严重度 | 漏洞本质与攻击后果 | SILO 修复方案与源码证据 |
|---|---|---|---|
| CVE-2026-33322 | Critical (9.8) | OIDC JWT 算法混淆:当 JWT 头部声明 HS256 等对称算法时,上游将 OIDC ClientSecret 当作 HMAC 密钥验签,知道 ClientSecret 的攻击者可伪造任意身份(含 consoleAdmin) |
严格限定 JWKS 非对称公钥验签,彻底拒绝 HMAC 对称算法(cmd/jwt.go、internal/config/identity/openid/) |
| CVE-2026-33419 | High | LDAP STS 接口可枚举有效用户名,且无登录速率限制,可被直接爆破换取 STS 凭据 | 统一绑定失败的认证错误响应,并按“可信源 IP + 归一化用户名”双维度施加滑动窗口限流,成功登录不消耗配额(cmd/sts-handlers.go) |
| CVE-2026-34204 | High | 普通客户端在 PUT/COPY 请求中夹带未授权的 X-Minio-Replication-* 头,可污染加密元数据导致对象永久不可读 |
在请求入口剥离未获复制授权的内部复制头与 SSE 复制元数据(cmd/auth-handler.go、cmd/object-handlers-common.go) |
| CVE-2026-39414 | High | S3 Select 处理无换行符的超大 CSV 或 JSON Lines 流时无界累积单条记录,单请求即可打穿进程内存(OOM) | 为 CSV 与 JSON 解析器设置严格的单记录最大字节上限,超限立即终止流并返回规范错误(internal/s3select/) |
| CVE-2026-41145 | High | STREAMING-UNSIGNED-PAYLOAD-TRAILER 流式写入路径在特定处理分支漏掉签名校验,允许未授权或伪造签名请求写入对象 |
在所有 aws-chunked 与 unsigned-trailer 入口强制执行完整的请求认证与权限检查(cmd/auth-handler.go) |
| CVE-2026-40344 | High | Snowball 归档批量解压上传接口存在入口认证缺失与归档内部条目越权写入 | 强制校验入口身份,并对归档内展开的每一个目标对象键逐一执行目标路径与桶策略鉴权(cmd/bucket-handlers.go、cmd/object-handlers.go) |
| CVE-2026-42600 | High | 节点间内部存储接口 /rmpl(ReadMultiple)缺少磁盘根目录路径边界约束,存在路径穿越读取风险 |
直接从内部存储 REST 路由与协议中移除未使用的 ReadMultiple 接口,并对全部保留的内部磁盘 RPC 实施严格的盘内路径校验(cmd/storage-rest-server.go) |
18 项 SN-2026-* 安全边界加固
除了公开披露的 CVE,我们在对抗性代码审查与真实多节点验收中,还系统性地挖掘并修复了 18 项安全边界缺陷(完整索引见安全公告总表):
- SigV4 签名头完整性与策略条件键隔离(SN-2026-011、SN-2026-003):
- 上游允许攻击者在已签名的 SigV4 请求外附加未签名的
X-Amz-*业务头(如篡改保留期绕过、对象标签或校验和)。SILO 强制要求请求中出现的全部X-Amz-*业务头必须包含在SignedHeaders中。 - 上游在构造 IAM 策略评估参数时,会把客户端请求头和查询参数混入条件字典,导致客户端能伪造
s3:prefix、s3:versionid、s3:ExistingObjectTag/*或aws:PrincipalType等服务端权威条件键。SILO 在silo-pkg中将服务端权威条件键与客户端输入彻底隔离,并在DeleteObjects与GetObject中将s3:VersionId严格绑定到实际操作的目标版本。
- 上游允许攻击者在已签名的 SigV4 请求外附加未签名的
- 桶级写权限收紧、指定版本删除授权与管理动作拆分(SN-2026-004、SN-2026-005、SN-2026-009 与 010、SN-2026-017):
- 收紧 12 项桶级写操作(如
s3:CreateBucket、s3:DeleteBucket、s3:PutBucketPolicy、s3:PutLifecycleConfiguration等)的资源匹配规则,拒绝仅授予arn:aws:s3:::bucket/*(对象通配符)或裸arn:aws:s3:::的策略越权修改桶级配置。 - 显式指定版本 ID(包括
null)进行永久删除时,要求s3:DeleteObjectVersion;不指定版本、只创建删除标记时仍使用s3:DeleteObject,批量删除逐项检查。 - 将启停用户的权限从
admin:CreateUser中拆出为独立的admin:EnableUser/admin:DisableUser;将用户自改密码拆分为独立的admin:ChangeMyPassword(详见密码权限迁移);修复 IAM 管理接口创建、查看、列举访问密钥时的父账户归属校验。
- 收紧 12 项桶级写操作(如
- 服务账户授权上限与 Object Lock 治理绕过校验(SN-2026-015 与 016):
- 修复内联会话策略(session policy)在没有同名
Allow语句时丢失父身份Deny规则的漏洞,并在临时 STS 凭据与服务账户每次鉴权时实时复核父账户当前权限上限。 - 修复
PutObject覆盖受GOVERNANCE模式保护的对象时未要求x-amz-bypass-governance-retention: true请求头的漏洞;在PutObjectRetention放行治理模式绕过前先完成 XML 请求体合法性校验。
- 修复内联会话策略(session policy)在没有同名
- IAM 持久化撤销、策略重载锁隔离与 LDAP 长 DN(SN-2026-013):
3. S3 协议补齐与端到端校验和
在 S3 协议层,SILO 这一年重点解决了三件事:把上游留下的空壳 API 补齐、把多池条件读写语义拉齐、把分片与加密场景下的校验和及逻辑大小算准。
| 特性 / 场景 | 上游 MinIO (2025-10-15) |
SILO (20261026) |
相关设计记录 |
|---|---|---|---|
| 桶级 CORS 配置 | GetBucketCors/PutBucketCors/DeleteBucketCors 直接返回 NotImplemented |
完整实现桶级 CORS XML 持久化(最多 100 条规则、64 KiB)、OPTIONS 预检匹配、跨站复制与桶元数据导入导出 |
按桶配置 CORS |
条件 DELETE |
不支持 If-Match 条件删除 |
单对象 DELETE 支持 If-Match: "<etag>" 与 If-Match: *,在共享对象锁内校验;批量 DeleteObjects 尚不支持逐项 ETag 条件 |
条件删除设计 |
多池条件 PUT 与分片完成 |
只检查目标写入池,可能被旧池的过期 ETag 误放行或误拒绝 | 跨所有存储池(含正在退役/排空的池)解析逻辑当前对象后再校验 If-Match / If-None-Match |
多池对象一致性 |
| 服务端分片校验和 | 已声明校验和算法的分片上传在分片请求省略校验和头时失败,复制分片也受影响 | 按上传初始化时声明的算法,在压缩或加密前计算明文分片校验和,并持久化到 xl.meta;未声明算法的上传不会因此默认启用 CRC32C |
分片校验和 |
CompleteMultipartUpload 校验 |
对重复 part 号、缺失校验和或 FULL_OBJECT 类型匹配检查不严 |
严格要求 part 号单调递增(重复返回 InvalidPartOrder),严格校验分片校验和与 FULL_OBJECT/COMPOSITE 汇总值 |
校验和完整性修复 |
CopyObject / UploadPartCopy 校验和 |
服务端复制加密或压缩对象时可能直接拷贝底层密文 bitrot 哈希,且响应漏返 ChecksumType |
统一按明文逻辑字节重算或验证校验和,并在 CopyObjectResult / CopyPartResult 中返回准确的 ChecksumType |
校验和完整性修复 |
| SSE-C 同对象密钥轮换与零字节 | 用新 SSE-C 密钥原地 CopyObject 只改元数据不重加密数据,导致对象损坏;零字节 SSE-C 对象与分片属性未解封校验 |
同对象换钥时强制重写底层密文;零字节 SSE-C 对象及 GetObjectAttributes 分片列表均执行完整密钥解封验证 |
SSE-C 密钥轮换、零字节验证 |
| 加密/压缩分片逻辑大小 | GetObjectAttributes 与 ?partNumber=N 可能返回含加密/压缩开销的物理分片大小 |
解密/解压路径与元数据属性统一返回真实的明文逻辑分片大小 | 分片逻辑大小 |
| 加密元数据篡改状态码 | ObjectTampered 被映射为 HTTP 206 Partial Content |
修正为 HTTP 500 (XMinioObjectTampered),避免客户端把损坏对象当成正常部分响应缓存或续传 |
ObjectTampered 500 |
DNS 联邦 CopyObject |
跨集群联邦复制可能直接流式传输 .minio.sys 底层密文导致目标对象无法解密 |
改为通过标准 S3 客户端流式读取明文并在目标端重新封存 | 联邦 CopyObject |
4. 跨站复制与生命周期
在开启了跨站复制(Bucket / Site Replication)或远端分层(ILM Tiering)的生产集群中,最危险的缺陷往往不是“请求直接报错”,而是源端与备端静默不一致,或者生命周期规则误删了仍在引用的底层数据。过去一年里,SILO 围绕复制与生命周期修复了六类核心问题:
| 问题领域 | 上游旧行为与风险 | SILO 修复后的行为 |
|---|---|---|
分片对象 FULL_OBJECT 校验和复制(#255) |
源端分片上传使用 CRC32/CRC32C FULL_OBJECT 校验和时,复制发起端调用 NewMultipartUpload 漏传 x-amz-checksum-type: FULL_OBJECT,导致目标端按 COMPOSITE 校验并在 CompleteMultipartUpload 时以 BadDigest 失败 |
在 putReplicationOpts 中显式携带 ChecksumType,使分片复制与 CopyObject 共享完整的 FULL_OBJECT 校验和头(设计记录) |
| SSE-C 加密对象复制 | 复制 SSE-C 对象时重新生成随机 KMS/DARE 封套或破坏原始客户密钥封存,导致备端副本无法用原 SSE-C 密钥读取 | 在受信任的复制通道上原样保留 SSE-C 原始密文、随机 IV 与加密封套元数据(设计记录) |
| 对象标签删除与乱序复制 | 删除对象标签时旧版本只传空字符串而不推进修订时间,迟到的旧复制事件会把已删除的标签重新写回;SSE-KMS 复制还会丢失标签修订时间 | 引入 replication-tag-modtime 时间戳与删除墓碑,SSE-KMS 复制保留标签修订时间,乱序到达的旧标签事件自动被高水位时间戳拦截(设计记录) |
| Object Lock 保留期复制排序 | 跨站并发更新同一对象版本的 Object Lock 保留期时,缺乏统一顺序判定,旧保留期可能覆盖新保留期 | 引入 replication-retention-modtime 时间戳并在持久化锁保护下按时间戳收敛(设计记录) |
| 删除标记复制与版本清除(Purge) | 指定版本永久删除(Purge)在缺盘时可能反而在盘上新建删除标记;MRF 重试队列可能混淆“新建删除标记”与“永久清除版本” | 严格区分删除标记创建与指定版本清除,排队的创建任务在发送前先在复制锁内复核源端状态,缺版本清除需写仲裁多数确认(设计记录) |
版本桶生命周期过期与远端分层(#253) |
启用版本控制(或暂停版本控制但含非 null 历史版本)的桶触发 Expiration 规则时,若操作仅仅是追加一个删除标记,旧逻辑仍可能连带删除已分层到远端存储的底层数据,导致历史版本永久无法读回 |
明确区分“仅创建删除标记的当前版本过期”与“真正销毁历史版本的过期动作”;前者完整保留远端分层数据,只有真正清除对应版本时才回收远端对象 |
此外,在复制重同步(Resync)与站点桶配置同步上,SILO 为每个 Resync 任务与目标端拆分了独立的取消上下文并修正了 ReplicatedCount/FailedCount 统计(复制重同步正确性),同时为桶级元数据(CORS、生命周期、加密、复制、锁配置)重载加装了逐桶互斥锁与可选的字段级删除墓碑(桶配置收敛)。
5. 存储写入、回滚与多池一致性
在纠删码存储引擎中,正常路径很容易写对,真正的坑全在慢盘、部分写失败回滚、并发覆盖写与多存储池(Multi-Pool)扩缩容的交叉地带。20261026 正式版收敛并交付了以下三组底层存储保护:
1. 存储提交错误分类与覆盖回滚 RPC 透传
- 暂存
xl.meta缺失归类为503 SlowDownWrite:在提交阶段,若个别慢盘因并发清理或时序竞争导致.minio.sys/tmp中的暂存xl.meta缺失,SILO 将其归类为errFileNotFound并向客户端返回 HTTP503 SlowDownWrite触发安全重试,而不是像过去那样误报404 ObjectNotFound(错误分类设计记录)。 - 覆盖写回滚选项透传与超时边界:转发存储 RPC 的删除选项(包括覆盖写回滚所需的旧数据目录),同时保持既有的磁盘/RPC 超时、请求取消、对象锁、读仲裁与自愈规则。需要明确的是:客户端收到请求超时并不代表底层磁盘或 RPC I/O 已经停止,也不代表写入一定未提交——在部分磁盘故障、租约丢失或网络分区下,迟到提交或延迟
UndoWrite仍可能发生(10 月维护候选中曾尝试的已接纳 I/O 排空等待与逐盘变更锁已在dcc2f20ba中撤回,详见最终维护边界)。
2. 多池并发锁、两阶段批量删除与跨池迁移保护
在多存储池(Multi-Pool)集群中:
- 跨池共享对象锁:修复了多池集群中不同存储池各自维护局部锁导致并发写入、删除与元数据更新互不感知的问题。
- 多池批量删除保住最新副本(
#257):过去多池DeleteObjects对所有存储池并行下发删除,一旦保存最新版本的池删除成功、而保存旧副本的池因磁盘故障删除失败,旧池里的过期数据就会“复活”成当前可见对象。SILO 将多池删除改为两阶段执行:先定位每个对象的权威版本所在池,优先清理非权威池中的旧副本,待旧副本清理确认后再删除权威池副本,并在部分失败时保护仍被引用的远端分层对象(多池删除设计记录)。 - Rebalance 与 Decommission 迁移保护:修复了跨池再平衡(Rebalance)与池退役(Decommission)过程中丢失对象标签、覆盖目标池更新版本或漏处理删除标记的问题(多池对象一致性)。
3. 盘间分歧下的 LIST 恢复(#239)
在滚动重启叠加并发覆盖写时,部分磁盘排在前面的少数派新状态或未决条目可能导致 ListObjects 漏掉实际上可读的对象。SILO 在保持无锁快路径的前提下,对盘间出现分歧的候选条目执行按需确认,严格区分“对象已删除”与“磁盘读取故障”,并将终端错误正确传递到分页响应中(LIST 恢复设计记录)。
6. 完整管理面回归:SILO Console v2.5.1 与 mcli 20261026
对日常运维人员来说,SILO 与上游 MinIO 最直观的差异体现在管理控制台(Console)和命令行客户端(mcli)上。
SILO Console v2.5.1:从被阉割的浏览器回到完整管理台
2025 年中,上游将社区版 MinIO 内嵌的 Console 裁剪到了 v1.7.7,砍掉了所有集群管理、IAM、策略、生命周期、复制、诊断与监控界面,只留下一层简陋的对象浏览器;随后更是直接删除了 github.com/minio/console 仓库。
SILO 在社区开发者 Georg Mangold(@gmangold)恢复的历史分支基础上,从 v2.0.0 持续迭代至 v2.5.1,交出了一份不仅恢复全功能、而且比原版更轻量可靠的管理控制台:
| 维度 | 上游 2025-10-15 内嵌 Console (v1.7.7) |
SILO Console v2.5.1 |
|---|---|---|
| 管理功能范围 | 仅剩基础桶与对象浏览,移除全部管理面板 | 完整管理面:Overview 概览、存储桶、对象浏览器、生命周期、跨站复制、分层存储、事件通知、IAM(用户/组/服务账号/策略及内置策略 Reset)、IDP(OIDC/LDAP)、KMS、集群诊断(Health/Trace/Logs/Watch/Speedtest/Profile)与后台任务中心(Tasks) |
| 前端体积与遥测 | 约 10 MB,含外部字体/脚本与运营服务调用 | < 3 MB,逐字节可复现构建,零遥测、零外部网络依赖,响应式明暗主题 |
| 多语言支持 | 仅英文 | 原生中英双语切换(含界面、错误提示与文档跳转) |
| 监控指标体系 | 旧版接口或缺失 | 原生对接 Metrics V3,区分“真实零值”与“节点未上报数据” |
| 大桶浏览与文件预览 | 易在大目录下全量拉取卡死 | 游标分页(默认 100 项,最高 1,000 项)、1 MiB 有界文本/Markdown/CSV/代码预览、原生流式 ZIP 批量下载 |
高延迟网络诊断(#63) |
跨地域或高延迟链路下下载健康报告易触发 WebSocket 1006 / 1011 截断 |
修复六类诊断流的 WebSocket 关闭握手与 madmin v3 帧解码器预读吞字节缺陷,双向叠加 250 ms 延迟仍稳定输出完整健康报告 |
Console 2.5.1 进一步修复切换组状态时丢失成员、OIDC 组权限、TLS 代理会话 Cookie、凭据导出及键盘焦点问题,完整变化见 Console 2.5 系列发布说明。
mcli 20261026:说真话的退出码与只读校验和核验
在客户端侧,pgsty/mc 以 mcli 作为二进制与包名称发布(同时保留 MC_* 环境变量与配置格式兼容),重点解决了自动化脚本最头疼的“表面成功、实际失败”问题:
- 新增只读
mcli checksum verify命令:无需下载落盘或修改对象,直接对存储桶、前缀或指定对象版本执行端到端FULL_OBJECT校验和核验,支持--versions、--Algo与--json输出。 - 修复一系列静默返回
0的错误退出码:mcli admin info --json在权限被拒绝或节点报错时输出status: error的同时返回非零退出码(上游仍以0退出);mcli mirror、mcli mv、mcli retention、mcli legalhold、mcli sql在遇到部分对象失败、源删除失败或权限拒绝时,均确保返回非零退出码;- 下载流中途断开时直接报错返回,绝不把截短文件当作成功下载。
- 安全的
mirror删除传播与服务重启预演:修复mirror --remove在源端列举异常时的误删风险;为mcli admin service restart增加--dry-run预演模式;支持在别名配置中持久化可选的region字段。
20261026 还加入客户端身份缓存隔离、复制重同步 JSON 终态输出和速率溢出修复、start/cancel 部分失败的非零退出码,以及缺少实时指标时的 batch pending 状态与有界回退。完整命令与脚本迁移说明见 mcli 更新。
7. 运维配置、供应链与二进制分发
在服务端运维配置与交付链路上,SILO 做了四类贴近生产环境的改造:
- 让请求头超时真正生效,并补齐可信代理边界:
- 上游虽然定义了
--read-header-timeout/MINIO_READ_HEADER_TIMEOUT,但并未真正传给底层http.Server,导致 HTTP/1 连接在慢速滴灌请求头(Slowloris)下可以无限期挂住。SILO 将该参数真正接入 HTTP 服务(默认 30 秒绝对读取期限,设计记录)。 - 新增
MINIO_API_TRUSTED_PROXIES与MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES,默认不再盲目信任客户端伪造的X-Forwarded-For;新增CONSOLE_WS_MAX_MESSAGE_BYTES与CONSOLE_WS_MAX_DECOMPRESSED_BYTES限制内嵌控制台 WebSocket 帧大小。 - 新增原生
silo healthcheck子命令,使无 shell 的 distroless 容器也能直接执行就绪与存活探针;收紧MINIO_CONFIG_ENV_FILE解析器,拒绝畸形配置行。
- 上游虽然定义了
- 修复事件通知目标(Notification Targets)连接管理:
- 修复 NATS 断线重连与 JetStream 异步发布回复主题冲突、AMQP 确认锁竞争、PostgreSQL/MySQL TLS 配置加载,并显式保留 Kafka 通知目标的默认协议版本
2.1。
- 修复 NATS 断线重连与 JetStream 异步发布回复主题冲突、AMQP 确认锁竞争、PostgreSQL/MySQL TLS 配置加载,并显式保留 Kafka 通知目标的默认协议版本
- 彻底移除自更新与后台外联(Call-Home):
- 永久移除二进制原地自更新(
MINIO_UPDATE)以及全部SUBNET注册、遥测、许可证检查与后台外联代码。软件升级完全交还给操作系统的包管理器(RPM/DEB/APK)与容器编排系统。
- 永久移除二进制原地自更新(
- 重建可验证的多架构发行供应链:
- 全栈升至 Go 1.27.3(内置
crypto/x509失信中间证书拦截),容器基础镜像采用 UBI 9.8 与 distroless 双变体,内置静态编译的curl 8.23.0并应用 Red HatRHSB-2026-012根证书封禁列表。 - 每个正式版本均提供 Linux(
amd64/arm64/ppc64le/s390x)、macOS(amd64/arm64)、Windows(amd64/arm64ZIP 归档)二进制、RPM/DEB/APK 软件包、Docker Hub 多架构镜像(pgsty/silo、pgsty/silo-console、pgsty/mc、pgsty/kes),并附带 GPG 签名、SPDX SBOM 与 GitHub Sigstore 构建证明(详见下载与安装)。
- 全栈升至 Go 1.27.3(内置
8. 诚实的边界:撤回的尝试与尚未解决的问题
维护一个生产级存储系统,不仅要看合并了什么,更要看试过之后因为代价过高而主动撤回了什么。这一年里,有四项改动我们在做完实现、跑完压测或故障注入后,最终决定撤回或关闭:
- 撤回公开持久化的
ListMultipartUploads磁盘扫描(#79):- 初衷:上游的
ListMultipartUploads只查单节点内存缓存,节点重启后未完成的分段上传在列表中就会消失。我们曾在 9 月版本中尝试扫描底层存储池/纠删集上的持久化上传目录。 - 撤回原因:容量实测表明,持久扫描的延迟随前缀深度与残留未完成上传数量呈线性恶化,在 20,000 个未完成上传规模下会直接触发请求超时并拖慢磁盘 I/O。因此在 Server 20261018 中,我们果断撤回了公开持久扫描并退役了可选
strict模式,恢复为节点本地缓存加精确 key 查询(详见 ListMultipartUploads 设计与容量证据)。
- 初衷:上游的
- 撤回默认路径上的严格代际拒读与自动跨池读恢复:
- 初衷:当部分覆盖写失败导致不同磁盘或不同存储池之间出现版本代际分裂(split generation)时,曾尝试在读取路径上直接拒绝存在代际分歧的读取,或在
GET时自动跨池恢复。 - 撤回原因:故障注入表明,在部分覆盖写失败后直接拒读会破坏 MinIO 原有的可用性契约(即部分覆盖失败后旧版本仍应可读)。因此我们将严格拒读诊断降级放入非默认的
consistency_strict构建标签中,默认生产构建继续保留原有的读取仲裁与自愈行为。
- 初衷:当部分覆盖写失败导致不同磁盘或不同存储池之间出现版本代际分裂(split generation)时,曾尝试在读取路径上直接拒绝存在代际分歧的读取,或在
- 放弃多池覆盖亲和性方案(#245):
- 初衷:在多存储池集群中,同名对象的无版本覆盖写可能落到新池,从而在旧池留下过期副本。
#245尝试把覆盖写强制路由回对象原先所在的存储池。 - 放弃原因:当历史存储池因为坏盘失去写仲裁、而集群中其他健康存储池仍有充足空间时,强制亲和会让覆盖写直接失败;且两轮
m3多节点压测显示小对象覆盖延迟仍未达标。最终我们选择关闭#245不予合并(详见多池覆盖亲和设计记录)。
- 初衷:在多存储池集群中,同名对象的无版本覆盖写可能落到新池,从而在旧池留下过期副本。
- 移除实验性的访问频率池间分层(
ilm access_*):- 该实验特性从未达到生产可靠性标准,已在 Server 20260916 中彻底清理(详见访问频率分层移除说明)。
当前仍需注意的工程与升级边界
最后,把当前版本的客观边界再次汇总如下,供评估与升级时参考:
- 没有分布式锁 fencing:当节点因 GC 停顿、网络抖动或慢盘导致锁租约过期后,失去租约的节点上已经在途的提交仍可能晚于新写入落地;部分元数据更新在故障窗口下仍可能短暂暴露旧标签或旧属性。本版保持既有超时语义,不能把请求返回当作底层 I/O 已结束的证明。
- 不宣称未经对等验证的全集群吞吐百分比:我们在单点热路径上有明确的微基准(例如
silo-pkg v3.14.3将ContainsAdminAction从每次调用分配 24 次内存、742.5 ns/op优化为零分配、5.53 ns/op),但尚未在固定硬件上对2025-10-15与2026-10-26运行完整的端到端多节点吞吐基准。 - 协调升级要求:虽然底层磁盘格式保持
xl.metav1.3 兼容,但启用持久化 IAM 撤销(siloParentRevocation)、站点复制元数据墓碑时,集群中的所有参与节点应协调升级至相同版本,不支持新旧节点长期混跑或滚动降级(详见组件版本矩阵与 IAM 升级指南)。 - 已知待修细节:当前仓库内 Helm Chart(
7.0.3)的默认服务端镜像标签仍停留在RELEASE.2026-09-16T00-00-00Z(部署新版本时需通过values.yaml显式指定目标标签)。
结语
一年前按下 fork 按钮时,我们的目标很朴素:不造噱头、不画大饼,让还在跑开源 MinIO 的生产环境有安全补丁可打、有完整控制台可用、有干净的二进制包和镜像可拉。
一年下来,604 次提交、7 个 CVE 补丁、18 项安全边界加固、几十篇公开在设计归档与安全归档里的根因分析与取舍记录,就是 SILO 交出的第一年答卷。
如果你正在使用开源版 MinIO,可以直接查阅迁移指南与下载页面;如果你在生产环境中遇到任何可复现的协议、复制或存储问题,欢迎直接到 pgsty/silo 提 Issue。
路还长,我们继续维护。
1.2 - 续命 MinIO:承诺兑现
两个月前,我在《MinIO 已死,MinIO 复生》里立了一个 flag,承接上游的烂摊子,跟 CVE、修 Bug。 那篇文章上了几个小时的 Hacker News 头条。鼓励不少,质疑也不少:一个人,真维护得了这种项目吗?
这个问题其实问得很好。因为真正见真章的时刻,不是点 fork 按钮,也不是改 README 文档,而是当安全漏洞真砸下来的时候。
现在,这件事可以交账了。
4 月 15 日到 17 日,三天时间,pgsty/minio 发布了 RELEASE.2026-04-17,连续修掉并关闭了 4 条 CVE 加几条同期披露的安全漏洞,OIDC JWT 算法混淆(CVSS 9.8)、LDAP 登录用户名枚举与暴力破解、复制头元数据注入导致对象不可读、S3 Select 超大记录打穿内存,以及两条 unsigned-trailer 写入路径上的签名绕过。
A promise made, a promise kept.
当时我把话说得很清楚:不做新特性,只保障供应链;遇到可复现问题和安全漏洞,会积极跟进和修补。这件事,现在算是交账了。
上游发生了什么
2025 年 12 月,MinIO 把开源仓库改成 维护模式。README 里写着 “安全修复会逐例评估”。
到 2026 年 2 月,仓库直接归档,首页变成了 “当前仓库已经不再维护”。但同一个仓库的 SECURITY.md 还留着:“我们总会为最新版本提供安全更新”。
而最近一个月,MinIO 又暴漏出四个高危漏洞,两个中危,覆盖最后的开源版本。

与此同时,上游官方仓库距离最后一次发布已经过去 184 天。 他们披露漏洞,但只在商业版本中修复。对于开源版用户,他们给的建议就一条,“升级到商业版 AIStor”。
顺便一提,MinIO 入门起步价约 10 万美元/年,400 TiB,单价基本跟 AWS S3 差不多,简直离大谱,毕竟这是纯软件。
一种很精致的玩法。仓库归档了,道义责任撇清了;但 CVE 通告照发,既能刷一波 “我们很负责任” 的存在感,又恰好能把用户赶进商业版的羊圈。
挺聪明的。只是还得有人得把这坑填上。
这次修了什么
这篇文章我不想写成漏洞分析报告。具体每一条的 CVSS 分数、攻击链、PoC 代码,我在 发布注记 里都一一列了,感兴趣的朋友可以去看。这里只说一句话版本:
- CVE-2026-33322(OIDC JWT 算法混淆,CVSS 9.8):在特定 IdP 配置下可以 伪造任意身份,包括
consoleAdmin。攻击者只要知道 OIDC ClientSecret,数学上就能签出一张 “我是管理员” 的通行证,MinIO 会乖乖验证通过。影响范围从 2022 年 11 月到今年 3 月,三年半。 - CVE-2026-33419(LDAP STS 登录枚举与暴力破解):攻击者可以先用登录接口枚举出真实用户名,再无速率限制地爆破密码,最后直接拿到 STS 凭证。整个链条从头到尾没有一道闸。
- CVE-2026-34204(复制头元数据注入):普通 PUT / COPY 请求里夹一些
X-Minio-Replication-*头,就能把对象写成 永久不可读 状态,数据还在,但你再也读不出来。 - CVE-2026-39414(S3 Select 内存耗尽):一条恶意请求,就可以把 MinIO 进程吃到 OOM。
- GHSA-hv4r-mvr4-25vw / GHSA-9c4q-hq6p-c237:unsigned-trailer 路径上的两条签名校验绕过,匿名或伪造签名的请求可以在某些路径下成功写入对象。
再加上 go-jose、go.opentelemetry.io 和 Go 1.26.2 自身吸收的一连串标准库与依赖 CVE,这一版本聚合了接近二十条安全条目。
有的能伪造身份拿到高权限访问,有的能把登录入口拿去枚举和爆破,有的能把对象写成永久不可读,有的能用一条请求把服务吃到 OOM,还有的能在缺失签名校验时直接写入对象。这不是小修小补,这是实打实的维护责任。

这次是怎么修的
在之前那篇文章里,我明确说过我会用 AI Coding Agent 来维护这个项目,事实上我也是这么做的。这一轮修复里,我扮演了一个 Blind Manager 的角色。
简单解释一下我的工作范式。具体到每一条漏洞,流程大致是:
- Codex 先打铁:根据 CVE 描述和相关代码路径,产出第一版补丁。
- Claude Code 做 review:站在对抗视角挑毛病。
- 回到 Codex:我要求它,如果你同意 Claude Code 的意见,那就返工;如果不同意,那就反驳,把理由写清楚。
- 把所有思路摊开,再交给 Claude Code 做一轮 review。必要时来回再跑几轮,直到两边收敛。
- 进行测试:依然是类似的对抗操作,由 Codex 设计测试用例,Claude 补充。然后由 Codex 去实际执行并产出结果,再由 Claude Code review。
- 我来定夺:看 diff,跑测试,做最后决策与验收。
这个过程中,我自己不写一行代码。我的工作是定义问题、约束边界、挑方案、看 diff、跑验收、拍板。
公开提交页上,你能直接看到 Vonng、Codex、Claude Code 三个名字同时出现在几条关键安全提交的 Co-authored-by 里。这不是作秀。这就是 2026 年一个人维护一个中型基础设施项目的真实样子。

这种协作模式有几个实际的好处。
第一,两个 agent 对抗能筛掉大部分“听起来都对、实际上不对”的方案。 单独一个 agent 在修复安全漏洞时会有一种 “幻觉级自信”,写出一份解释流畅、看起来干净的补丁,但漏掉了一个边界条件。让另一个 agent 从敌对视角审视它,这种方案很难活过第一轮。
第二,逼出显式的权衡。 两家不同实现路径撞上了,自然就要回答 “为什么你选 A 而我选 B”。这个对话本身就是在把隐性假设显性化,而显性化的假设,才是我作为 Blind Manager 能拍板的东西。
第三,真正的维护是“补丁打补丁”,而不是一把梭。
拿 LDAP STS 这条洞来说,首版修复推出来以后,很快发现成功请求不该消耗限流额度、默认不该信任 X-Forwarded-For、限流账户要按 “源 IP + 归一化用户名” 双维度算账。
然后又连着补了三次提交才算收敛干净。这个过程如果没有 agent 的火力支持,单个 maintainer 要一边读代码一边迭代,成本是完全不一样的。

有些事还是要人来拍板
但也正因为这个模式运转得不错,maintainer 唯一的不可替代性,就凸显在那些 AI 给不出最后答案的地方。
最典型的就是 OIDC 那条 fix。表面上,它是一个 JWT 算法混淆漏洞;但实质上,它是一个 兼容性和安全性之间的取舍。
简单解释一下。JWT 的签名算法分两类:非对称(RS256、ES256 这类,签名用私钥、验签用公钥)和 对称(HS256 这类,签名和验签用的是同一个密钥)。OIDC 的标准姿势是 IdP 用自己的私钥签 token、MinIO 用公开的 JWKS 拿到公钥来验签。公钥是公开的,攻击者拿不到私钥,所以没法伪造。
而 HS256 这类对称算法的问题在于:签名和验签用的是同一个密钥。这个密钥就是 MinIO 自己也存着的 ClientSecret。于是攻击者只要拿到这个 “共享秘密”,就既能当裁判又能当运动员。自己用它签一张 token,MinIO 拿自己存的同一个密钥一验,当然通过。
这在教科书上是 JWT 的经典反模式,但历史代码就是这么走过来的。修法有几条路可选:
- 继续容忍这条历史路径,只在某些条件下收窄:保留向后兼容,但安全边界依旧模糊。
- 严格 JWKS-only,拒绝 HS256 等对称签名 token:一刀切、安全边界清晰,但少数本来就配得模糊的用户会感到配置失效。
两个 agent 可以给我列出每个方案的 trade-off,可以写好任何一个方案对应的补丁,但它们不会替我决定。最后我的选择是后者,恢复严格的 JWKS-only 验证路径,明确拒绝不该接受的 HS256。
这个决定也许会让少数模糊配置失效,但安全边界终于清楚了。AI 可以提三个方案,真正承担后果的人还是 maintainer。
这就是 Blind Manager 模式的上限,也是下限:机器负责穷尽方案,人负责选择方向。
不是情怀,是必需
我一直说,这个 fork 不是情怀,也不是 cosplay。它存在,首先是因为这是我自己要用的东西。
MinIO 是 Pigsty 的生产依赖。我需要可用的二进制、完整的控制台、持续可得的包,以及真正有人处理的 CVE 补丁。也正因为我自己在用,所以这条线没有太多空话空间:它不是拿来讲故事的,而是拿来顶生产环境的。
这也决定了我的策略很保守。不会去追求 “新特性很酷”,也不会把仓库弄成另一个方向的实验场。我的目标一直都很明确:保持兼容,守住供应链,在该修的时候把问题修掉。
到现在,这个分支在 GitHub 已经有了 1300 star,在 Docker Hub 也累计了 五万+ 下载。数字本身不算什么惊人的成就,但它至少说明了一件事:需要这条线的人,并不只有我自己。

2026-10-25 更新: 当前维护的 Docker 镜像已更名为
pgsty/silo。下文 Docker 示例已更新为当前名称,详见迁移指南。
对已经在用 MinIO 开源版的人来说,迁移到这个分支的成本其实很低:
- Docker 镜像:把
minio/minio换成pgsty/silo,一行的事。 - RPM / DEB:GitHub Release 里都有,或者用
pig一键装。 - 源代码仓库:pgsty/minio
- 中文文档:silo.pgsty.com/zh/
- 英文文档:silo.pgsty.com

你不需要换掉整个系统,也不需要重新学习一套对象存储;多数情况下,只是把一个失去维护的上游,替换成一个还会继续交付补丁的分支。 如果你需要完整的生产级部署方案,Pigsty 里也提供了开源免费、开箱即用的 MinIO 生产级高可用部署支持。
承诺是什么
两个月前那篇文章发出去以后,有人私信我,说这事看着挺悲壮。其实不是。
写那篇文章的时候我没有赌气,发这个版本的时候我也没有激动。从头到尾,这就是一件普通得不能再普通的事,用的东西坏了,自己修一下。仅此而已。
只是到了 2026 年,“自己修一下” 这件事的门槛,被 AI Coding Agent 重新定义了。一个人,加两个 agent,加一点点判断力,足以把一个六万 star 的中型基础设施顶起来。这不是我厉害,这是 时代变了。
以前我们谈论开源的韧性,谈的是 “社区”,几十上百个志愿者众筹时间。现在这套剧本还在,但 底下多了一层保险:哪怕社区散了,只要有一个人还愿意按下 fork 按钮,项目就能续命。
承诺是什么?承诺不是 “我有激情”,也不是 “我有道义”。承诺是 “下一个 CVE 出来的时候,我还在”。

下一个 CVE 出来的时候,老冯还在。
就这样。
1.3 - MinIO 已死,MinIO 复生
MinIO 开源仓库正式归档,不再维护。一个时代落幕,但开源的精神不死。 老冯 Fork 了 MinIO,复活了管理控制台,重建了二进制分发渠道,让 MinIO 浴火重生。
2026-10-25 更新: 当前维护的 Docker 镜像已更名为
pgsty/silo。下文 Docker 链接与示例已更新为当前名称,详见迁移指南。
如果你正在用 MinIO,把 minio/minio 换成 pgsty/silo ,其他一切照旧。
MinIO 的死亡证明
2025年12月3日,MinIO 在 GitHub 上宣布进入"维护模式"。我写了一篇《MinIO 已死》。
2026年2月12日,MinIO 在 GitHub 首页将状态从"维护模式"更新为 “不再维护”,随后正式将仓库归档(Archived)。Read-only,不接受 PR Issue,不接受任何贡献。 一个拥有六万 star、超过十亿次 Docker 拉取的项目,变成了一座数字墓碑。

如果说12月是 临床死亡,那 2月的这个提交就是 正式开具了死亡证明。
今天(2月14日),一篇题为《How MinIO went from open source darling to cautionary tale》的长文引发了广泛传播,详细复盘了 MinIO 从开源宠儿到反面教材的完整堕落时间线。

Percona 创始人 Peter Zaitsev 也在 LinkedIn 上表达了对开源基础设施可持续性的忧虑。国际社区的共识已经形成:MinIO 完了。

不是 “不更新了” —— 是 彻底的、不可逆的、官方盖棺定论的死了。
回顾这18个月的时间线,你会发现这不是一次意外死亡,而是一场蓄意的、分阶段的自毁:
| 时间 | 事件 | 性质 |
|---|---|---|
| 2021-05 | Apache 2.0 → AGPL v3 | 许可证武器化 |
| 2022-07 | 公开攻击 Nutanix | 许可证执法 |
| 2023-03 | 公开攻击 Weka | 许可证执法 |
| 2025-05 | 阉割管理控制台 | 功能阉割 |
| 2025-10 | 停止分发二进制/Docker | 断供 |
| 2025-12 | 宣布维护模式 | 临终关怀 |
| 2026-02 | 仓库归档,不再维护 | 死亡 |
一家融了1.26亿美元、估值十亿美金的公司,花了五年时间,亲手把自己建立的开源生态一砖一瓦地拆干净。
这比跑路还让人难受 —— 因为跑路至少是一次性的,MinIO 选择了凌迟。
但开源不死
故事到这里,按照正常剧本应该是一声叹息,然后大家各回各家。
但我想讲一个不一样的故事 —— 不是悼词,是复活。
MinIO 公司可以归档一个仓库,但它归档不了 AGPL 协议赋予社区的权利。
讽刺的是,AGPL 正是 MinIO 自己选的。他们当年从 Apache 2.0 换成 AGPL,是为了在保留开源名份的同时拿它当武器打 Nutanix 和 Weka。 但开源许可证 是双刃剑 —— 同一把刀,如今也 保障了社区 Fork 的完全合法性。 代码一旦以 AGPL 发布,许可就不可撤回。你可以把仓库设为只读,但你收不回已经发出的许可证。
这就是开源协议设计的深意:公司可以抛弃项目,但不能带走代码。
所以 —— MinIO 已死,但 MinIO 也可以复生。
但也先别急着热血沸腾。Fork 谁都会,点一下 Fork 按钮的事。 真正关键的问题不是 “能不能 Fork”,而是 有没有人真的能把它当成生产组件来维护。
我本来并不想接这个摊子 —— 但我在 MinIO 进入维护模式后等了一两周,社区里没有人站出来说 “我来”,我就只能自己上了。
简单介绍一下背景:我一个人维护着整个 Pigsty 项目 —— 一个全功能的 PostgreSQL 发行版,451 个扩展,支持 14 个 Linux 发行版的交叉构建。 我同时维护着 270+ PG 扩展、六七款 PG Fork、几十款 Go 软件(Victoria/Prometheus 等)的全平台构建工作流,还是游刃有余的。
我对 MinIO 也不陌生。2018年,我们在探探内部就维护过一个 MinIO 的内部分支(当时还是 Apache 2.0), 支撑了约 25 PB 数据,是当时国内最早、最大的 MinIO 部署之一。
更关键的是,MinIO 在 Pigsty 中是 真实使用的组件, 很多用户将它作为 PostgreSQL 的备份仓库默认跑在生产环境里。

这不是一个 “要不要做” 的问题,而是 不做不行。 早在2025年12月 MinIO 宣布维护模式时,我就已经自己动手创建了修复了 CVE 的二进制。
我们做了什么
截至今天,我们做了三件事。
1. 复活管理控制台
这是社区最愤怒的一刀。
2025年5月,MinIO 把完整的管理控制台(Admin Console)从社区版中移除,只留下了一个残废的对象浏览器。 用户管理、桶策略、权限配置、生命周期管理…… 一夜之间全没了。想要?掏钱买企业版。
我们把它弄回来了。

讽刺的是,这甚至不需要逆向工程。你只需要把 minio/console 子模块的版本号改回去就行了。
也就是说,MinIO 当初做的事情就是 改了一个依赖版本号,把完整控制台换成了残废版。功能都在那,代码都在那,他们只是给你关上了门。
他们拆了门窗,我们给装回去了。
2. 重建二进制分发
2025年10月,MinIO 停止分发预编译的二进制文件和 Docker 镜像,只留源码。“请用 go install 自己编译” —— 这是他们给用户的交代。
对于绝大多数用户来说,开源软件的价值不只是一份源码副本 —— 供应链的稳定性才是命脉。 你需要的是一个可以写进 Dockerfile、放进 Ansible Playbook、塞进 CI/CD Pipeline 的稳定制成品,而不是每次部署前先装个 Go 编译器。
我们重建了完整的分发渠道:
- Docker 镜像
pgsty/silo已上线 Docker Hub,docker pull pgsty/silo即可使用- RPM / DEB 包
- 为主流 Linux 发行版构建了与原版规格一致的安装包。
- CI/CD Pipeline
- GitHub 上全自动化构建流程已经搭建完毕,确保供应链持续稳定。
如果你在用 Docker 镜像,把 minio/minio 简单换成 pgsty/silo 就好了
喜欢原生 Linux 安装的朋友,可以直接从 GitHub Release 页面下载 RPM/DEB 包。 老冯的 pig (PG扩展包管理器)也可以简单的免翻墙安装。你也可以自己配置启用 pigsty-infra APT/DNF 软件仓库来安装。
一切照旧。
3. 复活社区版本文档
MinIO 的官方文档同样面临风险 —— 原本的链接已指向它们的商业产品 AIStor。
所以我们基于 minio/docs 进行了 Fork,修复了失效链接,恢复了被删除的控制台文档,部署在:https://silo.pgsty.com/zh/
文档采用与原版相同的 Creative Commons Attribution 4.0 协议,完整保留了所有内容,并持续进行必要的维护更新。

我们的承诺与原则
一些话需要提前说清楚,免得产生误解。
我们不做新特性,只保障供应链
MinIO 作为一个 S3 兼容的对象存储,功能已经足够完善。 它是一个 已经完成的软件,它不需要更多花里胡哨的新特性,它需要的是一个稳定可靠、持续可用的版本。
我们做的核心事情就是:确保你随时可以拿到一个能用的、完整的、带管理界面的 MinIO 二进制制成品。
RPM、DEB、Docker 镜像 —— CI/CD Pipeline 自动构建,与你现有的基础设施无缝对接。
不用担心某天 docker pull 拉不到镜像,不用担心 yum install 找不到包。
前提是 MinIO 别用商标武器来搞我,搞我那我就只能重命名了。
这是真实使用的版本,不是归档备份
可能有人会想:这只是又一个 Fork 备份而已吧?不是。 MinIO 在 Pigsty 中是真实使用的组件,很多用户将它作为备份仓库跑在生产环境里。 我们使用的就是自己构建的版本 —— 如果出了问题,我们会第一时间发现,第一时间修复。 我们自己构建的版本,已经在自己的生产环境中用了三个月。吃自己的狗粮,是最好的质量保证。
我们会修 Bug 并跟进安全更新
如果你在使用中遇到问题,欢迎在 pgsty/minio 提交反馈。 如果是我们构建的版本中可复现的问题,以及安全漏洞(CVE),我们都会积极跟进和修补 —— 但请不要将此视作商业 SLA 承诺 —— 我们尽最大努力,以开源社区的方式运作。
在 AI 编码能力突飞猛进,以及决定不做新特性的前提下,我认为只是修复 BUG/漏洞的工作量是完全可控且可以接受的。
商标问题很难搞,但走一步算一步
商标声明:MinIO® 是 MinIO, Inc. 的注册商标。 本项目(pgsty/minio)为社区独立维护的 AGPL 开源 Fork, 与 MinIO, Inc. 无任何关联、从属或背书关系。 本文中对 “MinIO” 的使用仅用于指代该开源软件项目本身,不暗示任何商业关联。
AGPLv3 虽然允许我们合法 Fork 和分发,但商标法是另一个领域。 虽然我们已经在所有地方明确标注了这是一个独立的社区维护版本, 但 MinIO 公司可能会以商标侵权为由对我们提出异议,要求我们停止使用 “MinIO” 这个名字。
如果 MinIO 方面对商标使用提出异议,我们会配合更名。(大概会叫 silo, stow 之类的)
但在此之前,我们认为在 AGPL Fork 中描述性使用原项目名称是合理的,
毕竟我们也不想把所有的 minio 给重命名了 —— 这对用户没有任何帮助。
AI 改变了游戏规则
可能有人会问:一个人能维护得了吗?
2026 年了,情况和五年前不一样。AI 编码工具正在改变开源维护的经济学。
一个复杂 Go 项目的 bug 定位和修复,在 Claude Code 的辅助下,成本已经降低了不止一个数量级。 以前维护一个复杂基础设施项目需要一个专业团队,现在一个自带 AI 助理的老司机就够了。
你想,马斯克砍到 30 人的工程团队就能维护 X/推特 这种级别的系统。 维护个 MinIO 真没什么大不了的 —— 你只需要有测试验收能力就够了。
老冯行,老冯自己就上了。
Just Fork it!
MinIO 公司可以归档一个 GitHub 仓库,但它归档不了六万颗 Star 背后的需求, 归档不了十亿次 Docker Pull 背后的依赖。这些需求不会消失,它们只会寻找新的出口。
HashiCorp 的 Terraform 被 Fork 成了 OpenTofu,活得好好的。 MinIO 的情况其实更有利 —— AGPL 比 BSL 更友好,社区 Fork 没有任何法律风险。 公司可以抛弃项目,但开源协议的设计就不允许代码死掉。
git clone 是开源世界最强大的魔法。当一个公司决定关门的时候,社区只需要两个字:
Fork it.
参考阅读
1.4 - MinIO已死,谁能接盘?
前天 MinIO 宣告进入维护模式,老冯写了一篇《MinIO 已死》聊了聊这个话题。 很多朋友也问我,MinIO 既然摆烂躺平了,有谁能接 MinIO 的班?
大方向的话,台面上的替代品无非就那几个:Ceph、RustFS、SeaweedFS、Garage…… 老冯把这些方案都打好了 Linux 上的 RPM/DEB 包,挨个试了一遍。
总结一句话:没有完美替代。
各有各的问题 —— Ceph 功能全但太复杂;SeaweedFS 针对小文件优化但需要独立元数据库;Garage 小巧玲珑但功能简陋;RustFS 兼容 MinIO 但竟然还是 Alpha。
MinIO 替代品速览
MinIO 是 AWS S3 的开源替代,所以从单纯的 对象 CRUD 功能 上来讲,任何兼容 S3 API 的对象存储系统都可以作为 MinIO 的替代品。 但如果考虑到非功能类特性 —— 可靠性,可运维性,复杂度,工具链,生态成熟度,运维 SOP 这些,想要 “平替” 掉 MinIO 确实不容易。
这里我们不聊商业存储,云厂商的对象存储服务,只聊开源项目的话,大体上会有这些选择:
Ceph 可能是企业用户的最佳选择,但学习曲线陡峭,适合有专人运维的团队,不像 MinIO 一个二进制走天下。 很多用户并不需要分布式块存储和分布式文件系统的功能,而且运行还需要额外的 Podmon,不如 MinIO 爽利。
SeaweedFS 质量不错,针对海量小文件场景做优化,O(1) 磁盘寻址,小文件场景性能碾压。 但它需要一个独立的元数据库来存储文件元信息,这就带来了外部依赖。如果你需要一个"通用对象存储",它不是最佳答案。
Garage 是欧洲 Deuxfleurs 团队的作品,拿过欧盟 NGI 资助,适合自托管爱好者和边缘计算场景。 非常轻量(10MB),但 S3 兼容性太弱,没有版本控制,跨区域复制,IAM 这些,不适合企业场景。
RustFS 是唯一一个瞄准"MinIO 替代"生态位的项目,但成熟度不足 —— 竟然还是 Alpha。
RustFS 是否可以替代 MinIO
在所有号称"MinIO 开源替代"的项目中,老冯本来最看好 RustFS,所以特地花了些时间测试。 我在 Pigsty 中尝试将 MinIO 换成 RustFS,大部分逻辑可以复用,但还是有些区别:
- RustFS 对证书名称有特殊要求
- RustFS 的健康检查接口与 MinIO 有所不同
- RustFS 不支持 mc admin 管理命令,无法配置详细的 IAM 策略。这一点对企业用户而言比较重要。
总的来说,跑起来了,但老冯思考再三,还是把这个分支给放弃掉了。因为把 Alpha 版的软件用在生产环境实在是太不像话了。 我是比较期待 RustFS GA 版本出来之后,再来做一次评测。
RustFS 是否会重蹈 MinIO 覆辙
当然,RustFS 这个项目虽然看上去很有潜力,但也存在一些问题。例如,RustFS 是否会重蹈 MinIO 覆辙? 特别是,RustFS 在很多雷点上,跟 MinIO 十分相似。
老冯请 AI SOTA 三件套(GPT5-pro, Claude4-Opus, Gemini3-pro)对 RustFS 的风险进行了全面的分析评测。
其中 Gemini 对 RustFS 项目提出来了几项相当严重的指控,老冯又请 Claude 核实了一遍。
RustFS 这些风险信号与当年的 MinIO 几乎一模一样:Apache 2.0 + 版权转让型 CLA + 单一商业公司控制。 考虑到这些因素,老冯对 RustFS 的评级从 “乐观期待”,下调为 “谨慎观望”。
所以,应该怎么做?
老冯的 PostgreSQL 发行版 Pigsty 里面集成了 MinIO 作为对象存储的解决方案。 这完全是一个可选的模块,主要作用是 —— 存储 PostgreSQL 备份,以及在其他业务软件需要对象存储的时候提供一个 —— 比如自建 Supabase 。
考察了现有的生态替代品之后,老冯确实是不想再折腾换 MinIO 的事情了,也许会提供一个用 pgbackrest 自己的备份服务器替代掉 MinIO 的选项。
老冯觉得目前最优的方案,还是继续使用 MinIO 的最新版本,锁定版本,做好网络隔离。 等待半年左右,看看社区生态的发展再做打算。如果那时候 MinIO 有人接手,或者 RustFS GA 可用了,再做调整也不迟。
当然,RustFS 也可以抓住这个机会,抢占 MinIO 的生态位,并真的实现一个更好,更安全,协议更友善的 MinIO 版本。 机会不等人,老冯觉得这个窗口也就几个月时间,错过了就是错过了。
继续使用 MinIO 的注意事项
如果要继续使用 MinIO,有这么几个注意事项。第一是应该用什么版本。 虽然说,MinIO 20250422 版本是最后一个功能完整(带有控制台 GUI)的版本,但老冯还是建议使用最新的版本。
因为从 20250422 到现在(2025-12-08)这段期间,MinIO 有一个比较严重的 CVE 安全漏洞。
CVE-2025-62506: Privilege escalation via session policy bypass (HIGH)
这个漏洞允许低权限用户创建一个新的账户实现权限提升。不过如果是在内网环境中,并且做好网络隔离的话,这个漏洞的风险也是相对可控的。 这个漏洞已经在 MinIO 20251015 版本中修复了,但是 MinIO 很鸡贼的从这个版本开始移除了二进制,只提供源代码。
不过老冯觉得还好,因为 MinIO 是一个 Go 语言项目,编译就一条命令,跨平台编译 goreleaser 一把梭也很简单。 流程我已经跑通了,其实很简单,老冯就直接 Fork 了 MinIO :然后用 MinIO 自己的打包器做了 2025-12-03 的 RPM/DEB 包,起码不会带病上岗。 把 MinIO 重新从一个 “源码发行版” 恢复成一个二进制发行版:https://github.com/pgsty/minio

不过,安全漏洞和BUG还是得有人来修的,MinIO 自己说还是会看情况修安全漏洞。 老冯觉得,社区里如果有人愿意接手 MinIO 的话,现在还真是一个非常好的机会。 从 20250422 版本作为基础,CherryPick 重要的 Bug 和安全修复的话,然后开始维护一个 MinIO 社区版本。
说到底,MinIO 经过这么长时间的社区打磨,已经是一个相当成熟稳定的对象存储系统了 —— 基本上算是一个 “已完成的软件”。 需要的不是更新跟进S3各种花里胡哨的新功能(S3 Vector/S3 Table),而是扎扎实实的修 Bug 和安全漏洞。
这种维护状态的软件,搞一个 LTS,社区自发维护起来并不难。如果 MinIO 团队不愿意继续维护,老冯觉得社区里会自发涌现出接棒的人选 —— 毕竟现在有很多商业存储硬件公司都在用 MinIO。 比起自己从零瞎搞一个对象存储,接手一个成熟的项目,反而是更省事的选择。
题外话与更新(2026-02-14),MinIO 官方仓库已经彻底归档并不再维护。 我创建了一个 Fork:
pgsty/minio/ 文档 https://silo.pgsty.com/zh/。 搭建了 CI/CD 提供 RPM/DEB 二进制包与 Docker 镜像,基于最后的上游版本 2025-12-03 构建,恢复了 2025-05 阉割的控制台能力。
1.5 - MinIO已死
2025年12月3日,是个值得在开源软件历史上记一笔的日子。 MinIO 官方在 GitHub 上更新项目状态,宣布 MinIO 开源项目进入“维护模式” 。 这基本上宣告了 MinIO 作为一个开源项目的死亡。
MinIO 这家公司,终于完成了从“屠龙少年”到“恶龙”的华丽转身。

从屠龙勇者到新的恶龙
民主化时代(2014–2019):对象存储的 Apache
MinIO 成立于2014年,其创始愿景极具理想主义色彩——做“对象存储领域的 Apache”。在那个 AWS S3 统治云存储的年代,MinIO 以其极致的轻量化(单个静态二进制文件)和对 S3 API 的 100% 兼容性,迅速赢得了开发者的青睐。
在这一阶段,MinIO 采用宽松的 Apache 2.0 许可证,鼓励开发者将其集成到各种应用中。其核心价值主张是“让任何硬件都能变成 AWS S3”。这种开放策略极其成功,MinIO 官方宣称其 Docker 镜像下载量超过10亿次,成为全球部署最广泛的对象存储服务 。此时的 MinIO 是云原生技术栈的宠儿,是 Kubernetes 环境中标配的存储后端。
许可证武器化(2019-2025):AGPL 攻防战
社区关系的第一次重大裂痕出现在2019年至2021年间。MinIO 宣布将其核心许可证从 Apache 2.0 变更为 GNU AGPLv3 。
虽然官方解释称此举是为了防止云厂商(如 AWS、Azure)“白嫖”代码并将其包装为专有服务——这是开源界常见的防御性手段。 这一时期,MinIO 从社区的守护者转变为激进的知识产权捍卫者。 2022 年, MinIO 公开指责 Nutanix Objects 产品侵犯其许可证,撤销了Nutanix 的使用授权;2023 年, MinIO 以类似理由起诉高性能文件系统厂商 Weka。 这些法律行动虽然在法理上具有争议,但释放了一个明确的信号:MinIO 不再欢迎未经付费的商业集成。这为2025年的全面封锁奠定了法律和心理基础。
阉割控制平面(2025-05)
2025年5月,当时 MinIO 决定从社区版代码中移除 MinIO Console——这是一个集成了存储桶管理、身份与访问管理(IAM)、监控和日志审计的关键图形用户界面(GUI)。 此次剥离后,开源版 MinIO 仅剩下一个基础的“对象浏览器”,仅具备查看和下载文件的能力。
而身份策略管理,站点复制配置,生命周期管理等核心运维功能被完全移至商业企业版。 这一变更将社区版 MinIO 从一个功能完备的存储管理系统降级为一个单纯的数据平面组件,剥夺了其作为独立产品在生产环境中使用的控制平面能力
中断二进制分发(2025-10)
2025年10月15日,当时,正值一个关键安全漏洞(CVE-2025-10-15T17-29-55Z / GHSA-jjjj-jwhf-8rgr)被披露之际,MinIO 停止了向 Docker Hub 和 Quay.io 发布更新的 Docker 镜像 。 这一时间点的选择具有极高的战略意味。在重大安全漏洞爆发期间切断二进制分发,实际上是将安全性变成了一种“勒索”筹码。
这一决策直接切断了绝大多数企业级用户的自动化部署链路,使得依赖 docker pull minio/minio 或 helm install 的标准 CI/CD 流程瞬间失效。
对于那些缺乏 Go 语言编译环境或内部容器镜像仓库维护能力的团队而言,这实际上等同于不可用。
维护模式(2025-12)
2025年12月3日,MinIO, Inc. 在其官方渠道及 GitHub 仓库中正式更新了项目状态,宣布 MinIO 开源项目进入“维护模式”。 README 上写到:以后不再提供功能更新改进,不再审Issue合 PR,重大安全问题看情况。 不再提供 RPM/DEB 包与 Docker 镜像,不再加新功能,需要维护的企业用户请切换到商业版本 AIStor 上。

技术影响:对开源生态的破坏
MinIO 进入维护模式对现有技术栈造成了即时且深远的破坏。
CI/CD 管道的断裂与自动化危机
成千上万的 Helm Charts、Ansible Playbooks 和 Terraform 脚本依赖于 minio/minio 或 bitnami/minio 镜像。
随着官方停止发布镜像,Bitnami 等第三方打包商也因无法获取上游稳定代码而被迫停止更新 。
- 连锁反应: 新环境的部署将直接失败;自动扩缩容组(Auto-scaling groups)在拉取新节点时会因找不到镜像而挂起。
- 修复成本: 企业必须重写所有的部署脚本,指向私有镜像仓库,并建立内部的构建流程来从源码编译 MinIO。
安全真空:CVE 管理的私有化
停止发布二进制文件最致命的后果是安全补丁的滞后。以2025年10月的漏洞为例,MinIO 实际上扣留了二进制补丁 。
- 风险暴露: 缺乏专门安全团队的中小企业将被迫继续运行含有高危漏洞的旧版本。
- 合规噩梦: 对于受 PCI-DSS、HIPAA 或 SOC2 监管的企业,无法获得供应商签名的安全更新意味着合规性失效。
运维复杂度的指数级上升
UI 的移除不仅是用户体验的倒退,更是运维成本的增加。
过去只需在 Console 中点击几下即可完成的存储桶策略配置、用户权限分配,现在需要运维人员熟练掌握 mc 命令行工具或编写复杂的 JSON 策略文件。
这无形中提高了使用门槛,使得 MinIO 不再适合作为轻量级的内部工具使用。
背后的原因:资本与商业化的压力
MinIO 的技术决策根本动力来自于资本市场的估值逻辑。截至2025年,MinIO 已累计融资1.26亿美元。 其中最具决定性的是2022年1月完成的1.03亿美元 B 轮融资,由英特尔资本(Intel Capital)、软银愿景基金2期和 General Catalyst 领投。 这轮融资将 MinIO 推上了10亿美元估值的“独角兽”宝座。
在风投逻辑中,10亿美元的估值意味着公司必须展现出通往IPO的明确路径,通常要求年经常性收入(ARR)达到1亿美元以上,并保持高速增长。 2025年2月,MinIO 宣布其 ARR 在过去两年增长了149% 。虽然增速可观,但要支撑如此高的估值,仅靠自然转化已不足够。
停止开源支持,是将庞大的用户基数强制转化为付费客户的最直接手段。
2025年,MinIO 进行了全面的品牌重塑,推出了“MinIO AIStor”,将自己定位为“企业 AI 的数据基石”。 公司管理层意识到,通用对象存储(用于备份、网盘)的市场已是一片红海,且利润微薄;而生成式 AI(Generative AI)对高性能数据吞吐的需求(Exascale AI)才是下一个增长爆发点。 通过优化 AI 工作负载并专注于服务财富500强企业 ,MinIO 实际上决定剥离低价值的开源用户群体。 维护模式的开启,标志着 MinIO 正式从一个广泛的开源项目转型为一家服务于高端 AI 客户的垂直软件供应商
MinIO 已经不是几个极客在车库里写的玩具了,它是一家融资了 1.26 亿美元、估值超过 10 亿美金 的商业公司。 它的背后站着 Intel Capital,站着 软银愿景基金。 当你拿了风投那么多钱,你的老板就不是用户,而是投资人。 投资人要的是什么?是 ARR(年度经常性收入),是 增长率,是 IPO。 你跟投资人说:“我有10亿次 Docker 下载量!” 投资人会问:“这10亿次下载,给你付了一毛钱吗?”
现实就是这么残酷。那帮用免费版 MinIO 的中小企业、个人开发者,在资本眼里就是 低价值资产。 你们提 Issue 报 Bug,群里问东问西,消耗的是昂贵的工程师工时,带宽和服务器资源,而你们 永远不会转化为付费客户。 MinIO 的管理层很清楚,他们的真正金主是那些搞 AI 大模型 的 500 强企业。 那些训练 GPT、跑自动驾驶数据的公司,需要的是 AIStor,是极致的性能,是 7x24 小时的 SLA 。
所以,开启“维护模式”,本质上是一次 资产剥离。MinIO 决定切掉这块“坏肉”(免费用户),把所有资源集中到能产奶的“金牛”(企业级 AI 客户)身上。 从商业策略上讲,这叫 聚焦。 从对投资人的交代上讲,这叫 负责。 只是从开源上来说,这叫 缺德。
老冯的感想
老冯大概从 2018 年开始使用 MinIO ,当时还是 Apache 许可证,我们搞了几个几 PB 的对象存储,用来存放视频,图片,备份 —— 可能是那时候国内最大规模的部署实践。 老冯也编写了 MinIO 部署监控,扩缩容的 Playbook ,算是做过一些贡献 —— 现在还能在 Pigsty 中开源提供。

作为开源创业者,老冯不是不能理解这种改变的动机。 但是站在开源贡献者与用户的立场 —— 老冯也知道很多兄弟现在心里只有一句话:“我从未见过如此厚颜无耻之人。”
开源协议虽然不是卖身契,但它是一种 社会契约。 开发者贡献代码、用户贡献测试场景和口碑,大家一起把项目捧红。 MinIO 享受了十年的社区红利,靠着“全球下载量第一”的虚荣指标拿到了融资, 转头就对这就帮把它捧上去的用户说:“你们是搭便车的,滚蛋。” 这种行为破坏了开源社区最底层的信任。
这种“养套杀”的手段,比币圈的 Rug Pull 还要恶心。币圈割的是钱,MinIO 割的是全球数万家企业的技术栈 —— 用户的选择其实不只是一个二进制,而是一个软件生态和设计哲学。等大家都上车了,把迁移成本堆高到无法承受,然后突然抽走梯子。 这种模式,开源专家 Tison 在《诱导转向的伪开源战略》已经聊的很透聊。
诱导转向的核心问题在于 欺骗,既然 MinIO 背叛了社区,社区也会抛弃它。Garage、SeaweedFS 甚至 RustFS,替代品有很多。江湖路远,后会无期。 如果要说老冯的感想是什么,那么就借用《银河系漫游指南》里海豚临走时说的那句话吧:
—— “So long, and thanks for all the fish.” —— 再见,多谢你们的鱼了。
题外话与更新(2026-02-14),MinIO 官方仓库已经彻底归档并不再维护。 我创建了一个 Fork:
pgsty/minio/ 文档 https://silo.pgsty.com/zh/。 搭建了 CI/CD 提供 RPM/DEB 二进制包与 Docker 镜像,基于最后的上游版本 2025-12-03 构建,恢复了 2025-05 阉割的控制台能力。
2 - 发布注记
发布日志记录各版本的功能演进、安全修复、依赖更新、兼容性要求与交付制品。
最新的 20261026 联合发布 汇总了 SILO Server、mcli 与 KES 自 20261018 正式版以来的全部更新,并配套发布 Console 2.5.1 与 silo-pkg 3.15.2(10 月 24—25 日的阶段性维护记录已并入正式发布日志,原链接自动重定向至新页面)。
2.1 - SILO 20261026:Server、mcli 与 KES 联合发布
SILO、mcli 与 PGSTY KES 20261026 现已联合发布,配套 SILO Console 2.5.1 与 silo-pkg 3.15.2。本文整合了 10 月 24—25 日的维护记录,完整说明自 20261018 正式版以来的所有变更。三个可执行组件采用统一的发布日期与包版本号;KES 仍作为可选的外部密钥服务独立部署。
发布组件
| 组件 | 版本 | 源码 | 交付形式 |
|---|---|---|---|
| SILO Server | 20261026000000.0.0 |
c3ad836aed0d |
平台归档、Linux 软件包、Docker Hub 镜像 |
| mcli | 20261026000000.0.0 |
0ceb298cdcd0 |
平台归档、Linux 软件包、Docker Hub 镜像 |
| PGSTY KES | 20261026000000.0.0 |
e9b4f1acebc1 |
平台归档、Linux 软件包、Docker Hub 镜像 |
| SILO Console | 2.5.1 |
fff1618d9abb |
Server 内嵌;独立二进制、软件包与镜像 |
| silo-pkg | 3.15.2 |
0416eb1f4505 |
Go 源码模块 |
五个项目统一使用 Go 1.27.3 构建。Server、Console 与 mcli 选用 silo-pkg v3.15.2 以及固定的上游提交 minio-go v7.4.2-0.20261025165926-320af050c445(伪版本,上游尚未打 v7.4.2 标签)。Server 显式集成已发布的 Console 与 mcli 源码;单独升级独立部署的 Console 不会替换旧版 Server 内嵌的控制台界面。详见组件矩阵与下载页。
Server:自 20261018 以来的修改
- LDAP 与 IAM(#250): 修复规范化 LDAP DN 过长导致无法登录的问题。当合法 IAM 键的单个 ASCII 路径分量超过 255 字节时,可选 IAM 记录读取将其视为“记录不存在”并继续按组策略登录;但无法读取的吊销记录与底层存储故障仍严格按 Fail-Closed 拒绝,且对象后端仍不支持直接写入超长路径记录。此外,策略重载在访问底层存储期间不再持有认证缓存锁,仅在完整加载成功后原子替换缓存并保持 IAM 写入顺序;请求取消信号会传递至 KMS 与 SSE-S3 解密路径,同时不缩短调用方的策略查询超时。
- 内置策略 Reset 与实时桶清单: 对内置策略执行 Reset 后,首次读取即可立即恢复原生策略定义;延迟到达的 peer/etcd 通知会重新加载已提交状态,不会覆盖后续新修改。策略在用检查、删除修订号与自定义策略删除行为保持不变。Console 的
account-info接口改为读取实时存储桶清单,创建或删除桶后刷新页面即可看到最新结果(对象用量统计仍由后台扫描器异步更新)。 - 多池删除保护(#257、#256): 普通删除与批量删除改为先清理非权威池中的旧副本,再删除权威池副本,从而在部分失败时保住最新权威副本;同时为新建删除标记选择可用存储池,并保护仍被其他存活副本引用的远端分层对象。
- 版本桶生命周期过期(#253、#252): 当生命周期过期规则只是追加删除标记时,完整保留上一版本的远端分层数据(包括暂停版本控制桶中的非
null版本);只有当历史版本被真正永久清除、且没有任何存活引用依赖其底层载荷时,才回收远端分层数据。 - 分片上传复制(#255、#254): 为复合 CRC 分片对象复制透传
FULL_OBJECT校验和类型,避免目标站点因按分片复合模式重算校验和而返回BadDigest。 - 存储 RPC 与超时语义: 转发存储 RPC 删除选项(包括覆盖写回滚所需的旧数据目录),并保持既有的磁盘/RPC 超时、请求取消、对象锁、读仲裁与自愈策略。正式版在
dcc2f20ba中撤回了 20261025 草稿里的已接纳 I/O 排空等待与逐对象变更锁方案(含 #258),详见多池对象一致性设计记录。 - 依赖、打包与交付: 更新 SFTP、etcd、NATS 及云厂商 SDK 等 Go 依赖;保留 Kafka 2.1 默认协议版本、CLI 输出格式与 NetBSD 兼容版本;修复旧版 DARE v1 解封逻辑(新写入继续使用 v2)。容器镜像、校验安装脚本与 Helm 客户端任务统一内置 mcli 20261026。Windows 归档改用 ZIP 格式,静态构建
curl升级至8.23.0并附带下文的证书信任配置说明;同时修复了最终刷新 SBOM 与构建溯源时的 RPM 签名校验。 - 文档与迁移: 合入单节点 MinIO 迁移指南(#228),校准运维默认参数说明,并将升级流程统一收敛至迁移指南。
本版完整保留 20261018 已交付的 Object Lock、IAM 授权、持久 STS 吊销、跨池迁移与 LIST 恢复修复。文末的依赖安全清单将本轮新合入的修复、历史修复与未使用代码中的扫描告警分别列出。
mcli:自 20261018 以来的修改
- 客户端身份缓存隔离(#50、#55): S3 与管理客户端缓存按凭据、证书信任、代理、DNS 解析器及自定义请求头隔离,同时保留凭据自动刷新与 Web Identity token 文件轮换。
- 复制 Resync 状态与速率计算(#48、#49、#52):
admin replicate resync status --json会持续消费状态流直至到达Completed、Failed或Canceled终态,完整保留已完成任务的状态输出;当桶未启用复制时明确输出结果,TTY 模式在遇到Failed或Canceled时立即结束等待;同时修复了累计传输超过约 8.6 GiB 后吞吐量计算的整数溢出。 - 诚实的退出码(#51):
resync start与resync cancel在部分桶操作失败时保留逐桶错误详情并返回非零退出码;用resync status成功查询到一个已处于Failed或Canceled状态的任务仍正常退出0。admin info --json在请求失败时同样返回非零退出码,并保留 JSON 错误输出。 - 批处理状态观察(#53、#54):
batch status在实时指标暂缺时继续观察并输出新增的pending状态(不再把排队中或缺样本的任务误判为已完成),在指标流中断时通过带超时边界的持久化状态查询兜底,并修复了超大对象计数下的 IOPs 整数溢出。 - 别名区域配置(#44): 支持按
MC_REGION、AWS_REGION、持久别名配置中的region字段、自动发现的优先级解析区域,并在客户端缓存中隔离签名区域(未新增alias set --region命令行参数)。 - 底层 SDK 修复: 拒绝被截短的顺序或并行分片读取流;修复
ReadAt的 EOF 处理、负偏移校验及Stat之后的读取/Seek 边界;对批量删除回退路径中的重复错误去重;移除桶复制配置时同时接受 HTTP200与204(403等真实鉴权或请求错误仍正常报错)。 - 打包与帮助信息(#45): 升级至 Go 1.27.3 与 GoReleaser 2.19.0,Windows 归档改用 ZIP 格式;更新命令行帮助链接以及已停用的服务端自更新/SUBNET 命令提示,保留全部既有 CLI 配置与
MC_*环境变量兼容。
自动化脚本请适配 batch status 新增的 pending 状态,以及 resync start/cancel 部分失败时的非零退出码。本次客户端升级无需变更服务端协议。下载流中途断开仍会立即报错;分片上传被 SIGINT 中断时以退出码 130 退出,未完成的分片可能需显式清理。详见完整客户端提交历史。
KES:自 20261018 以来的修改
KES 20261026 汇总了 Go 1.27.3 与云厂商 SDK 依赖维护,将二进制、Linux 软件包与容器镜像统一对齐到 20261026 批次。HTTP API、配置文件格式与后端存储密钥格式保持完全兼容。
| 依赖 | 20261018 | 20261026 |
|---|---|---|
| Go | 1.27.2 | 1.27.3 |
| AWS SDK v2 core | 1.47.3 | 1.47.4 |
| AWS config / credentials | 1.33.9 / 1.20.9 | 1.33.10 / 1.20.10 |
| AWS Secrets Manager | 1.50.4 | 1.50.6 |
| Google Secret Manager | 1.22.0 | 1.23.0 |
| Google API / IAM | 0.304.0 / 1.14.0 | 0.308.0 / 1.15.0 |
| Google certificate proxy / GAX | 0.3.23 / 2.26.2 | 0.3.24 / 2.27.0 |
| go-rootcerts / procfs | 1.0.2 / 0.22.0 | 1.1.0 / 0.23.0 |
全套 25 项模块更新还包含 AWS 配套模块与 Google 生成的 API 库;终端输出与 YAML 解析继续固定在 Lip Gloss v1 与 gopkg.in/yaml.v3。构建工具升级至 GoReleaser 2.19.0(nFPM 保持 2.48.0);Linux 软件包仅安装 /usr/local/bin/kes 可执行文件,不会自动启动未配置的服务。
兼容性回归测试覆盖了 OpenBao KV v2 与本地 Moto Secrets Manager 协议夹具,验证了升级前后写入的加密对象读取、版本回滚以及 Server 冷重启解密。生产环境升级时请保留持久化根密钥、证书与配置,并在重启 Server 后实际执行读取解密验证。详见 KES 完整提交历史。
Console 2.5 与 silo-pkg 3.15
Console 2.5.1 包含了 2.5 系列的整体界面重构、按权限裁剪导航、Overview 集群概览、对象预览、Tasks 任务中心、身份管理以及高延迟网络下的诊断流修复;2.5.1 补丁版进一步修复了启停用户组时保留既有成员、OIDC 字符串数组组权限、反向代理后的安全会话 Cookie、准确的凭据 JSON 导出、流式 ZIP 错误状态码以及键盘焦点与无障碍标签。Server 20261026 已直接内嵌此版本。
silo-pkg 3.15.2 汇总了 3.15.0 的 Go 1.27.3 与依赖升级、3.15.1 的上游 SDK 修复,以及 3.15.2 移除复制配置时的 HTTP 204 状态码兼容,同时完整保留既有策略语义与 3.14.3 的策略匹配性能优化。silo-pkg 仅作为 Go 源码模块发布,嵌入方需重新编译以纳入更新。
升级与故障边界
- 协调升级: 升级前请备份 IAM 状态、配置文件与加密密钥,并按 IAM 升级指南同步升级集群内的所有 Server 节点与跨站复制站点。不支持新旧版本进程长期共用同一 IAM 后端或执行滚动降级。
- 升级后核验: 逐一验证 LDAP/OIDC 登录、内置策略 Reset、存储桶清单刷新、多版本读取、跨站复制与加密对象解密。分片对象
FULL_OBJECT校验和复制修复需要先升级发送端 Server,再检查待复制队列。 - 归档格式与脚本适配: Windows 平台归档已改为
.zip(Linux 与 macOS 保持.tar.gz);自动化脚本请适配mcli batch status的pending状态与resync start/cancel部分失败时的非零退出码。既有环境变量、配置文件名与磁盘存储格式保持不变。 - 已知功能边界: 公开的
ListMultipartUploads仍使用节点本地内存缓存与精确对象键查找,已退役的strict开关会被忽略并打印警告;#79 与 #217 尚未关闭,Webhook 热重载 PR #249 未纳入本版,更大范围的性能问题仍在 #251 中跟踪。
存储超时与一致性边界: 客户端收到请求超时并不代表底层磁盘或 RPC I/O 已经停止,也不代表该次写入一定没有提交。在部分磁盘故障、锁租约过期或网络分区下,仍可能出现迟到提交、延迟回滚或短暂读到旧值与旧标签。本版未引入分布式锁 fencing,也不会自动恢复升级前已经损坏或丢失的对象、远端分层数据与元数据。
下载与验证
各平台的二进制文件名、SHA-256 校验和、Linux 软件包与验证说明见下载页面。版本化容器镜像可直接拉取:
- Server 标准镜像(Linux
amd64/arm64多架构 manifest):sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7(与latest一致) - Server distroless 镜像(Linux
amd64/arm64多架构 manifest):sha256:e6544a902f426f2b4e775b89f3c1cd78310dbffc262d1ed82e9800c1cb1b5885(与distroless标签一致,两个架构均保留silo healthcheck ready原生健康检查,见 Server 镜像构建流水线)
各组件的发布制品均通过固定源码提交、归档校验和、软件包签名与构建溯源(Provenance)独立标识:
- Server 制品验收: 核验了对应源码
c3ad836aed0d的 36 项发布资产与 34 项构建溯源证明(含 6 个平台归档与 6 个 Linux 软件包),两个架构的 RPM 包均已完成 GPG 签名,校验清单、SBOM 与溯源证明同最终软件包完全一致(见 Go CI、VulnCheck、Release 与 Finalize 流水线)。 - 本地多节点与故障注入验收: 在 Linux
arm64环境下完成了基线四节点 88 项、升级后四节点 88 项与八节点多池 136 项 S3 兼容性检查;历史对象、标签、加密对象及跨升级周期的分片上传均完整保留。在滚动重启、SIGKILL强杀与 75 秒进程挂起恢复测试中,408 个已确认写入的对象全部完整读回,哈希与列表校验全部通过;原生可信 TLS 下的mcli测试套件与 16 项持久化检查同样全部通过。注意集群健康检查端点仅反映节点上报的磁盘仲裁状态,在滚动重启或升级后恢复流量前,请按启动就绪窗口指南验证实际数据面读写。 - KES 制品与集成验收: 核验了 19 项公开资产、11 项构建溯源证明及两个签名 RPM 包。使用实际构建的 Darwin
arm64KES 二进制,先后搭配 SILO 20261018 与最终版 Server/mcli 20261026 Linuxarm64二进制完成了 OpenBao KV v2 与 Moto 升级、回滚及 Server 冷重启测试。两个架构的 KES 容器镜像内置二进制与发布归档一致,多架构镜像摘要为sha256:ea36fcdd260c606eb89e693b0949aea73b149359605273a98fcf13c8433049cd(见 KES 制品流水线与镜像流水线)。
贡献者与完整历史
感谢 Denis Babchuk / @botdeniska 贡献生命周期、复制与多池删除修复,@910patin 修复长 DN LDAP 登录,Azat / @azataiot 定位并复现高延迟健康报告截断问题,@MikeVL 报告 resync 速率溢出与 JSON 状态输出问题,@mrjavadseydi 贡献别名区域配置支持并提出 Webhook 热重载方案,Gerald Combs / @geraldcombs 提出 Windows ZIP 归档建议,@yupoet 贡献单节点迁移指南,以及 @GhaziTriki、@devTeam-AiReg 与 @sargarass 报告的问题与反馈。
下表汇总了本发布周期内五个组件仓库中合并、采纳或跟踪的社区 Issue 与 PR:
| 项目 | 贡献者 | Issue / PR 记录 | 说明 |
|---|---|---|---|
| SILO | @botdeniska | #252、#253;#254、#255;#256、#257;#245、#248 | 修复分层对象生命周期过期、分片 FULL_OBJECT 复制校验和与多池删除安全性;#248 的跨池迁移修复已由 60c7179dd 采纳并保留原作者署名。 |
| SILO | @910patin | #250 | 修复长 DN LDAP 用户登录失败问题。 |
| SILO | @yupoet | #228 | 贡献经过生产验证的单节点迁移指南。 |
| SILO | @mrjavadseydi | #249 | Webhook 热重载提案(评审中,未合入本版)。 |
| SILO | @sargarass | #79 | 分片上传列表语义反馈(待后续版本处理)。 |
| SILO | @GhaziTriki | #251 | 性能问题反馈(排查跟踪中)。 |
| SILO | @devTeam-AiReg | #247 | 发行版可用性反馈。 |
| SILO | @Vonng | #217、#258 | 跨站复制边界说明(#217)与候选锁隔离实验(#258,最终版本已回退该变更)。 |
| mcli | @mrjavadseydi | #44 | 支持在 alias 配置中持久化自定义 S3 区域。 |
| mcli | @MikeVL | #48、#49 | 报告复制 resync 速率溢出与 --json 空输出问题(已在本版修复)。 |
| mcli | @geraldcombs | #45 | 提出 Windows 发行归档采用 .zip 打包的建议(已采纳)。 |
| mcli | @Vonng | #50–#55 | 客户端身份切换隔离与复制 resync 状态修复。 |
| Console | @azataiot | #63 | 报告并协助复现高延迟健康报告截断问题(已在 Console 2.5.1 修复)。 |
| Console | @Vonng | #64–#69 | Console 2.5.1 的依赖升级、界面改进、端到端验收与发版维护。 |
| silo-pkg | @botdeniska | #11 | 策略求值快速路径优化。 |
| KES | @Vonng | 20261018–20261026 发布周期维护 | 依赖刷新、打包构建与 OpenBao / Moto 兼容性验收。 |
社区贡献者名册收录了所有参与过 Issue 或 PR 的社区成员。各仓库的完整提交对比:Server、mcli、KES、Console、pkg。
以下为 10 月 22—25 日的依赖与安全审计记录(多个组件共用的依赖按对应软件包合并列出):
10 月 24 日追加的 Go 依赖更新
Go 工具链维持 1.27.3。相对 10 月 22 日的候选依赖,本轮纳入 26 个新发布的第三方模块版本,并同步升级 Arrow 与 codescan,共更新 28 个模块:
| 依赖 | 本轮版本变化 | 涉及组件 |
|---|---|---|
| pkg/sftp | 1.13.11 → 1.13.12 | SILO |
| etcd 的 api/v3、client/pkg/v3、client/v3 | 3.7.2 → 3.7.3 | silo-pkg、mcli、Console、SILO |
| JWX | 3.3.0 → 3.4.0 | silo-pkg、mcli、Console、SILO |
| nats.go | 1.54.0 → 1.55.0 | SILO |
| procfs | 0.22.0 → 0.23.0 | mcli、Console、SILO、KES |
| AWS SDK v2,共 14 个模块 | core 1.47.3 → 1.47.4;config 1.33.9 → 1.33.10;credentials 1.20.9 → 1.20.10;Secrets Manager 1.50.5 → 1.50.6,以及对应配套模块 | KES |
| GCP IAM / Monitoring / Secret Manager / Google API | IAM 1.14.0 → 1.15.0;Monitoring 1.31.0 → 1.32.0;Secret Manager 1.22.0 → 1.23.0;Google API 0.307.0 → 0.308.0 | IAM 与 Google API:SILO、KES;Monitoring:SILO;Secret Manager:KES |
| go-rootcerts | 1.0.2 → 1.1.0 | KES |
| Arrow | 18.7.0 → 18.8.0 | SILO 的 Azure SDK 间接依赖 |
| codescan | 0.36.4 → 0.36.6 | Console 生成工具链 |
本轮更新保持既有公共 API 与输出格式不变;各组件最终锁定的模块版本见上文组件清单。
已纳入的运行时安全修复
Go 与证书信任
CVE-2026-111943 / GO-2026-6809:Go 1.27.3 在 crypto/x509 证书链校验中直接拒绝已知失信的中间证书,在操作系统信任库尚未更新时也能提供兜底拦截。五个主项目及全部独立调试工具均统一使用 Go 1.27.3 编译。
此外,SILO、mcli 与 KES 的 UBI 容器信任库已应用 Red Hat RHSB-2026-012 的根证书封禁列表。由于根级封禁会一并拒绝挂靠在这些根证书之下的所有证书链,其拦截范围比 Go 1.27.3 的中间证书黑名单更广;若外部服务仍使用受影响根证书签发的证书链,需先行更换证书。发行版 CA 软件包的跟进状态见下文本地缓解。
SFTP 依赖安全加固
pkg/sftp 1.13.12 修复了 10 月 23 日公布的三项安全问题。截至本次核对,它们均没有 CVE 编号,以下按 GHSA 单独记录,不计作新增 CVE:
| 公告 | 上游问题 | SILO 适用性 |
|---|---|---|
| GHSA-xgcg-wgmj-5rw3,Medium | Client.RemoveAll 跟随顶层符号链接,可能删除目标目录内容 |
SILO 服务端未调用该客户端 API |
| GHSA-47hq-m6q9-3rm6,Medium | sftp.Server 的 ReadOnly 模式允许创建硬链接 |
SILO 使用 NewRequestServer 与自有 driver,未使用该只读 Server API |
| GHSA-h959-g3h6-w76c,High | ReadOnly 模式允许截断或新建文件 | 同上 |
上游后两条公告的受影响版本区间(affected range)与修复版本字段存在矛盾,本次直接升级至包含实际代码修复的 1.13.12 并通过回归测试验证(当前 Go 漏洞库快照停留在 10 月 21 日,尚未收录这三条 10 月 23 日发布的公告)。
静态 curl 及其链接库
SILO 容器镜像内置的静态 curl 从 8.22.0 升级至 8.23.0(启用 HTTP/2、SSH 与 Brotli,TLS 后端采用 OpenSSL)。下表完整列出 curl 8.22.0 官方漏洞汇总中的 21 个 CVE 及其在当前静态构建中的适用情况:
| CVE | 问题 / 适用性 |
|---|---|
| CVE-2026-92392 | HTTP/2 上传释放后使用(UAF) |
| CVE-2026-107929, CVE-2026-94266, CVE-2026-94265, CVE-2026-86413, CVE-2026-86414, CVE-2026-86415, CVE-2026-87861 | 连接复用、凭据或认证边界处理缺陷 |
| CVE-2026-92386, CVE-2026-86412, CVE-2026-87862, CVE-2026-87863 | Cookie 与 HSTS 状态污染 |
| CVE-2026-92387, CVE-2026-92388, CVE-2026-92389, CVE-2026-92390, CVE-2026-92391 | 循环处理、FTP/TFTP、下载文件名及 MIME 递归解析 |
| CVE-2026-86410, CVE-2026-87865 | SSH 主机校验及 known_hosts 回调 |
| CVE-2026-107928, CVE-2026-87866 | 仅影响 GnuTLS / wolfSSL 后端;当前 OpenSSL 构建不使用这两类后端 |
静态链接的 Alpine 底层库同步升级至以下回补版本(-rN 代表含安全回补的 Alpine 修订号):
| 库 | 本次版本变化 | CVE |
|---|---|---|
| Brotli | 1.2.0-r1 → 1.2.1-r0 | CVE-2026-111497 |
| musl | 1.2.6-r2 → 1.2.6-r3 | CVE-2026-107891 |
| libssh2 | 1.11.1-r3 → 1.11.1-r4 | CVE-2025-15661, CVE-2026-58050, CVE-2026-58051, CVE-2026-66032, CVE-2026-66033, CVE-2026-66034, CVE-2026-66035 |
此前发布的 SILO arm64 镜像静态库清单已包含 OpenSSL 3.5.9-r0、nghttp2 1.70.0-r0(CVE-2026-58055)与 zlib 1.3.2-r1(CVE-2026-85091);本次在构建脚本中显式锁定上述最低安全版本并完成双架构验证。Alpine 修复修订详见官方安全元数据。
构建与开发工具安全修复
本节记录 SILO Console 前端资源构建、代码生成与测试环境(生产环境运行的是编译后的 Go 服务端二进制),以及仓库内独立的 Go 调试模块。
Node 24.22.1
构建环境的 Node.js 从 24.22.0 升级至 24.22.1。下表列出 Node.js 2026 年 10 月安全公告中适用于 Node 24.x 的 8 个 CVE(不含仅影响 Node 26 的条目):
| CVE | 修复内容 |
|---|---|
| CVE-2026-105482 | DNS 名称中的 NUL 字节截断 |
| CVE-2026-105483, CVE-2026-106069, CVE-2026-106072 | 非规范 IP 地址导致 TLS 主机名校验绕过 |
| CVE-2026-111497 | Brotli 解码越界写(Node 已回补修复,内置版本字符串仍显示 1.2.0) |
| CVE-2026-105481 | HTTP keep-alive 接受请求前的响应数据 |
| CVE-2026-106074 | 启动快照重复使用随机数缓存 |
| CVE-2026-106073 | fs.realpathSync 权限模型绕过 |
Node 24.22.1 内置的 Undici 从 7.29.1 升至 7.30.1,包含以下 8 项修复(详见 Undici v7.30.1 发布说明):
| CVE | 修复内容 |
|---|---|
| CVE-2026-87018, CVE-2026-109383 | TLS 重协商崩溃、Agent 丢失自定义验证回调 |
| CVE-2026-109829, CVE-2026-109841 | 跨源缓存污染 / 数据泄露及 SRI 校验绕过 |
| CVE-2026-109869, CVE-2026-87029, CVE-2026-109924, CVE-2026-109932 | 内容解码、EventSource 和 WebSocket 的拒绝服务 |
Node 24.22.1 内置的 OpenSSL 从 3.5.8 升至 3.5.9(旧 Node 24.22.0 仍停留在 3.5.8),涵盖 DTLS、QUIC、CRL、CMP、EC / SM2 与握手内存安全方面的 13 项修复:CVE-2026-84782, CVE-2026-35189, CVE-2026-35191, CVE-2026-42772, CVE-2026-54872, CVE-2026-54873, CVE-2026-54875, CVE-2026-72897, CVE-2026-75804, CVE-2026-75805, CVE-2026-75806, CVE-2026-77696, CVE-2026-84784。
Node 24.22.1 内置的 c-ares 从 1.34.8 升至 1.34.9,修复了 11 条尚未分配独立 CVE 编号的上游 GHSA 公告(其中 GHSA-wpr3-8q3r-4q8c 补齐了此前 CVE-2026-33630 修复遗漏的连接关闭路径,上游仍标为 No known CVE):
| 公告 | 修复内容 |
|---|---|
| GHSA-wpr3-8q3r-4q8c | 连接失败后取消查询时的内存破坏 |
| GHSA-fqh5-3x7m-wp44 | 接受来自错误 DNS 服务器的响应 |
| GHSA-97vq-4mxv-7vhm | 关闭失败连接后查询对象的释放后使用 |
| GHSA-rf6g-ww74-97qh | 取消 / 关闭回调中的释放后使用 |
| GHSA-mw23-pfg6-2x9r | socket 状态回调后的释放后使用 |
| GHSA-v33j-65h4-4ch8 | 服务器状态回调取消查询时的释放后使用 |
| GHSA-pj7p-v3cw-hq66 | 回调更换 DNS 服务器时的释放后使用 |
| GHSA-grv7-fq5q-9vfc | 读取缓存响应时的释放后使用 |
| GHSA-ph6c-3cvx-mq6v | 并发复制选项 / 修改服务器时的缓冲区溢出 |
| GHSA-cfcv-8v2j-qj6r | 带前缀长度的 IPv6 地址暴露未初始化内存 |
| GHSA-wrgc-prmw-27xh | 缓存错误复用不同 EDNS 选项的响应 |
前端依赖与代码生成器
| 依赖 | CVE / GHSA | 修复内容 |
|---|---|---|
| fast-uri 3.1.7 → 3.1.9 | CVE-2026-86472 | 编码主机名大小写规范化不一致 |
| ip-address 10.4.0 → 10.7.4 | CVE-2026-101913 | 链路本地 IPv6 分类错误 |
| ip-address 10.4.0 → 10.7.4 | CVE-2026-101910 | 遗漏 NAT64 本地使用网段 |
| ip-address 10.4.0 → 10.7.4 | CVE-2026-101912 | 混合地址族的子网检查绕过 |
| ip-address 10.4.0 → 10.7.4 | CVE-2026-101911 | 超长地址错误诊断导致资源耗尽 |
| moment 2.30.1 → 2.31.0 | CVE-2026-17495 | 非字符串 locale 参数路径穿越 |
| smol-toml 1.8.0 → 1.9.1 | GHSA-r4xh-jqrq-34v2 | TOML 解析二次复杂度 |
| vite 8.0.16 → 8.3.5 | CVE-2026-112945 | 开发中间件跨源脚本执行 |
| vite 8.0.16 → 8.3.5 | CVE-2026-112946 | 开发服务器文件允许列表绕过 |
| vite 8.0.16 → 8.3.5 | CVE-2026-112943 | Windows 编辑器参数转义不足 |
| vite 8.0.16 → 8.3.5 | CVE-2026-112944 | 编辑器参数被解释为命令选项 |
| vite 8.0.16 → 8.3.5 | CVE-2026-112942 | Windows 编辑器路径检查绕过 |
此外,代码生成工具 go-swagger 从 0.33.2 升级至 0.36.6,修复 GHSA-8gmg-wf5j-hvf7 与 GHSA-rg22-m539-jwvf(不可信 OpenAPI 规范中的内容类型或枚举值在代码生成阶段可能触发代码注入,上游首次修复版本为 0.35.3,无独立 CVE 编号)。生成文件已与新版工具同步;生成流程显式保留经过审查的配置导入绑定器与 multipart 处理方式,并通过回归测试保留原有文件大小、文件名和表单编码行为。
独立 Go 调试模块
服务端仓库下的 inspect、s3-verify 与 xattr 三个独立调试模块此前落后于根模块版本。本次同步消除了 37 个带 CVE 编号的依赖版本告警及 1 个无 CVE 的 S2 公告:
| 依赖 | 受影响独立模块 | 最终版本 |
|---|---|---|
| golang.org/x/crypto | inspect、s3-verify | 0.58.0 |
| golang.org/x/net | s3-verify | 0.61.0 |
| golang.org/x/text | s3-verify | 0.43.0 |
| golang.org/x/sys | inspect、xattr | 0.49.0 |
| klauspost/compress | inspect、s3-verify | 1.20.3 |
下表列出对应的 Go 官方漏洞库条目与上游首次修复版本(OpenPGP 的无修复公告单列于下文其他残留项):
| CVE / 公告 | 依赖 | 首次修复 | 问题 |
|---|---|---|---|
| GO-2026-5841 | klauspost/compress | v1.18.7 | S2 越界读取(无 CVE 编号) |
| CVE-2026-39824 | x/sys | v0.44.0 | Windows Unicode 字符串整数溢出 |
| CVE-2025-47913 | x/crypto | v0.43.0 | SSH agent 拒绝服务 |
| CVE-2025-58181 | x/crypto | v0.45.0 | SSH 无界内存消耗 |
| CVE-2025-47914 | x/crypto | v0.45.0 | SSH agent 畸形约束拒绝服务 |
| CVE-2026-39833 | x/crypto | v0.52.0 | SSH agent 密钥约束未执行 |
| CVE-2026-39832 | x/crypto | v0.52.0 | SSH 密钥转发丢失约束 |
| CVE-2026-46597 | x/crypto | v0.52.0 | SSH 字节运算下溢及 panic |
| CVE-2026-39828 | x/crypto | v0.52.0 | SSH 证书限制绕过 |
| CVE-2026-39835 | x/crypto | v0.52.0 | SSH 主机密钥 / 认证回调 panic |
| CVE-2026-39827 | x/crypto | v0.52.0 | 拒绝 SSH channel 时内存泄漏 |
| CVE-2026-39830 | x/crypto | v0.52.0 | SSH 非预期响应导致死锁 |
| CVE-2026-39829 | x/crypto | v0.52.0 | 畸形 RSA / DSA 参数导致拒绝服务 |
| CVE-2026-39831 | x/crypto | v0.52.0 | FIDO / U2F 物理交互要求绕过 |
| CVE-2026-39834 | x/crypto | v0.52.0 | SSH 大 channel 写入无限循环 |
| CVE-2026-42508 | x/crypto | v0.52.0 | known_hosts 的 @revoked 未执行 |
| CVE-2026-46595 | x/crypto | v0.52.0 | VerifiedPublicKeyCallback 权限未执行 |
| CVE-2026-46598 | x/crypto | v0.52.0 | SSH agent 畸形输入导致 panic |
| CVE-2026-56854 | x/crypto | v0.55.0 | SSH 非公钥认证遗漏 source-address 约束 |
| CVE-2026-78662 | x/crypto | v0.56.0 | 未决 SSH channel 死锁 |
| CVE-2026-56855 | x/crypto | v0.56.0 | 已建立 SSH channel 死锁 |
| CVE-2025-47911 | x/net | v0.45.0 | HTML 解析二次复杂度 |
| CVE-2025-58190 | x/net | v0.45.0 | HTML 解析无限循环 |
| CVE-2026-33814 | x/net | v0.53.0 | HTTP/2 非法帧大小导致循环 |
| CVE-2026-42506 | x/net | v0.55.0 | HTML foreign content 命名空间处理错误 |
| CVE-2026-39821 | x/net | v0.55.0 | 未拒绝纯 ASCII 的 Punycode 标签 |
| CVE-2026-42502 | x/net | v0.55.0 | HTML foreign content 元素处理错误 |
| CVE-2026-25680 | x/net | v0.55.0 | 畸形 HTML 解析拒绝服务 |
| CVE-2026-25681 | x/net | v0.55.0 | DOCTYPE 字符引用处理错误 |
| CVE-2026-27136 | x/net | v0.55.0 | 重复 HTML 属性导致 XSS |
| CVE-2026-46600 | x/net | v0.56.0 | 畸形 SVCB / HTTPS DNS 记录导致 panic |
| CVE-2026-78659 | x/net | v0.60.0 | HTTP/2 Trailer 导致内存耗尽 |
| CVE-2026-78660 | x/net | v0.60.0 | HTTP/2 接受畸形帧相关头 |
| CVE-2026-78669 | x/net | v0.60.0 | HTTP/2 窗口反复变化导致 CPU 耗尽 |
| CVE-2026-78663 | x/net | v0.60.0 | HTTP/2 流控额度重复返还 |
| CVE-2026-97032 | x/net | v0.60.0 | HTTP/2 HPACK 编码器竞争导致崩溃 |
| CVE-2026-56852 | x/text | v0.39.0 | 非法文本输入导致无限循环 |
| CVE-2026-56851 | x/text | v0.41.0 | PRECIS 解析畸形输入导致 panic |
上游或发行版仍未解决的条目
截至 2026-10-23,以下条目在上游或所选操作系统发行版中尚无可用修复版本。我们保留原始扫描记录,并逐项说明已采取的本地缓解措施或运行时可达性边界。
已做本地缓解,等待上游发布
| 条目 | 上游 / 发行版状态 | 本次处理及剩余边界 |
|---|---|---|
| CVE-2026-93687 / braces | 最新 npm 版本仍为 3.0.3,上游尚未发布修复版本 | 对 [email protected] 应用本地 Yarn 补丁,将解析与 AST 遍历深度限制为 100(13 项回归测试通过)。该包仅位于前端构建/测试依赖图;纯版本号比对的扫描器仍会提示告警。待上游发布官方修复版并通过验证后移除本地补丁。 |
| CVE-2026-111943 / UBI CA | 验收时 UBI 9 仍提供 ca-certificates 2025.2.80_v9.0.305-91.el9,尚未包含最新信任变更 |
已在构建阶段执行 Red Hat 官方根证书封禁并校验生成的 PEM 文件;Go 1.27.3 与 Node 24.22.1 也各自内置了证书信任拦截。后续继续跟踪 UBI ca-certificates 包更新。 |
UBI 9.8 尚无适用发行版补丁的扫描记录
2026-10-22 对 SILO、mcli 与 KES 在 amd64 和 arm64 上的系统组件验收镜像(包含最终静态 curl 与 CA 证书库)进行了扫描:每个镜像均保留 24 条 RPM 记录,对应同一组 12 个唯一 CVE(15 条 Medium、9 条 Low,无 High / Critical,所有记录的 FixedVersion 均为空)。2026-10-23 复查 Red Hat 安全数据时,以下 RHEL 9 源包状态仍为 Affected 或 Fix deferred。
由于 RPM 扫描器按源包(SRPM)映射漏洞,部分仅存在于未安装命令行工具或其他 CPU 架构中的缺陷也会挂靠到基础运行库(如 libgcc、glibc)名下;下表逐一列出各条目的实际适用范围:
| CVE | 源包 | Red Hat 级别 | RHEL 9 状态 | 适用性 |
|---|---|---|---|---|
| CVE-2026-56391 | coreutils | Moderate | Fix deferred | uniq 多字节输入越界读,需特定命令行参数与输入触发 |
| CVE-2026-8674 | glibc | Moderate | Fix deferred | DNS resolver 处理超长搜索域时触发进程中止 |
| CVE-2026-86805 | glibc | Moderate | Affected | 动态加载器本地竞争,需满足本地 setuid、路径与硬链接前提 |
| CVE-2026-89092 | glibc | Moderate | Affected | nscd DNS 缓存守护进程缺陷;容器镜像未安装 nscd 包 |
| CVE-2026-95818 | glibc | Moderate | Affected | 动态加载器栈越界,需特定本地 setuid 与路径条件 |
| CVE-2026-97399 | glibc | Low | Affected | Power8 架构专用优化路径;发布镜像仅针对 amd64 / arm64 |
| CVE-2021-46195 | gcc | Low | Fix deferred | libiberty 符号解码器递归耗尽(Red Hat 公告中的 mingw-gcc 修复不适用于 libgcc 运行库) |
| CVE-2022-27943 | gcc | Low | Fix deferred | libiberty 缺陷;Red Hat 明确说明 libgcc / libstdc++ 不含受影响代码 |
| CVE-2026-95619 | gcc | Moderate | Affected | libstdc++ 对齐分配溢出,因源包归属映射为 libgcc 告警 |
| CVE-2026-102010 | gcc | Moderate | Affected | libstdc++ 可选 pb_ds 容器缺陷,因源包归属映射为 libgcc 告警 |
| CVE-2023-50495 | ncurses | Low | Fix deferred | tic 终端编译工具缺陷,不在 libncurses 运行库中 |
| CVE-2022-41409 | pcre2 | Low | Fix deferred | 仅影响 pcre2-tools 命令行工具;UBI 未安装该工具包 |
构建阶段与无 CVE 公告
- Alpine 构建阶段宿主包的
curl/libcurl仍为8.22.0-r0:截至 2026-10-23,Alpine 双架构包索引尚未推送8.23.0。该包仅在容器构建阶段用于下载固定 SHA-256 校验和的源码包,最终打入运行镜像的是从源码编译的静态curl 8.23.0。 - GO-2026-5932:
golang.org/x/crypto/openpgp已废弃且无上游修复版本(无 CVE 编号)。虽然golang.org/x/crypto仍在模块图中,但已核查的构建目标均未导入受影响的openpgp子包。
已有历史修复,不属于本次新增漏洞
按模块路径匹配的二进制扫描器仍会对 SILO 报告 CVE-2025-27414、CVE-2026-33322、CVE-2026-33419、CVE-2026-34204、CVE-2026-39414、CVE-2026-40344、CVE-2026-41145、CVE-2026-42600(因为 SILO 保留了 github.com/minio/minio 模块路径,而上游通告使用开放版本区间)。这八项缺陷在此前版本中均已修复并通过回归测试,详见 SFTP 公告、OIDC、LDAP、复制元数据、CSV、Snowball、签名校验 与 ReadMultiple。
兼容性与依赖回归证据
为避免已复现的终端表格输出变化、字符宽度计算差异及 NetBSD 交叉编译回归,本次固定保留 tablewriter 0.0.5、go-humanize 1.0.1、根模块 go-runewidth 0.0.29 及 go-systemd/v22 22.6.0(xattr 模块保留其既有宽度库组合)。此外:
- Kafka 通知目标在应用用户配置前显式保留协议版本默认值
2.1; - DARE 加密继续使用 32 字节密钥与 v2 写入格式,并修复旧 v1 解封缓冲区处理;
pgzip保留对缺失校验尾部返回unexpected EOF的完整性检查。
10 月 22 日至 23 日的候选构建(与回退基线均基于 Go 1.27.3,持久化验收在单节点四盘 Linux arm64 环境执行)已完成以下验证:
- 全量编译、适用的
-race与 lint 检查; - 加密对象在新旧依赖栈之间的互读与回滚验证;
- 服务重启、缺失分片自愈恢复与 CLI 格式化输出校验;
- Console 真实对象上传下载、ZIP 归档预览、WebSocket 会话及监控指标图表验证。
10 月 24 日追加升级后的候选源码另行完成以下验证:
- silo-pkg、mcli、KES 的全量 race 测试,Console 的 API / pkg / cmd race 测试,以及 SILO 的全量测试和 SFTP、IAM、Parquet 专项 race 测试;五个项目的 lint、模块校验与依赖整洁性检查通过。
- SFTP 自有驱动在 HTTP / HTTPS S3 后端上的对象读写、列表、删除、Unicode 文件名、传输块边界和只读权限保护;上游只读服务与符号链接删除回归测试通过。旧、新依赖均通过新增的对象操作测试。
- JWX 签名与时间声明边界对照通过;68 项 CLI 输出对照无新增差异,仅对既有元数据映射顺序做归一化。
- 本地 etcd 服务的 mTLS、账户认证、事务、watch、lease 和旧→新→旧客户端跨服务重启读取;NATS / STAN 的消息持久化、ACK、持久订阅游标及断线重连通过。
- Azure / GCS 本地协议夹具、KES GCP Secret Manager 操作与分页、Azure SDK 的 Arrow 解码测试、procfs 完整上游解析夹具测试通过。procfs 更新包含 bcache 单位及 NVMe 大小的解析修正,相关指标数值可能随之纠正。
- mcli、Console、SILO、KES 的 Darwin arm64 原生构建与 Linux amd64 交叉构建,以及 mcli、SILO 的 NetBSD amd64 交叉构建通过。
本轮五个项目的 Linux amd64 包级 Go 漏洞扫描未发现受影响的已导入包(仅余未导入受影响子包的模块级 GO-2026-5932 记录)。
2.2 - SILO 20261018 发布说明
Server 20261018 已正式发布,提供 amd64 与 arm64 版本化归档、原生软件包及 Docker Hub 标准/distroless 镜像。本版本接替 Server 20260916(20261015 保留为已撤回的历史草稿记录)。
| 身份 | 值 |
|---|---|
| 发行标签 | RELEASE.2026-10-18T00-00-00Z |
| 软件包版本 | 20261018000000.0.0 |
| 发行源码 | 0cd388c666f8211f552604ae73448a066e9c25ce |
| 上一正式版源码 | 2a4d51406b7ed87af5fe6fe0f801f3290f96eb3c(Server 20260916) |
| 完整变更对比 | 20260916 → 20261018 |
版本化归档、原生软件包及固定标签的容器拉取命令见下载页。
Object Lock 与 IAM 安全修复
SN-2026-015 / SN-2026-016 — Object Lock 保留期授权与原位覆写防护。 #234 与 #235 修复了保留期修改鉴权与原位覆写漏洞:
- 每次
PutObjectRetention请求在读取目标对象前,都会携带请求中的 Object Lock 条件键校验s3:PutObjectRetention。缩短、清空未到期的GOVERNANCE保留期或切换其模式时,调用方必须同时提供x-amz-bypass-governance-retention: true请求头并具备s3:BypassGovernanceRetention权限(不能替代基础s3:PutObjectRetention权限)。清空保留期时剩余天数按0计算;未到期的COMPLIANCE保留期不允许缩短或更改模式。 - 调用方显式指定写入
versionId时必须具备可信复制身份(s3:ReplicateObject)。目录对象的null版本和已持久化的分片上传目标会在对象写锁内重新校验锁状态;锁状态读取失败时按 fail-closed 拒绝写入,既有 Legal Hold 继续生效。请同步升级集群中所有写入节点及接收 S3 请求的复制站点。详见安全公告与修复设计。
SN-2026-017 — 管理凭据自助授权边界。 #242 使凭据自查询与清理严格遵守显式 Deny、会话策略收窄(sessionPolicy)及外部授权插件判定。在内置授权下,受限会话凭据无法创建或更新权限超出自身范围的服务账号,非 root 管理员也无法修改 root 派生的凭据;无限制空策略继承与损坏的非 root 密钥清理保持支持。后续提交 995439260 将批量自查询别名归一到父身份,并保留无附加策略普通用户的自查询能力,无需新增 Admin action 或迁移 IAM 存储。详见安全笔记与授权契约。
按范围撤销 STS 令牌
0cd388c66 让 RevokeTokens 同时枚举持久化 STS 身份记录与节点内存缓存,即使某个节点此前漏掉了令牌签发通知,也能完整撤销目标令牌。即便批次中个别令牌写入墓碑失败,已提交的删除记录仍会广播给同集群节点与站点复制对端;离线站点在恢复后通过既有 IAM 删除自愈补齐。基于对象存储和 etcd 的缓存刷新也会在通知或 watch 遗漏后自动剔除已撤销令牌。
撤销范围仍为请求指定的父身份及可选令牌类型(覆盖请求集群在操作截点前的快照,不影响新签发的令牌、无关用户或父身份策略)。持久枚举仅增加管理端撤销命令的开销,不为日常请求鉴权增加网络查询。详见 STS 撤销设计与成本。
重平衡与池下线一致性
60c7179dd(含 #248)与 1748f62c7 防止 rebalance 或池下线(decommission)搬迁旧副本时覆盖其他存储池中的最新副本。搬迁流程会在共享对象锁内复核源版本(包含分片完成与分层对象元数据),保留 null 与 UUID 删除标记,并仅清理已确认搬迁完成的版本,不再粗暴删除整个源前缀。若期间发生并发写入、删除或目标池变化,清理会安全中止并留待重试。
退役旧存储池前,请按多池对象一致性设计核验对象版本、标签和删除标记状态(跨站点删除标记的独立边界仍由 #217 跟踪)。
普通对象列表恢复
#239(含原 #221 工作,关闭 #218)修复了在符合恢复条件的元数据分歧下,ListObjects / ListObjectsV2 成功返回却遗漏可读对象 key 的问题。多池合并层会在对象读锁内复核存在分歧的同名条目组:返回当前可读元数据、略过已确认不存在的对象,或在无法确认时返回 503 SlowDownRead 等明确错误。分页生产者出错会终止整次扫描;客户端应消费所有分页并将任一分页错误视为不完整列表。
常规无分歧条目继续走原有的无锁快速路径。锁内复核仅针对符合条件的最新对象组,不降低读仲裁要求,也不新增持久化元数据;请求取消可随时中断锁重试等待,遇到恢复锁冲突时仅执行一次有界短等待。后续提交 3dd53eb81 与 2080b3d03 确保当同名碰撞对象被确认为不存在时,同名目录或公共前缀(CommonPrefixes)仍被完整保留。实测延迟与锁开销见列表恢复锁成本记录。
分片上传列举与清理
回退默认磁盘扫描,恢复 Legacy 内存列举。 #243 在 10 月 17 日引入的默认磁盘扫描已由 4733e1423 撤回。公开 ListMultipartUploads 在空 prefix 时查询节点本地内存缓存,在非空 prefix 时按精确对象 key 查找;进程重启或请求落到其他节点可能遗漏未完成上传(跨池持久化列举仍在 #79 中跟踪)。
与 20260916 相比,环境变量 MINIO_API_MULTIPART_LISTING=strict 已停用:设置该变量会在启动时输出警告并被忽略(旧共享配置键 api multipart_listing 同样被容忍忽略)。但 20260916 引入的上传创建身份元数据、UploadID 路径校验、取消目标匹配检查以及成功取消后的缓存/对端清理通知均得以保留。只读管理诊断接口 multipart-preflight 仍要求 admin:StorageInfo 权限并返回 mode=legacy,其扫描条目数、超时与并发上限仅作用于显式管理诊断。详见分片列举设计、回退原因与容量基准及配置参考。
后台过期分片清理(默认 24 小时过期、每 6 小时巡检一次)包含两项修复:
- #236:优先解析原生上传目录名中的时间戳,再回退尝试 Base64 兼容路径,消除无谓的磁盘元数据读取。
0f9199e0c:删除过期上传子目录后,立即清理变空的父目录,不再因删除子目录刷新了父目录mtime而多等一轮 24 小时过期周期(非空父目录与活跃上传不受影响)。
错误分类、元数据复用与其他修复
| 改动 | 用户可见效果 |
|---|---|
| #232:提交暂存元数据丢失分类 | 当 PUT 或分片合并在最终提交阶段找不到暂存元数据时,返回可重试的 503 SlowDownWrite,不再误报为 IncompleteBody 或 NoSuchKey;存储 RPC 与 healing 同步保留该错误分类。 |
| #233:元数据与扫描消息结构体复用 | 解码旧格式 xl.meta 时清空未携带的 EcM / Signature 残留值,并在构建扫描响应时清理互斥的 update/final 字段(见结构体复用修复设计)。 |
| #225、#229、#238:校验和错误文案 | 对齐 AWS S3 的 Content-MD5 大小写与算法名错误文案;PUT / UploadPart 已知算法不匹配时指明具体算法名,未知算法保留通用提示。 |
| #238:内嵌 Console 错误解包 | 保留被多层包装的底层 S3 错误码;当 Object Lock 阻止暂停桶版本控制时,准确向前端返回 InvalidBucketState。 |
| #220:Lambda 目标集合判空 | 修正写反的 TargetIDSet.IsEmpty 布尔返回值。 |
| 纠删码文档修正 | 按单个纠删集合(Erasure Set)和对象实际布局说明校验块与容错盘数(见存储类参考)。 |
配套组件与依赖
| 组件 | 选定版本 |
|---|---|
| 内嵌 Console | v2.4.2(v0.0.0-20261017112417-ef2dd2c41d21) |
| MC 模块/镜像内置 mcli | 20261018(v0.0.0-20261017101407-fa3b8bf72458) |
| Server 共享包 | github.com/pgsty/silo-pkg/v3 v3.14.3 |
| 上游 S3 SDK | github.com/minio/minio-go/v7 v7.4.1 |
| Go 工具链 | 1.27.2 |
| 可选外部密钥服务 | PGSTY KES 20261018(独立服务,源码 2f7e741db905) |
独立发布的 Console v2.4.2 与 mcli 20261018 依赖 silo-pkg v3.14.2;Server 主模块升级至 v3.14.3(优化管理 action 分类性能,授权语义不变),并显式 require 上述 Console 与 MC 版本。
minio-go v7.4.1 包含上传失败时的取消清理、UTC 保留期时间戳及 STS 时长透传。核心依赖更新包括 go-jose v4.1.6、ldap v3.4.15、compress v1.20.3、msgp v1.6.5、etcd v3.7.2 与 golang.org/x/*。注意:升级后的 go-jose v4.1.6 会拒绝超过 4096 位的 OIDC/JWK RSA 密钥,使用超长 RSA 密钥的部署在升级前须先核验 OIDC discovery、JWKS 与登录链路(详见依赖安全说明)。
发行流水线固定使用 GoReleaser v2.18.3、nFPM v2.48.0、golangci-lint v2.15.0 与 govulncheck v1.9.0,并将 Object Lock 竞态回归设为发版前置门禁。make rebrand-guard 改用 git grep 扫描源码并显式报告读取错误,不再依赖外部 ripgrep(7007c164a)。注意仓库内置的 Helm Chart 默认镜像标签仍为 20260916,通过 Helm 部署本版本时请显式覆盖为 RELEASE.2026-10-18T00-00-00Z。
升级与回滚要点
- 备份状态与固定制品:记录当前运行版本并固定目标二进制或镜像 digest;按迁移指南与 IAM 恢复流程完整备份 IAM 存储、配置与加密材料(常规在线 IAM 导出不包含撤销墓碑历史)。
- 全集群同步切换:协调升级所有 Server 节点与接收写入的复制站点,切勿在同一 IAM 后端混跑新旧二进制或执行跨版本滚动降级。
- 核对 Object Lock 与服务账号权限:使用业务实际凭据验证保留期修改、Legal Hold、服务账号创建/更新、自助操作显式
Deny以及 root 凭据管理。此前因策略格式受损而无法使用的到期密钥,应由授权父身份重新签发。 - 移除
strict分片列举依赖:若此前在 20260916 上启用了MINIO_API_MULTIPART_LISTING=strict,升级前请确保客户端自行维护待完成或待取消的bucket/key/UploadID,不要依赖公开列表进行全量发现或空间回收判断。 - 检查列表、重试与 OIDC/TLS 链路:验证普通与分页
LIST、PUT/分片完成对503 SlowDownWrite的重试、OIDC/JWKS 密钥长度及经过 TLS 检查设备的出站连接(参见 Go 1.27 TLS 与 OIDC 指南)。 - 回滚风险:降级回 20260916 会重新暴露本版修复的 Object Lock 与管理凭据授权缺陷,且升级本身不会自动恢复升级前已被覆写的对象版本或丢失的标签。
制品核验与验收记录
签名标签 RELEASE.2026-10-18T00-00-00Z 及全部 36 个发行制品均对应源码 0cd388c666f8211f552604ae73448a066e9c25ce,并通过了 Go CI、VulnCheck、Release 构建与软件包定稿工作流。
- 静态制品核验:核验全部下载摘要、6 个平台归档版本戳、6 个系统软件包(RPM/DEB/APK)载荷、34 份构建来源证明(attestation)、2 份 Sigstore 校验清单签名、2 份 RPM 签名与 12 份 SBOM。
- 本机功能与故障验收:在原生 Darwin 与 Linux 环境下完成 S3/IAM/STS/Object Lock、LDAP/OIDC、20260916 原地升级、桶复制与站点复制、含内嵌 Console 的三站点恢复、三站点及四进程共享集群 STS 撤销,以及 4 组真实多池迁移(2/3 池 × rebalance/decommission)。在 8 进程 2 池拓扑下,滚动重启负载完成 2,896 次全量扫描,故障恢复负载完成 1,270 次全量扫描,零漏项、零重复、零读取损坏;另有 3 组 Linux 软件包生命周期与 8 种容器运行变体全部通过(注:Windows 与 Darwin amd64 为静态核验,容器 amd64 运行于跨架构模拟环境)。
- Docker Hub 公开摘要:
- 标准镜像(及发布时的
latest):sha256:2636fc4155d9a7e41ff58cdb65ddd13ba9304e67184e13c25bc5cb34016269d7 - Distroless 镜像:
sha256:472f59c7be537571f0f9a2ee6b8fe033a220939d521255bd4bf5339e53db7218
- 标准镜像(及发布时的
感谢 szopi00 报告 Object Lock 问题;mr javad seydi(@mrjavadseydi)贡献原始 LIST 与分片工作;Julien Laurenceau(@julienlau)修复提交元数据分类与结构体复用问题;clown15 修复上传目录清理;cbornet 与 yupoet 改进校验和错误文案;AleksaMCode 修复 Lambda 目标集合判空;以及 Denis Babchuk(@botdeniska)贡献 #248 迁移防覆盖修复与 silo-pkg 性能优化。
2.3 - silo-pkg 3.14.3 发布
发行版: v3.14.3 · 源码: e9d585ba1e7a。依赖基线见 v3.14.2 发布说明。
github.com/pgsty/silo-pkg/v3 v3.14.3 优化了 IAM 策略判定期间识别 admin: 动作的开销,被 Server 20261018 选用。
AdminAction.IsValid 与 HasResource 现在优先执行精确查表,再按需扫描动作集合:不含通配符且未命中的字面量动作直接返回;前缀无法匹配 admin: 命名空间的通配符模式直接跳过合法性扫描,而 adm*、a*d*m*i*n* 等通配模式保持原有匹配语义不变。
性能与验证
在 Apple M5 Max(Go 1.27.2)上进行的 6 轮交替单核基准测试表明:在先遍历 100 条不匹配桶授权、最后命中目标授权的策略场景下,单次策略判定中位耗时从 303.2 µs 降至 54.4 µs(降低约 82%),且零新增堆内存分配。
验证覆盖 Go 1.26 与 1.27 race 测试、lint、LDAP 集成测试、漏洞扫描、60 秒等价性模糊测试(fuzzing),以及针对 50,000 份策略、970,020 次策略判定的新旧版本全量结果比对;使用 mcli 20261018 连接基于新旧包构建的 Server 结果完全一致。
采用方式
公开 API、策略格式、权限判定结果、外部依赖与 go 1.26 最低版本要求均无变化。Server 20261018 采用 v3.14.3,而独立发布的 mcli 20261018 与 Console v2.4.2 继续使用 v3.14.2(各组件依赖版本见组件矩阵)。
感谢 Denis Babchuk(@botdeniska) 提交 PR #11,以及 Klaus Post(@klauspost) 的原始优化工作。
2.4 - PGSTY KES 20261018 发布
发行版: RELEASE.2026-10-18T00-00-00Z · 软件包: 20261018000000.0.0 · 源码: 2f7e741db905。
这是 PGSTY 维护的密钥加密服务(Key Encryption Service,KES)首个正式发行版。KES 是独立部署的无状态服务,供需要外部 KMS 的 SILO 服务端加密场景使用(参见组件版本矩阵)。
变更内容
- 建立 PGSTY 维护与发布流水线,完整保留上游版权、署名及第三方许可证。可执行文件与系统软件包保持
kes命名,Go 模块路径保持github.com/minio/kes。 - 升级至 Go 1.27.2 并刷新依赖,将 AWS Secrets Manager 后端从已弃用的 AWS SDK for Go v1 迁移至 AWS SDK for Go v2。完整保留 YAML 配置字段、显式凭据优先级、自定义端点与密钥存储格式(继续忽略
~/.aws/config与 SDK 环境变量端点覆盖)。 - 完整兼容既有 KES HTTP API、
MINIO_KMS_KES_*环境变量、存量根密钥及已加密对象;现有 SILO + KES 部署可直接升级,无需迁移密钥。 - 新增 SILO 搭配 OpenBao KV v2 及 Moto Secrets Manager 模拟器的升级、回滚与重启集成测试(每次版本切换均强制重启 SILO 以排除服务端密钥缓存干扰)。
- 提供签名 Git 标签、GitHub 构建来源证明、GPG 签名 RPM 包与非 root 容器镜像;禁用内置
kes update自更新命令。
部署与升级
在服务端加密链路中,SILO 负责加解密对象数据,KES 负责验证客户端 mTLS 身份并封装/解封装数据加密密钥(DEK),OpenBao 负责持久化存储主密钥(KEK)。因此,MINIO_KMS_KES_ENDPOINT 必须指向 KES,而非直接指向 OpenBao(架构边界与故障恢复要求见 KES 与 OpenBao 设计说明)。
生产启动 KES 前须配置 TLS 证书、mTLS 客户端身份策略与持久化密钥后端(开发模式仅将密钥保存在内存中)。官方容器默认以非特权 UID/GID 1000:1000 运行,挂载的配置文件与证书须对该用户可读;Linux RPM/DEB/APK 软件包安装 /usr/local/bin/kes,不会自动拉起未配置的服务。
升级前请先备份底层密钥存储与配置文件;升级后验证存量加密对象解密、新加密对象写入,以及重启 KES 与 SILO 后的冷读能力。
交付与验证
全部 19 个发行附件(Linux/macOS/Windows 的 amd64/arm64 共 6 个平台压缩包、6 个 Linux RPM/DEB/APK 软件包、归档校验清单与 6 份软件包校验文件)均已核验,涵盖下载摘要、二进制源码/平台标记、软件包载荷、2 份 RPM 签名及 11 份 GitHub 构建来源证明(见发布工作流)。
官方多架构容器镜像 docker.io/pgsty/kes:RELEASE.2026-10-18T00-00-00Z(linux/amd64 与 linux/arm64,Docker 发布记录)的索引摘要为:
维护验收矩阵重点覆盖 SILO + PGSTY KES + OpenBao KV v2(含 mTLS 认证、OpenBao AppRole 访问、DEK 加解密、重启持久性、未认证请求拒绝,以及与 SILO 20260903/20260916 和上游 KES 2025-03-12T09-35-18Z 的互操作与回滚测试;AWS Secrets Manager 后端通过本地 Moto 模拟器完成协议与存储回归)。
2.5 - SILO Console 2.4.2 发布
版本: v2.4.2 · 源码: ef2dd2c41d21。
本版本作为独立 Console 发行版及 Server 20261018 的内嵌 Console 组件发布(参见 silo-pkg v3.14.2 发布说明)。
更新内容
- 升级至 mcli 20261018、silo-pkg v3.14.2 与上游 minio-go v7.4.1。SDK 包含取消后的失败分段上传清理、UTC 保留期时间戳及 STS 时长透传(并行 GET 与 ComposeObject 仍需显式启用)。
- 保留桶版本控制操作中被包装的底层 S3 错误;当 Object Lock 阻止暂停版本控制时,明确向前端返回
InvalidBucketState。 - 使用 Go 1.27.2 与 Node 24.22.0 LTS 构建。更新
golang.org/x/*模块及依赖版本:compress v1.20.3、msgp v1.6.5、etcd v3.7.2、fwd v1.2.1 与 cpuid v2.4.1(保留 go-systemd v22.6.0 以支持 NetBSD,保留 tablewriter v0.0.5 以兼容现有 MC API)。 - 构建工具固定为 GoReleaser v2.18.3、golangci-lint v2.15.0 与 govulncheck v1.9.0,重新生成 2.4.2 内嵌前端资产并更新固定摘要的构建容器。
集成说明
由于模块路径保留无 /v2 后缀的 github.com/pgsty/silo-console,Go 消费方通过伪版本 v0.0.0-20261017112417-ef2dd2c41d21 引用。嵌入 Console 的 Server 需同时显式声明 Console 与 MC 替换:
Console v2.4.2 直接依赖 github.com/pgsty/silo-pkg/v3 v3.14.2(既有密码权限迁移规则见密码权限迁移指南)。注意 go-jose v4.1.6 的 4096 位 RSA JWK 限制仅影响链接了该包的 Server OIDC 路径,独立 Console 二进制未链接 go-jose 与 go-ldap。
发布与验证
全部 44 个发行制品(6 个平台二进制、6 份归档、9 个 Linux 软件包及配套校验清单、SBOM 与签名来源证明)均已核验并与标签源码 ef2dd2c41d21 一致。该提交通过完整 CI 矩阵(权限、SSO、复制、浏览器测试、源码镜像一致性与可复现打包)、漏洞检查、本地 Go race 测试、251 项前端单元测试,以及搭配 Server 20261018 的真实 API 与浏览器分享测试。
公开镜像 docker.io/pgsty/silo-console:v2.4.2(linux/amd64 与 linux/arm64)的多架构索引摘要为:
发布核验约定 · 组件版本矩阵 · 相对 v2.4.1 的源码差异。
2.6 - mcli 20261018 发布
版本: RELEASE.2026-10-18T00-00-00Z · 软件包版本: 20261018000000.0.0 · 源码: fa3b8bf72458。
本版本作为独立 mcli 客户端及 Server 20261018 与 Console v2.4.2 的配套客户端版本发布(参见 silo-pkg v3.14.2 发布说明)。
更新内容
- 升级至 silo-pkg v3.14.2 与上游 minio-go v7.4.1。SDK 修复取消后的失败分段上传清理、UTC 保留期时间戳格式及 STS 时长透传,并保留既有
CopyObject内嵌错误处理与校验和逻辑(并行 GET 与 ComposeObject 仍需显式启用)。 - 使用 Go 1.27.2 构建,更新
golang.org/x/*模块、compress v1.20.3、msgp v1.6.5 与 etcd 客户端 v3.7.2(其中 fwd v1.2.1 修复同时返回数据与错误时的字节丢失及Skip偏移;cpuid v2.4.1 修复缓存识别)。 - 通过
silo-pkg引入 go-jose v4.1.6(修复 RSA JWK 解析 CPU 消耗问题 GHSA-mgcc-cww5-fp2q)与 LDAP v3.4.15。 - 刷新固定摘要的 UBI 9 基础镜像与发行工具链:GoReleaser v2.18.3(含日志脱敏与归档修复)、nFPM v2.48.0、golangci-lint v2.15.0、govulncheck v1.9.0 与 gofumpt v0.12.0。
升级说明
CLI 命令、别名、MC_* 环境变量与本地配置文件格式保持不变(保留 go-systemd v22.6.0 替换以支持 NetBSD 构建,保留 tablewriter v0.0.5 API)。各组件配套关系见组件版本矩阵。
发布与验证
全部 19 个发行制品(Linux/macOS/Windows 的 amd64/arm64 共 6 个归档、6 个 Linux RPM/DEB/APK 软件包、1 份归档校验清单及 6 份软件包校验文件)均已核验,涵盖下载摘要、6 个二进制源码标记、6 个软件包载荷、PGSTY GPG 签名(9592A7BC7A682E7333376E09E7935D8DB9BD8B20)及 11 份 GitHub 构建来源证明(均绑定至 fa3b8bf72458)。
该提交通过 Go 测试、交叉编译、漏洞扫描(无可达或已导入包漏洞;仅保留未导入 OpenPGP 模块公告 GO-2026-5932)及发布流水线检查,并通过针对 SILO fbcb5119782a 的 Go 与 Shell TLS 功能回归测试(制品构建 · Docker 发布)。
公开镜像 docker.io/pgsty/mc:RELEASE.2026-10-18T00-00-00Z(及发布时的 latest,支持 linux/amd64 与 linux/arm64)的多架构清单摘要为:
下载与安装 · 完整变更记录 · 相对 20260916 的源码差异。
2.7 - silo-pkg 3.14.2 发布
发行版: v3.14.2 · 源码: c6835145176f。
github.com/pgsty/silo-pkg/v3 v3.14.2 为 mcli 20261018 与 Console v2.4.2 提供十月依赖基线;后续补丁版 v3.14.3 在此基础上优化了策略判定性能,供 Server 20261018 使用。
变更内容
- 上游
minio-go升级至 v7.4.1:在调用方取消上下文后仍确保清理失败的分片上传,以 UTC 序列化 Object Lock 保留期时间,原样透传请求的 STS 时长,并支持 Kubernetes 投射服务账号令牌文件(并行 GET 与 ComposeObject 仍需显式启用)。 go-jose升级至 v4.1.6:修复解析超大恶意 RSA JWK 时的 CPU 消耗问题(GHSA-mgcc-cww5-fp2q);同步升级 LDAP v3.4.15、msgp v1.6.5 与 etcd 客户端 v3.7.2。golang.org/x/*升级:x/cryptov0.58.0、x/netv0.61.0、x/sysv0.49.0、x/textv0.43.0。使用 Go 1.27.2 开发与测试,保持模块go 1.26最低版本要求及 NetBSD 的go-systemd v22.6.0替换。
兼容性与采用方式
公开包签名与密码策略语义保持不变(从 v3.14.0 之前版本升级请参考密码权限迁移说明,各组件依赖对照见组件矩阵)。
行为变更提示:
go-jose v4.1.6拒绝超过 4096 位的 RSA JWK,使用该解析器的 OIDC/JWKS 消费方需确认身份提供方密钥长度不超过 4096 位。- 低于 1 小时的 STS 请求时长现在按原值发送,不再被客户端静默改写,因此超出服务端下限的过短时长会被服务端直接拒绝。
验证
该版本通过 Go 1.27.2 完整 lint 与 race 测试、Go 1.26.9 race 测试、OpenLDAP 集成测试、模块整洁性检查及 govulncheck 扫描(无可达或已导入包漏洞)。
2.8 - SILO 20261015:已替代草稿记录
状态:已被正式版 Server 20261018 替代。 RELEASE.2026-10-15T00-00-00Z 曾于 2026-10-15 13:52:28(UTC+8)短暂公开,随后撤回为 Draft 并由包含完整十月修复栈的 20261018 接替。本页归档该候选版的源码范围与制品验收记录,新部署与升级请直接使用 20261018。
- 历史标签:
RELEASE.2026-10-15T00-00-00Z - 软件包版本:
20261015000000.0.0 - 候选源码:
7a3ca0f82100cce06289a465f54139da2639f642(相对于 Server 20260916 的完整源码差异)
对象锁修复
- SN-2026-015 — 保留期修改授权(#234):每次
PutObjectRetention请求在读取对象状态前,必须携带请求体中的 Object Lock 条件键通过s3:PutObjectRetention鉴权。缩短、清除未到期GOVERNANCE保留期或改变其模式时,必须同时具备显式 bypass 请求头与s3:BypassGovernanceRetention权限(不能绕过基础s3:PutObjectRetention的Deny)。清除保留期按剩余0天求值,远期日期计算不再溢出饱和。 - SN-2026-016 — 保护已有版本免受原位覆写(#235):客户端指定写入
versionId必须具备可信复制权限(s3:ReplicateObject)。目录null版本与持久化分片上传目标在实际覆写前、持有目标写锁时复查锁状态(覆盖升级前创建或从旧暂存状态恢复的分片上传),确保既有GOVERNANCE、COMPLIANCE与 Legal Hold 保护生效;锁状态读取失败时拒绝写入。 - 发行前置门禁(#237):发行构建流水线新增 Object Lock 竞态回归前置检查,覆盖慢请求体目录覆写、预检读取失败、旧分片完成覆写
COMPLIANCE以及恢复上传 UUID 碰撞。
20260916 以来的其他改动
| 改动 | 用户可见效果 |
|---|---|
| #233 | 解码复用的旧版 xl.meta 头部时清除缺失字段,并使扫描更新与最终响应字段互斥(见结构体复用修复记录)。 |
| #225 | 对齐 S3 校验和错误文案(含 Content-MD5 拼写)。 |
| #220 | 修复 Lambda 目标集合 TargetIDSet.IsEmpty 判空逻辑,并补充空集合与非空集合回归测试。 |
fa565ec9f |
升级至 Go 1.27.2、golangci-lint 与 golang.org/x/net v0.60.0,同步模块要求与固定 digest 的 Docker 构建阶段。 |
组件身份
| 组件 | 选用版本 |
|---|---|
| Server | RELEASE.2026-10-15T00-00-00Z(包版本 20261015000000.0.0) |
| 内嵌 Console | v2.4.1(源码 1360e26d976d) |
| mcli | RELEASE.2026-09-16T00-00-00Z(源码 e952aa78f10a) |
| 共享包 | github.com/pgsty/silo-pkg/v3 v3.14.1 |
| 上游 S3 SDK | github.com/minio/minio-go/v7 v7.3.1-0.20260915093545-32e1f32cb176 |
| Go 工具链 | Go 1.27.2 |
升级要求
- 全节点与复制站点升级:升级全部 Server 进程及所有接受 S3 写入的复制站点,避免未修复节点继续放行未授权的保留期修改或原位覆写。
- 核对策略权限:仅授予
s3:BypassGovernanceRetention而未授予s3:PutObjectRetention、或依赖 bypass 绕过保留期条件键的请求将返回403 AccessDenied。 - 指定
versionId写入限制:普通 S3 客户端写入(含 root 签名请求)不得指定目标versionId;可信复制保留版本 ID,mcli batch replicate写入 SILO 时目标凭据需具备s3:ReplicateObject。 - 锁状态不可读时拒绝覆写:已锁定的目录对象与已有分片目标不可被覆写;若底层锁元数据读取失败,需先恢复存储盘健康再重试。
- 历史数据排查:升级前已发生的异常保留期变更或覆写不会自动回滚,请结合审计日志核查(从 20260916 之前版本升级时请一并参考九月版升级要求)。
候选制品验收记录
针对候选源码 7a3ca0f82100cce06289a465f54139da2639f642 的流水线与本地核验结果如下:
| 检查项 | 结果 | 流水线记录 |
|---|---|---|
| 主分支 Go CI | 通过 | Run 38686240587 |
| 漏洞检查(VulnCheck) | 通过 | Run 38686240625 |
| 发行构建与 Object Lock 前置门禁 | 通过 | Run 38686425467 |
| 发行软件包定稿 | 通过 | Run 38687734395 |
- 静态制品核验:36 个发行制品、34 份制品来源证明、Linux/macOS/Windows 的
amd64/arm64共 6 个平台二进制静态身份、6 个 RPM/DEB/APK 安装包载荷与 2 个 RPM 签名全部核验通过(参考历史归档校验和与安装包校验和)。 - macOS arm64 二进制运行验收(110/110 通过):
| 场景 | 结果 |
|---|---|
| 全新安装完整 Object Lock API 验收 | 49/49 |
| 基于官方 20260916 数据目录原地升级验收 | 12/12 |
| 升级后再次运行完整 Object Lock API 验收 | 49/49 |
(其中 65 次预期拒绝操作均未改变目标对象的任何内容或元数据状态;Windows 与 macOS amd64 为静态身份核验。)
Docker 镜像验收
Docker 发布工作流 38689158028 构建的 linux/amd64 与 linux/arm64 多平台镜像摘要及 OCI 来源证明均核验通过:
| 镜像变体 | 版本标签 | 多平台 Index Digest |
|---|---|---|
| Classic | RELEASE.2026-10-15T00-00-00Z |
sha256:aaac6bce13eb99316b3c88bd20143fc3c38ab607585256a411a095c44ee57f62 |
| Distroless | RELEASE.2026-10-15T00-00-00Z-distroless |
sha256:70cac541c5a928be5cd16d6b8732d85ac06e891a0e3d9956a745381330692fc6 |
四个平台单架构镜像(容器内 /usr/bin/silo 与官方 Linux 归档二进制逐字节一致)均通过 /minio/health/ready 探针与 Object Lock API 套件(196/196 通过,其中 120 次预期拒绝操作均保持对象版本、ETag、保留期、Legal Hold 与版本列表完整不变):
| 镜像 | 运行环境 | API 验收 | 二进制比对 |
|---|---|---|---|
Classic linux/arm64 |
Docker Desktop ARM64 原生 | 49/49 | 与官方 Linux arm64 归档一致 |
Classic linux/amd64 |
Docker Desktop ARM64 仿真 | 49/49 | 与官方 Linux amd64 归档一致 |
Distroless linux/arm64 |
Docker Desktop ARM64 原生 | 49/49 | 与官方 Linux arm64 归档一致 |
Distroless linux/amd64 |
Docker Desktop ARM64 仿真 | 49/49 | 与官方 Linux amd64 归档一致 |
连同 macOS arm64 原生安装与升级测试,候选制品运行验收合计 306/306 通过。
2.9 - SILO Console 2.4.1 发布
SILO Console v2.4.1
于 2026 年 9 月 16 日发布,源码为
1360e26d976d。
本版接入 pkg v3.14.1 与 mcli 20260916,收紧匿名分享代理,支持多对象流式 ZIP 下载,并提供带签名和构建溯源的发布制品。
共享下载
#52 的修复将匿名代理限制为 配置的 S3 源站上的对象内容 GET 请求,拒绝重定向、系统路径和通过查询参数选择的非下载操作。 公开对象、预签名链接和指定版本下载继续可用。不需要新增一个关闭分享的环境变量。
感谢 Jiri Pejchal(@jiri-pejchal) 报告内部指标暴露问题。 支持的请求形式及反向代理要求见共享下载边界。
密码策略迁移
这个补丁版本包含授权语义变化。 修改密码按钮和会话能力改用 admin:ChangeMyPassword;
创建用户、重置他人密码继续使用 admin:CreateUser。配合对应的 SILO Server,
单独拒绝 CreateUser 不再锁定调用者自己的密码。要保留旧限制,应在升级前于同一个 Deny 语句中拒绝两个动作,保留原资源范围与条件。
更新后的内置 readonly 允许自助修改密码,也不再覆盖另一份策略授予的 CreateUser 权限。
已保存的策略覆盖保留原有语句。请协调升级 Server、Console 与 pkg,并阅读迁移及回滚说明。
下载与浏览器恢复
- 浏览器支持文件写入器时,多选 ZIP 使用背压流式写入,支持取消和重复下载保护;其他浏览器交给原生下载管理器。两条路径都不会在 JavaScript 内存中缓冲整个 ZIP。
- 只有响应提供可靠总长度时才显示百分比。原生下载会明确提示已交接,此后需在浏览器下载管理器中取消。选择超过 5 GiB 或总大小未知时,界面推荐使用 mcli 进行长时间传输。
- 无效路由和渲染异常提供恢复入口,不清空偏好设置;补全中英文字段、图标控件、退出文字和支持键盘操作的提示。
依赖与嵌入
| 组件 | 选择的版本 |
|---|---|
| 共享包 | github.com/pgsty/silo-pkg/v3 v3.14.1 |
| MC 源码 | github.com/pgsty/mc v0.0.0-20260916070421-e952aa78f10a,对应 mcli 20260916 |
| 上游 minio-go | v7.3.1-0.20260915093545-32e1f32cb176 |
| JWX / strfmt | v3.3.0 / v0.27.2 |
| React Router | v7.18.4 |
| 构建工具 | Go 1.27.1、Node 24.21.0、Yarn 4.13.0 |
SDK 正确处理 CopyObject HTTP 200 响应内嵌的 S3 错误;JWX 修复自定义 JSON 字段名转义, strfmt 更新 Go 1.27 主机名校验。嵌入前端和第三方署名已按最终依赖重新生成。 go-systemd v22.6.0 与 tablewriter v0.0.5 是保留的兼容性固定版本。
Go 嵌入方通过历史模块路径 github.com/minio/console 的 replacement 选择
github.com/pgsty/silo-console v0.0.0-20260916075814-1360e26d976d。
Go 不继承依赖模块的 replacement,需同时复制标签 README 中显式的 MC replacement。
正式支持的管理对象是配套 SILO 组件;上游 MinIO/MC 兼容性属于尽最大努力。
软件包与验证
本次 GitHub Release 有 44 个附件:二进制及归档、DEB/RPM/APK 软件包、源码、法律声明、SPDX SBOM、校验清单及 Sigstore 签名包。校验清单使用 Cosign 签名,构建溯源记录对应标签和工作流。
独立部署 v2.4.1 请使用 GitHub Release 二进制或 Linux 软件包;docker.io/pgsty/silo-console 的多架构容器镜像自 Console v2.4.2 起恢复公开拉取。Server 内嵌 Console 不受影响。
Linux 软件包保留 minio-console.service、console-user 和 /etc/default/console。
服务状态目录为 /var/lib/silo-console,证书目录改为 /etc/silo-console/certs,停止服务最多等待 90 秒。
已有安装重启前,请按要求迁移证书和权限,或通过 CONSOLE_OPTS 保留旧证书路径。
安装过程保留旧证书与私钥,不会自动重启服务。具体见软件包升级说明。
精确发布源码通过完整 CI 矩阵、 漏洞检查和 发布验证。 本地还验证了 251 项前端单元测试、51 项浏览器测试、嵌入资源逐字节一致性,以及独立和内嵌部署的分享行为。 实际下载的 Darwin/arm64 二进制经过签名校验清单核验,并连接 SILO 测试服务执行了分享验证。
2.10 - silo-pkg 3.14.1 发布
v3.14.1 从
fa657ef4 发布。
这是 github.com/pgsty/silo-pkg/v3 的依赖与工具更新;
相对 v3.14.0 的差异没有修改包的 Go 源码或公开 API。
依赖变化
- JWX 从 v3.2.0 升至 v3.3.0。上游发布说明
将自定义 JSON 字段名编码时缺少转义的问题标识为 GHSA-4cf7-xm37-g63h。可利用性取决于是否接受攻击者控制的自定义 claim 名称;
本包
env的 JWT 路径仅使用已注册的 claim 名称。 - 上游 minio-go 升至
v7.3.1-0.20260915093545-32e1f32cb176,包含 minio-go #2306:识别 HTTP 200 响应正文中的 S3CopyObject错误,避免误报复制成功。 - Testify 升至 v1.12.1,lint 配置采用
gomodguard_v2。 保留 Go 1.26 最低版本、Go 1.27.1 工具链及支持 NetBSD 的 go-systemd replacement。
组件与迁移边界
v3.14.0 的密码策略变化继续生效,本补丁没有回退它。
原先意图同时禁止用户管理与修改自身密码的策略,应按迁移指南
保留 admin:CreateUser 与 admin:ChangeMyPassword 的配对 Deny。
SILO 20260916、mcli 20260916 和 Console 2.4.1 均已选用此包,详见组件版本表。
发布验证记录见上述 release 和 PR #9。
2.11 - SILO 20260916 发布注记
版本日期: 2026-09-16 · 正式发布: 2026-09-17(UTC 2026-09-16) · 标签: RELEASE.2026-09-16T00-00-00Z · 软件包版本: 20260916000000.0.0。安装入口见下载页。
源码范围为已发布提交 9b11dc9469e6 到本版 2a4d51406b7e,共 210 个提交,其中 140 个非合并提交。本文覆盖该范围内的最终行为,包含 #214 之后的列表 quorum、版本 purge 和迁移标签修复。
本版重点
- 更完整的请求认证边界: 关闭普通签名与预签名请求的未签名操作头缺口,校验预签名正文摘要,使签名年龄及载荷哈希策略使用可信输入。
- 可持久恢复的 IAM 撤销: 保留删除版本、父身份撤销边界与成员授权时间,防止延迟事件、重建同名身份和重启恢复重新激活旧授权。
- 多池操作针对同一个逻辑对象: 条件 PUT、分片完成、条件 DELETE、指定版本删除及元数据协调使用一致的对象选择和锁边界。
- 池迁移保留标签: rebalance 和 decommission 的普通对象及分片迁移恢复完整标签写入,保留原修订和有序清空状态。
- 修复加密及联邦复制: 保留 SSE-C 原始副本、明文分段大小、独立 Object Lock/标签修订,以及远端实际写入的校验和、版本和时间。
- 复制和配置最终状态更可靠: 修复标签删除复活、删除标记 purge/MRF、resync 计数与取消、桶配置来源时间和删除状态收敛。
- 可选的严格分片发现: 新增持久化扫描、全局分页和只读预检;默认仍为 legacy,容量和迟到创建写入的限制明确保留。
- 更新整套管理工具: 内嵌 Console 2.4.1,捆绑 mcli 20260916,采用 pkg 3.14.1;补齐浏览分页、流式 ZIP、错误退出码和策略正确性。
- 两项必须提前安排的升级变化: 密码权限拆分会改变已有策略的含义;持久 IAM 撤销要求协调升级和完整后端备份。
版本与组件
| 组件 | Server 20260903 | Server 20260916 |
|---|---|---|
| Server | RELEASE.2026-09-03T13-18-01Z |
RELEASE.2026-09-16T00-00-00Z;源码核对点 2a4d51406b7e |
| 内嵌 Console | 源码 464a59d73ada,版本标识 v2.3.0 |
已发布 v2.4.1,源码 1360e26d976d |
| MC 模块 / 镜像内 mcli | 20260903 | 已发布 20260916,源码 e952aa78f10a |
| 共享包 | github.com/pgsty/silo-pkg/v3 v3.13.2 |
v3.14.1,包含 v3.13.3 和 v3.14.0 的累计修复 |
| 上游 S3 SDK | minio-go/v7,revision 0e78d3f18efe |
v7.3.1-0.20260915093545-32e1f32cb176 |
| Go / madmin-go | Go 1.27.1 / v3.0.110 | 保持 Go 1.27.1 / v3.0.110 |
| Helm | 7.0.2 | tag 源码内 Chart 7.0.3;未单独发布 Chart 包或 Helm 仓库 |
本版的 Console 和 MC replacement 分别为 v0.0.0-20260916075814-1360e26d976d、v0.0.0-20260916070421-e952aa78f10a。Go 不继承依赖模块的 replacement,因此 Server 显式选择两者。pkg 直接使用 PGSTY 模块路径;Server、Console、MC 保留现有历史模块身份和协议名称。minio-go/v7 继续使用经过核验的上游版本。
正式维护和发布验收面向 SILO + Console + mcli + silo-pkg;与未修改的上游 MinIO/MC 保持尽最大努力兼容。
安全与授权
本轮包含四个本地安全编号;SN- 是项目台账编号,不是 CVE。完整说明见签名头边界及九月安全说明。
| 编号 | 修复与实际边界 | 依据 |
|---|---|---|
| SN-2026-011 | 普通 Authorization 与预签名 SigV4 拒绝未签名的 x-amz-* 操作头,防止签名 PUT 被改造成使用签名者权限的 CopyObject;空首值的多值头也不能绕过 |
#173、#177 |
| SN-2026-012 | 通用认证路径验证仅由 header 提供哈希的预签名正文;摘要不符返回 XAmzContentSHA256Mismatch。调用者必须持有相应的签名写入 URL |
#177 |
| SN-2026-013 | 持久 IAM 撤销和父身份边界防止旧事件、旧子凭据及旧授权跨越保留的撤销历史 | #191、#192 |
| SN-2026-014 | Console 匿名分享代理只接受配置 S3 origin 的对象内容 GET,拒绝跳转、系统路径和选择其他操作的 query | Console #56、Server #209 |
签名配套修复还包括:消除重复复制源头的取值歧义;从签名日期计算 s3:signatureAge,移除请求头中的临时缓存值;统一预签名 query/header 的有效载荷哈希优先级;将 PutObjectTagging 的服务端合成头放到验签之后、授权之前。X-Amz-Content-Sha256 仍可不列入 SignedHeaders,但其有效值和正文校验必须一致;明确签入的 UNSIGNED-PAYLOAD 保留原含义。
streaming SigV4 seed 的未签名操作头仍在此检查范围之外。未签名头拒绝继续返回 400 AccessDenied。Console 分享修复未新增全局关闭分享的环境变量,继续执行 S3 授权检查。
密码权限拆分:兼容性变更
Server #175、pkg #6 和 Console #51 将自助修改密码与用户管理分开:
- 内置用户修改自身密码检查
admin:ChangeMyPassword;无显式 Deny 时允许自助操作。 - 创建用户、重置他人密码仍要求
admin:CreateUser;root、STS、服务账号原有身份限制继续生效。 - 单独
Deny admin:CreateUser不再锁住调用者自己的密码;原有Deny admin:ChangeMyPassword开始实际约束这一入口。 - 更新的内置
readonly不再以 CreateUser Deny 覆盖其他策略给予的用户管理权限,并支持独立的consolereadonly能力;已有保存的策略覆盖不会自动重写。
若要保留旧的组合限制,升级前就在同一条 Deny 语句中列出两个 action,保留原 Resource 和 Condition。 回滚旧 Server 时也保留这对 Deny,因为旧实现不能单独执行密码专用 Deny。详见密码权限迁移。
共享策略实现
pkg #4 修复 NotResource 未进入去重哈希、不同字符串条件集合表示碰撞而丢失 Deny 的问题;直接构造的 policy 也能在建立索引前报告 Deny。多星号通配匹配移除指数级递归,保留原有字节匹配与 MatchSimple 边界语义。
因此,过去依赖错误丢弃 Deny 才成功的请求可能开始被拒绝。若错误语句已经从保存的 JSON 中消失,升级无法还原,必须与原策略源比对后重新提交。
IAM 复制、撤销与恢复
删除版本保留在原 IAM 配置路径,覆盖用户、服务账号、组、策略及各类策略映射;普通列表与授权隐藏删除记录。对象存储和 etcd 后端均串行化版本比较和写入,接收端保存来源时间,相同时间删除胜过活记录。
- 延迟的同后端删除通知重新读取已提交状态,避免删除随后重建的身份。
- 同名父身份重建保留
RevokedBefore;新服务账号和内置 STS 携带签名的siloParentRevocation边界。旧子凭据即使被重放或更新,也不能重新获得效力。 - 每个组成员保留自己的授权时间,编辑另一成员或组状态不会顺便给全部成员重新授权。
- 较新的服务账号快照可替换同 key 的旧副本,保留 secret、disabled、绝对过期时间和撤销边界;身份类型冲突报错,过期快照不能延长旧凭据寿命。
- Healing 在失去和重新取得领导权后能恢复;保留已确认版本并以有界批次重试。自然过期的普通 STS 不被无条件转成永久删除记录,服务账号 key 的复用仍保留版本历史。
- 新增 IAM 撤销数量、healing 失败数、耗时和最近成功时间指标,见设计与指标。
跨站点撤销异步传播。父身份删除先于后续清理提交;若后续清理返回 HTTP 500,父身份撤销实际已经生效。墓碑没有 TTL 或自动压缩;存储和启动成本随保留历史增长。删除后组成员的独立增删、同时间活记录冲突、任意时钟偏差和跨站点逐 token 撤销仍有明确边界。
S3 条件操作与多池一致性
跨池协调层先在共享命名空间锁内选择逻辑对象,再判断条件或合并同版本状态。正在退役、排空的池也参与相关状态检查;保留期、legal hold、标签分别按自己的修订排序,不能用对象修改时间代替所有字段的版本。
| 操作 | 本次变化 | 依据 |
|---|---|---|
| 单对象 DELETE | 非空 If-Match 在删除前比较客户端可见 ETag;不匹配返回 412。* 要求选中表示存在且不是删除标记;显式 versionId 比较该版本 |
#145、#178 |
| 普通条件 PUT | If-Match / If-None-Match 针对各池逻辑当前对象判断,避免目标池旧副本接受旧 ETag 或拒绝当前 ETag;当前删除标记视为缺失 |
#207,修复 #199 |
| 条件分片完成 | CompleteMultipartUpload 在同一对象锁下跨池判一次条件,而非仅检查上传所在池 | #190 |
| 普通指定版本 DELETE | 协调所有池的同版本副本,涵盖 UUID、null version、delete marker 和适用的目录标记删除,减少删除后旧副本重新可见 | #188 |
| 对象与元数据修改 | 对象写入、元数据、healing、复制和迁移复用协调锁;保留独立的 Object Lock、标签修订和共享远端 tier 引用 | #178、#189 |
可用性取舍: 任一相关池元数据无法确认时,条件 PUT/分片完成及协调删除可能失败,即使另一池仍可提供 GET/HEAD。读仲裁不足返回 503;恢复可读性后重试。清理失败可能发生在部分磁盘修改之后,没有分布式回滚保证。出站删除复制未完成时会保留等待 purge 的版本。
普通条件 PUT 带目标 versionId 时仍比较当前对象,同时保留指定写入版本。该修复不清理其他池旧副本、不撤销历史错误覆盖,也不新增全局时钟顺序保证。#190 的分片修复与 #207 的普通 PUT 修复是两个独立问题。
未支持的边界: 批量 DeleteObjects 的 XML <ETag> 仍不提供条件保护;空 If-Match 按普通删除处理,内部递归前缀删除不走对象条件。尚无 s3:if-match 策略键,也没有额外的 GetObject 权限检查。需要条件保护时使用支持的单对象路径。详见条件删除与多池一致性。
池迁移保留对象标签
fced8630365f 修复 rebalance/decommission 迁移时丢失对象标签的问题:读取表示把标签放在 ObjectInfo.UserTags,迁移过去直接转交 UserDefined,可能漏掉目标写入所需的规范标签字段。
普通 PUT 与 multipart 初始化四个入口现在从独立的 metadata 副本重建标签字段,保留标签原修订,包括缺失、空值和已有的有序清空状态;迁移不生成新的标签修订,也不修改 reader 的 map。写入与分片完成继续使用现有锁内重读协调,避免旧 reader 越过后来的标签更新或清空。
此补丁不自动恢复历史已丢标签;请单独核对过去迁移过的对象及依赖标签的策略和生命周期规则。
null 版本列表 quorum
8d06424b1 修复新版本少数派掩盖旧版本多数派的列表选择问题。只有原选择未达到 quorum、每条非空磁盘流恰好只有一个普通 null 版本且纠删布局相同时,才按 header 重新计数;不扩大到混合版本历史。
仍有限制: #218 记录并发覆盖期间逐节点重启时,HTTP 200 列表仍可能遗漏可读取对象。不要用该阶段的列表驱动会删除目标多余对象的同步工具;集群稳定后重新列举。
加密对象、校验和与属性
- 原始 SSE-C 副本正确落盘: 已授权复制携带的密文按原样保存,避免目标默认加密或压缩再次变换,造成双重加密或不可读对象(#109、#122)。
- 保存明文分段大小: SSE-C multipart 副本记录逻辑长度,修复 part-number/range 读取、覆盖后对象大小和属性结果(#119、#124)。
- 排除 SSE-C 压缩: 普通 PUT、多段初始化、COPY、Snowball 不再新建压缩 SSE-C 对象,避免解密得到压缩流而非原文;SSE-S3/SSE-KMS 保留相应压缩选项(#118、#126)。
- 支持重传修复: 已存在或不可解码的 SSE-C 副本通过对象重传更新,不误用普通 metadata COPY;保留目标较新的保留期与 legal hold(#120、#134)。
- 密钥轮换遵守显式 checksum: 请求算法时执行必要的完整重写,包含与现有算法相同的请求;多段源可能变为单段目标,ETag 可能变化(#113、#123)。
- 属性返回逻辑结果: GetObjectAttributes 对压缩/加密分段返回明文尺寸,稀疏 part number 的末页正确结束(#114、#115、#128)。
- 不可读对象不冒充成功: 解密或存储完整性失败返回 500,不再以 206 被客户端当作有效读取(#110、#121)。
- 兼容 chunked checksum: 非 trailer 模式的 chunked PUT 即使声明
x-amz-trailer,实际放在普通 header 中的 checksum 仍被采用与验证(#107、#143)。
这些是预防新错误和提供受支持修复路径,不是自动重写历史加密数据。历史压缩 SSE-C、错分段尺寸及不可读副本须保留密钥和精确版本身份,另行读取验证。SSE-C 完整性说明列出恢复边界。
联邦 CopyObject
本节针对旧 etcd 桶联邦的远端转发,区别于桶/站点复制。累计修复来自 #157、#159、#163、#172、#177、#179。
| 过去的问题 | 修复后的行为 |
|---|---|
| 显式 checksum 算法被忽略,空对象或多段源结果错误 | 计算/转发逻辑完整对象 checksum,正确处理空流、trailer、继承摘要;拒绝缺失、畸形或仍带 multipart 后缀的必需远端摘要 |
| inline 源因内部 metadata 被目标拒绝 | 大小写不敏感地移除整类保留内部字段,公共元数据和标签使用正确的公开字段 |
| 加密源的字节或长度错误,可能重复加密 | 读取并转发逻辑明文与长度,由目标按显式 SSE 请求或自己的默认规则加密;SSE-KMS context 作为 JSON 对象传递 |
| 代理的自动加密覆盖目标桶默认值 | 未显式指定 SSE 时保留目标选择 |
| legal hold 被写成用户 metadata 或丢失 | 使用真正的 Object Lock header,保留完整精度的 retention 时间 |
| 返回零 LastModified、缺少目标 version ID,事件对象身份为空 | 响应和事件绑定目标实际提交的写入;不以代理时间或不带版本的后续 HEAD 猜测 |
| 原始 SSE-C replica COPY 的含义不明确 | 在创建目标前明确返回 501 NotImplemented;正常持源密钥的 SSE-C COPY 不受此拒绝规则影响 |
对应问题:#99、#100、#158、#166–#171。远端已提交写入之后仍可能因响应校验失败返回错误;版本化重试前先核对目标实际版本。详见联邦复制契约。
Object Lock、标签和复制恢复
保留与标签独立排序
- 只有 legal hold 的普通写入不再跳过桶默认 retention;ON/OFF 都不能替代保留期选择,显式时间精度保留(#165、#177)。
- 空值与只有时间戳的 retention/legal-hold 删除参与顺序比较,不抹掉更晚的目标状态,也不导致无穷重发(#111、#117、#129、#131)。
- 多池副本写入选择跨池权威 Object Lock 状态,关闭原先只在单个 erasure set 内成立的边界(#133、#178)。
- 副本协调时将标签值与修订一起带回,避免现有标签被空值覆盖;SSE-KMS metadata COPY、SSE-C 轮换保留标签时间;本地 PUT/DELETE tagging 单调推进修订,空标签作为有序删除抵御延迟重放(#189、#193、#196)。
复制双方须更新才能尊重空标签墓碑。带已记录标签修订的对象在显式 resync/heal 时每对象增加一次 metadata COPY;目标按桶默认 KMS 加密时可能重写数据。带标签过滤的复制规则仍按删除后的空标签决定目标资格。历史缺失或畸形修订不会被自动重建。详见Object Lock 排序和复制标签排序。
删除标记、MRF 与 resync
- 单对象删除标记 purge 正确分类,并在 worker、scanner/heal 和持久 MRF 恢复中保留标记身份及重试状态;只在 bucket、key、version、修改时间与任务匹配时接受相应 405 标记响应。审计状态由
COMPLETE统一为COMPLETED(#153、#162、#196)。 - 有界 MRF 队列的丢弃通过管理信息、指标与去重日志可见,并统一对象/删除 worker 分配;这不把有界队列变成永不丢弃的无限队列(#152)。
- resync 在发布 Completed 前应用最后的计数;以实际复制结果而非“目标已存在”判断成功(#136、#139)。
- 按正在同步的目标 ARN 派发;每个 resync ID 使用独立取消上下文,阻塞派发、walker 和 worker 能收尾,旧任务不能覆盖新终态(#141、#137)。
精确版本 purge 与排队创建
eb4f5e5b3 使纯版本 purge 不再向原本没有标记的磁盘创建标记;不存在版本的重试须由写 quorum 多数确认缺失,汇总已删除和可靠缺失的回复。接收端跨池解析指定版本,标记 healing 保留复制和 purge 元数据。
254b19ac0 在复制锁内重新读取排队的标记创建任务:版本已不存在、已非标记或正在 purge 时丢弃旧任务;读结果不能确认时通过 MRF 重试。该检查覆盖 DELETE handler、GET/HEAD/LIST heal、scanner 和 MRF 排队的任务。
358ab38fb 只把实际存储的标记报告为 delete marker,避免将等待 purge 的数据版本错误返回为 x-amz-delete-marker: true 并产生错误事件。
仍有限制: 已发出的创建请求、其他站点的重放,以及多数确认 purge 后崩溃留下的少数磁盘残留,仍由 #217 跟踪。该修复不构成接收端持久删除屏障。
副本元数据正常化
普通元数据提取之后只恢复六项复制专用字段,不再把传输用 aws-chunked 重新持久化为对象 Content-Encoding;可信 Snowball 条目也不继承外层归档的普通对象元数据(#185、#194)。
上述变更不会扫描或重写历史对象,不会自动恢复丢失标签、Object Lock 历史或所有未收敛 purge。请使用只读审计工具与手册处理精确版本、混合 Content-Encoding、歧义分类和受控恢复,不要直接做批量 self-COPY。
桶配置、缓存与启动
PR #180 完成 #77 的来源状态收敛:Policy、Tags、SSE、Quota、Versioning、Object Lock 的读取、比较、修改和保存处于同一 metadata.lock 中。peer 保存来源时间,本地更新在已有时间之后单调推进;同时间删除优先,其余冲突用稳定内容键裁决。
- 专用事件、bulk、import、初次同步和 heal 使用一致规则,省略字段保持既有设置不变;Versioning/Object Lock 的空事件仍不提供删除能力。
- Policy JSON 规范化,已接受的
NotAction/NotResource策略能正确 GET/export;Statement/集合顺序可能变化,授权语义不因此改变。 - 空 Policy 继续按删除处理;零 Quota 保留有值文档,容量不受限制,不能把它悄悄转成不同的删除状态。
- 旧桶缺少 Created 时从物理信息恢复并落盘;接管不把默认空状态误认作删除,未知/无效/不可达来源有有界诊断。
MINIO_SITE_REPLICATION_METADATA_TOMBSTONES默认 off。升级所有参与站点后才统一设为 on 并重启,使 Tags/SSE/Quota 的缺省载荷删除时间对 heal/初次同步可见。关闭期间遗漏删除的收敛仍有边界。
被旧接收时间污染的历史配置不会自动重写,且新增的持久 IAM 状态不支持滚动降级。桶配置收敛说明给出更细的恢复与取舍。
同一版本还修复:桶删除/更新/生命周期读取与缓存发布的剩余竞态;移除把“各字段最大时间”误当整记录版本的 reload guard,使等最大时间下的新字段仍能更新;桶 metadata 导入导出保留 per-bucket CORS(#105、#112、#127、#156、#164)。
#116 补全启动就绪窗口说明及 Linux 重启验证:除检查健康探针外,还应在每个协调节点使用签名 PUT/GET 请求验证数据路径就绪。OIDC 修复见下节。
分片上传发现、分页与取消
PR #198 增加持久化发现,#213 随后恢复保守的默认及回滚兼容性。20260916 的最终默认是 legacy。(SILO 20261018 4733e1423 随后退役了 MINIO_API_MULTIPART_LISTING=strict,仅保留 legacy 缓存/精确 key 列表作为公开模式;参见最新决策。)
| 项目 | 默认 legacy | 显式 strict |
|---|---|---|
| 列表来源 | 已发布行为:精确 key / 节点缓存,原有缺陷仍在 | 跨 pool/set/drive 扫描持久上传状态并验证 quorum |
| S3 列表语义 | 不承诺修复 prefix、delimiter 和跨页发现 | 全局 prefix、delimiter、排序及 marker 分页;原 marker 上传完成/取消后仍可续页 |
| 旧上传 | 保留原有可用行为 | 缺少持久 bucket/key 身份时返回诊断 503,不伪造成功的空页 |
| 取消 | 既有读仲裁、尽力清理和池顺序 | 各相关 set 多数删除确认,支持低于读仲裁的残片重试;已观察残片删除失败不能被空盘掩盖 |
新 writer 在现有 quorum 写入的 xl.meta 中保存保留的 bucket/key 身份,完成上传时删除这些临时字段;没有二级索引或目录格式迁移。两种模式共用的 maxUploadsList 请求上限从 10,000 降至 1,000。它限制传入的页长参数;legacy 缓存路径仍会因忽略分页而返回更多记录。
只通过进程环境 MINIO_API_MULTIPART_LISTING=strict 启用并重启;不支持 mcli admin config set 动态设置。非法值诊断后回落 legacy,不丢弃其他 API 设置。启用前升级所有 writer、排空旧上传、执行需要 admin:StorageInfo 的只读 GET /minio/admin/v3/multipart-preflight,再验证部署容量。旧上传缺失身份可能阻断 strict 下其他桶的列表。
每进程最多两个扫描;每扫描 16 个身份 worker、4 个 metadata worker、30 秒 context,合计目录条目预算 100,000(按跨盘目录条目累计,而非 100,000 个唯一上传)。每页重新扫描持久状态,预算耗尽或不确定覆盖返回 503,而非成功的局部页。临时 10,000 上传、四 APFS 卷试验的一页耗时 18.7 秒,未达到暂定五秒目标。
两种模式都修复错 bucket/key 的 Abort 误清有效缓存,以及解锁后 peer 通知失效的问题。HTTP 层不存在上传仍返回幂等 204。迟到的创建写入仍可能在取消成功后恢复上传,未加入持久创建屏障。 因此 #79 保持开放。详见启用及回滚契约。
Console 2.4.1 的累计改进
除分享代理和密码权限修复,本次内嵌选择还包含从 20260903 之后积累的浏览器、管理 API 和交付改进:
- 对象游标分页: 使用 ListObjectsV2,支持每页 50/100/250/500/1000 项;排序、过滤、选择明确限定当前页,不为总数扫描整个桶;翻页失败保留当前列表,取消后释放列举 worker,rewind 明示行数和时间范围;空或 null versioning 响应不再使列表崩溃。
- 精确操作对象身份: 删除保留 bucket/key/version,目录删除限制在带尾斜线的前缀内;生命周期编辑保留 current/noncurrent、按日期和未展示字段,正确读取旧 prefix 并移除过期 filter 表示;删除复制规则保留共享目标,最后一条规则使用配置删除 API。Governance bypass 对有权用户显式提供、默认关闭,包括仅对象级设置保留期的情况。
- 权限和会话: Create Bucket 按
s3:CreateBucket显示;后台请求传输失败不无条件注销;Watch 参数正确 URL 编码,Inspect 保留结构化错误、不重复读取响应体。 - 嵌入兼容: 修复回环 TLS 登录;只对 Server 明确初始化的 TCP loopback peer 应用嵌入代理信任,独立默认仍为不信任;恢复四个 WebSocket 连接限制配置,避免所有公共浏览被错误压在单源八连接内(Server #108、#147、#148)。
- 流式 ZIP: 有文件 writer 的浏览器以背压写入并支持取消;其余交给原生下载管理器,不在 JavaScript 内存中缓存整个归档。原生交接后的取消由浏览器负责;只在可靠长度可用时显示百分比,大于 5 GiB 或未知大小推荐 mcli。
- 恢复、翻译与无障碍: 畸形路由、持久偏好和渲染异常提供恢复入口,补齐中英文字段、图标控件名称、退出文字和键盘提示;共享下拉框修复快速点击/触摸选到旧悬停项,禁用项不再误选其他值。
- 独立软件包: 增强可复现构建、固定工具及浏览器依赖、SBOM/签名/来源验证。systemd 保留
minio-console.service、console-user、/etc/default/console;证书目录改为/etc/silo-console/certs,需按指南处理旧证书和权限,升级不自动重启服务。
来源:Console #47、#50、#57、2.4.1 注记。
mcli 20260916 的累计改进
镜像捆绑的新客户端包含 20260913 与 20260916 两批变化:
pipe使用已应用的全局 quiet/JSON 设置,重定向输出不混入进度条;空输入保留显式 checksum,非空输入持续流式处理。MC_*布尔量接受on/off/enabled/disabled。- S3 Select
sql、传输、mirror 权限失败、legalhold、递归 retention 部分失败及mv源删除失败返回非零;mv等待删除结果,成功复制的目标不回滚。 - mirror 的逐对象权限错误不会阻止后续对象;有限任务有失败返回 1,失败任务不输出普通成功结尾。显式 summary 的 JSON 标为 failure,极短传输速度保持有限数值;列举/监听的取消重试策略仍有原有边界。
mirror --remove --watch不因源历史版本删除而误删目标;管理重启 dry run 不退化成真正重启,非 TTY 结果可靠。- 空 retention 时长、无效
find --regex正常报错,不再 panic;JSON usage error 不混入不合法格式。 - SDK 识别 HTTP 200 内嵌的 CopyObject 错误,避免
mv误认复制成功后删除源。此修复适用于客户端选用 CopyObject 的路径(通常小于 64 MiB 或显式--disable-multipart),默认大对象路径走 ComposeObject。
脚本应检查最终退出码,而非开始时的 success 消息或进度字节。--skip-errors 不把已经发生的失败恢复成退出码 0;部分 retention JSON 错误文字可能为空。命令、别名、MC_*、配置格式与包名保持兼容。
HTTP、TLS 与可观测性
- 绝对请求头期限: #196 使
--read-header-timeout/MINIO_READ_HEADER_TIMEOUT真正进入 HTTP Server,连接包装器不再让持续小读无限延长 HTTP/1 请求头期限。默认 30 秒;正文保留滚动空闲超时。缩短值也收紧 TLS 握手读取窗口;HTTP/2 保留其独立行为,TLS 写入侧和 handler CPU/存储等待不因此获得新总期限。负值关闭头部专用上限,不推荐使用。设计说明回应 #183。 - 恢复 Go TLS 默认值: 移除 Server 八处显式曲线覆盖,使 listener、节点、身份提供方、复制和 etcd transport 尊重 Go 1.27 的默认密钥交换及相应 GODEBUG 控制;证书、主机名验证保持严格(#164)。这会改变 SILO 在线上发送与接受的内容: 不设 GODEBUG 时,提供的 group 列表变为 Go 当前默认值,额外包含
SecP256r1MLKEM768与SecP384r1MLKEM1024;设了GODEBUG=tlsmlkem=0时,该开关现在真正生效——作用于整个进程,出站与入站监听器同样适用。#154 已关闭,但报告人 2026-09-17 在本版本上的复测仍然失败;discovery/JWKS 总期限仍为独立事项。参见 TLS/OIDC 说明 与《写死 TLS 参数与握手兼容性》。 - CPU 指标竞态:
loadCPUMetrics对外层和嵌套 map 加读锁,防止 Prometheus 抓取与资源更新并发导致整个进程退出。修复已随 #214 进入 main;独立 #215 以替代方式关闭,问题 #210 也已关闭。 - 配额和日志指标: 使用生效的 BucketQuota 字段;
AddSystemTarget幂等,串行化 logger 初始化和 console target 发布,防止日志订阅后重复 collector 破坏/logger/webhook指标(#106、#150、#132、#151)。
依赖、构建与工程维护
amqp091-go从 1.10.0 升至 1.14.0,包含恶意 AMQP frame 过量分配修复;SILO 中可达性需要配置 AMQP 通知目标(#176、#177)。- JWX 3.3.0 修复自定义 JSON 字段名转义;strfmt 0.27.2 修复 Go 1.27 主机名验证;LZ4 4.1.30 修复 reader、部分读取及并发行为,本版增加 Snowball/S3 Select 集成回归。
- 上游 SDK 累计包含可配置上传限制、streaming Content-Type 签名、RDMA 使用调用方 TLS 信任、列表 checksum 与可选 restore status 一致性,以及前述 HTTP-200 CopyObject 错误处理。见上游采纳记录。Console 同时更新 React Router 7.18.4,并保留 js-yaml 与 smol-toml 的拒绝服务修复及重建后的嵌入资源。
- Go x/* 依赖刷新;保持 Go 1.27.1、NetBSD 所需的 go-systemd 22.6.0 replacement 和现有 tablewriter 兼容选择。
- 容器基础镜像 digest 更新,双 Linux 架构从校验源码构建静态 curl 8.22.0;客户端安装器和镜像固定 mcli 20260916 的实际已发布归档及 SHA-256。
- 本版固定 GoReleaser 2.18.1 与 Action v7.2.3 的完整 commit;依赖文件改动也触发 Test Release Pipeline。tag 源码附带 Helm Chart 7.0.3 和新 Server/mcli 默认标签;没有单独的 Chart 发布。
- 修复条件 PUT 与标签复制测试夹具竞态,恢复集成脚本有效凭据;将相应 CPU、标签、multipart 和 S3 Select race 回归纳入 CI。
- 持续校正 rebrand guard,明确 AGPL-3.0-or-later、DCO 和无 CLA 的贡献规则,保留实际人类贡献记录;README 仓库卡片增加每日更新流程。
- 正式产品、升级、安全和设计文档集中在本站;源码仓库保留入口、必要兼容文档和测试材料。迁走临时调查与旧安全工作资料,历史证据以固定提交链接保留。
9 月 4 日的 Helm 正式镜像标签校正、mcli 归档 pin 和容器恢复发布完成了上一版的交付。
撤回的实验功能
GET 访问频率驱动的池间 ILM 分层 #60 于本周期合入,随后由 #188 移除。它从未进入正式 Server 20260903,也不属于本版的新增功能。 移除 tracker、mover、scanner hook、专用配置/XML action 和指标;十个旧 ILM 键兼容接受但忽略,读取 v8/v9 usage cache 并写回不带退役字段的 v8。
使用过中间开发构建的部署应移除 access-only 规则,编辑混合规则时保留普通动作。普通过期、远端 tier、rebalance、decommission 继续存在;独立的多池 Object Lock、条件删除、写入协调和共享远端引用保护保留。迁移说明解释具体范围。
升级与恢复要求
- 固定最终组件和制品。 实际 Server、Console、mcli、包、镜像和 Chart 应逐一核验身份。官方镜像名称统一使用
docker.io/pgsty/silo、docker.io/pgsty/mc和docker.io/pgsty/silo-console。 - 提前迁移密码策略并核对原始 Deny。 要维持旧组合限制,在同一 Deny 中保留 CreateUser 与 ChangeMyPassword。对曾丢失语句的策略从原始来源恢复。
- 安排协调维护窗口。 暂停 IAM 修改,隔离未知离线站点;升级所有参与节点/站点,包括共享 IAM 后端但未启用站点复制的节点。不支持同后端新旧混跑或滚动降级。
- 保存完整恢复点。 备份 IAM 后端及加密材料;
mcli admin cluster iam export只包含活记录,不能备份删除历史。恢复前停止相关进程,防止继续使用旧缓存/peer 确认。 - 逐入口验证身份与数据。 旧撤销凭据应拒绝,新用户、服务账号和新签发 STS 应在每个节点有效;验证签名 PUT/GET、对象字节、版本、标签、保留期和复制收敛后再恢复业务流量。
- 回滚须调和备份后的撤销。 恢复旧快照会丢失后续撤销;在每个恢复站点逐一调和并检查全部端点,必要时改用不同名称的新身份、重新签发凭据。不承诺恢复前 STS 会话连续性。禁止在线删除 tombstone 或只转换活记录来强行启动旧版。
- 分别启用两个可选行为。 桶配置 tombstone export 只有全员升级后才打开;strict multipart 还要求所有 writer 更新、旧上传排空、预检和容量验证。正常升级可继续使用默认 legacy。
- 处理开发版本残留。 若运行过写入共享
api multipart_listing的中间构建,备份后用mcli admin config reset ALIAS api multipart_listing定向清除,保留其他 API 设置;不要重置整个子系统,也不要重放含旧键的历史。使用过访问频率分层的部署按撤回指南清理。 - 检查出站链路上的 TLS 检查设备。 握手内容在 20260903 随 Go 1.27 工具链变过一次,本次再次变化。SSL 检查 NGFW、SASE 网关、IDS/IPS,或按 JA3/JA4 客户端指纹做白名单的环境,都可能让身份提供方、webhook、远端分层或复制连接以
connection reset by peer被重置,而同一容器里的curl却正常。升级后请验证 OIDC discovery 与 JWKS、审计与通知目标、远端分层和复制端点。处置办法与代价见《写死 TLS 参数与握手兼容性》。 - 单独审计历史对象。 盘点不可读 SSE-C、错误分段尺寸、标签/锁状态、
aws-chunked污染、错误覆盖和待 purge 版本。经批准的恢复清单应在副本环境验证。
操作依据:IAM 升级与恢复、历史副本审计、密码迁移、分片列表契约。虽然对象数据与纠删码布局未变,但新增的持久 IAM 状态不支持滚动降级回旧版 Server 二进制。
已知限制与未完成事项
| 项目 | 本版边界 / 当前处置 |
|---|---|
| #79 分片列表 | 已有可选 strict 实现,legacy 仍默认;生产容量、迟到创建写入屏障和完整交付验收未完成 |
| 批量条件删除 | XML ETag 仍无保护;空头、递归扩展和策略强制不属于已实现契约 |
| Streaming SigV4 | 除普通与预签名 SigV4 修复外,streaming seed 的操作头覆盖仍待后续处理 |
| IAM 与桶配置历史 | 丢失修订/撤销不能自动重建;时钟、组成员局限和历史增长成本仍需运维管理 |
| #197 两盘 EC 文档 | 仍开放 |
| 其他开放提案 | #20 Renovate、#30 Operator、#40 通知目标热加载、#61 KES/OpenBao 规划、#160 图片处理仍保持开放 |
| 历史迁移标签 | 先前迁移已丢失的标签不会自动恢复,需单独核对依赖标签的策略和生命周期规则 |
| #217 / #218 | 在途/跨站点创建、崩溃残留与重启期间列表遗漏仍未解决;范围见上文 |
验证与发布状态
发布源码 2a4d51406b7e 的 Go CI 和 Test Release Pipeline 已通过。Release 构建和 Finalize 已完成。GitHub Release 提供 36 个资产,包括六平台归档、Linux 软件包、SBOM、校验和与证明材料。
Console v2.4.1、mcli 20260916 和 pkg v3.14.1 已分别发布。2026-09-17 复核时,独立 docker.io/pgsty/silo-console 的匿名 token 请求返回 HTTP 401,尚不能确认公开拉取;可使用 Console 的 GitHub 二进制或软件包。Server 内嵌 Console 的源码选择不受此独立镜像分发状态影响。
问题索引与贡献
本版已修复或在明确范围内完成的问题组为:条件操作 #10/#133/#144/#199;联邦复制 #99/#100/#158/#165–#171;桶元数据 #77/#105/#112;加密与属性 #107/#109–#115/#117–#120;嵌入 Console #108/#147/#148;复制调度恢复 #136/#137/#139/#141/#152/#153/#185;运行与指标 #106/#116/#150/#154/#176/#183/#210。池迁移标签补丁以直接 main 提交列示;#79 保持部分实现而非关闭。编号详情和限制见上文各节链接。
感谢报告、补丁与复核参与者,尤其是 @mrjavadseydi(配额、分片列表与分层探索)、@nikitapogromsky(logger 幂等)、@Aeirx(联邦 legal hold)、@magicxor(条件 DELETE 早期方案)、@julienlau(purge/MRF)、@chodorenko(复制编码)、@orenyomtov(签名头报告)、@jiri-pejchal(Console 分享)、@AEGEGE(HTTP 报告)、@aschyolkin(CPU 竞态),以及 @vampywiz17、@kh0mka 等复现和反馈参与者。
PR #12、#125、#155、#184、#187、#195 等原始提交已由后续实现替代。完整署名见贡献记录。
2.12 - mcli 20260916 发布
版本: RELEASE.2026-09-16T00-00-00Z · 软件包: 20260916000000.0.0 · 源码: e952aa78f10a。
相对 20260913 的变化
- 接入 silo-pkg v3.14.1、上游 minio-go v7.3.1-0.20260915093545-32e1f32cb176 和 JWX v3.3.0。CopyObject 会重试并报告 HTTP 200 响应内嵌的 S3 错误,避免复制失败后授权
mv删除源对象;JWX 正确转义输出 JSON 的字段名。 mirror报告权限失败,包括源不可读、目标写入拒绝及本地目标删除失败。后续对象继续处理,有限任务有失败就返回 1;这些逐对象权限错误不要求--skip-errors,也不会在 watch 模式中重启整个扫描。- 列举与监听失败保留原有取消重试路径。持续拒绝列举权限仍可能导致反复重扫,本次没有重写该重试策略。
- 失败的 mirror 不再输出普通成功结束统计;显式
--summary仍输出统计,JSON 使用status: failure,文本错误诊断仍位于 stderr。同一时钟刻度内完成的极短传输不再产生无穷大的 JSON 速度值。 - legalhold set/clear、递归 retention 部分失败、
mv源删除失败都返回非零。mv等待删除结果,已成功复制的对象不回滚;retention 每个失败对象只输出一次诊断,避免重复或误导提示。 - 空 retention 时长与非法
find --regex表达式正常报错,不再 panic。
脚本兼容性与升级
上述曾返回 0 的失败路径现在返回 1。 请检查最终退出码与错误记录。mv 删除失败可能使源和已成功复制的目标同时存在,重试前应核对两端状态。
配置格式、别名、MC_* 变量、命令与包命名保持不变。pkg v3.14.1 保留 v3.14.0 引入的密码权限语义。见组件版本矩阵与密码权限迁移。
正式支持和管理接口验收以 SILO 为目标;对原厂 MinIO 与其他 S3 服务保持尽最大努力的兼容。
交付与验证
本次发布包含 19 个资产:Linux/macOS/Windows 的 amd64/arm64 共六个归档,Linux 两架构的 RPM/DEB/APK 共六个包,一个归档校验清单,以及六个包校验文件。归档、清单与 DEB/APK 通过 Sigstore 构建证明绑定到签名 tag 与精确源码;RPM 携带 PGSTY GPG 签名(9592A7BC7A682E7333376E09E7935D8DB9BD8B20)。
最终发布提交通过 Linux/macOS/Windows race 测试、Linux 386 测试、交叉编译、漏洞扫描和发布打包流水线。发布前还验收了已发布 SILO 20260903 与修复后的 Server 源码 3c26a8b0b5bd、Object Lock 与权限失败、CopyObject 故障注入及双架构 RPM/DEB/APK 安装升级。扫描未发现可达漏洞或已导入包漏洞,仍保留未使用 OpenPGP 代码的模块级提示。
CI: VulnCheck · Test Release Pipeline · Crosscompile · Go · Release · Docker
docker.io/pgsty/mc 版本标签和 latest 对应同一经过验证的 Linux amd64/arm64 manifest:
这项 HTTP-200 CopyObject 修复适用于 mcli 选择单次 CopyObject 的路径:对象小于 64 MiB,或显式指定 --disable-multipart。64 MiB 以上的复制默认走 ComposeObject 路径。
JWX 的具体范围及 GHSA-4cf7-xm37-g63h 见 pkg 3.14.1。--json mirror 失败后若未指定 --summary,不会补一条尾部统计记录;--skip-errors 也不会把已经发生的失败恢复成退出码 0。部分 retention JSON 失败记录只有 status: failure,错误对象可能序列化为 {},消费方不能强制要求人类错误文字存在。
2.13 - mcli 20260913 发布
版本: RELEASE.2026-09-13T00-00-00Z
· 软件包: 20260913000000.0.0
· 源码: 4f609a4d。
相对 20260903 的变化
mirror --remove --watch保留目标端历史版本。- 服务重启的 dry run 只显示计划,不执行重启;明确非交互环境行为。
- 传输与 S3 Select 出错时返回失败;空上传遵守显式校验和参数;quiet 模式下仍输出
pipeJSON。 - 布尔环境变量接受 on/off,修复跨平台 CLI/JSON 行为。
- 使用 pkg v3.14.0 与上游 SDK
v7.3.1-0.20260910142817-60bd07042d49,刷新 Go x/*、UBI 依赖,保留 Go 1.27.1。NetBSD 继续固定 go-systemd v22.6.0。
保留 pkg v3.13.3 引入的 Deny/NotResource 与有界通配匹配修复。 正确保留的 Deny 可能拒绝曾依赖丢失语句而成功的请求;已经丢失的语句仍需从原始策略恢复。
新 pkg 还改变密码能力。协调升级 Server 前请阅读密码策略迁移。 配套密码界面已随 Console v2.4.1 发布,协调升级见组件版本矩阵。
制品与验证
不可变发布包含 19 个附件:Linux、macOS、Windows 的 amd64/arm64 共六个归档包, 两种 Linux 架构的 RPM/DEB/APK,以及校验清单和软件包校验文件。 归档与校验清单已核验构建证明,RPM 携带 PGSTY GPG 签名;macOS arm64 原生执行版本输出与 Go 构建信息已核对。
docker.io/pgsty/mc 的发行标签与 latest 指向同一份已验证的 amd64/arm64 manifest:
容器发布流程 已完成,两种架构镜像均经拉取和版本检查。 打标签前 Go、交叉编译、漏洞与 Test Release 门槛均通过。 扫描没有发现可达或已导入的易受攻击包;未使用的 OpenPGP 仍有模块级 GO-2026-5932 告警。 与上游 MinIO、其他 S3 实现的兼容属于尽力保留;正式管理集成目标是 SILO。
pipe 的非空标准输入持续流式读取到 EOF;空输入使用普通零字节 PUT,而不是未知大小的 multipart。显式校验和参数通过同一路径传给上传实现。quiet/非 TTY 模式下禁用进度条,同时对空输入与非空输入统一保留 JSON 结果输出。
2.14 - silo-pkg 3.14.0 发布
v3.14.0 于 2026-09-13 发布,源码提交为 827f8109,模块导入路径为 github.com/pgsty/silo-pkg/v3。
密码策略迁移
本版调整了密码修改的授权语义:
- 公共方法
Policy.IsAllowedActions默认放行admin:ChangeMyPassword(除非策略显式Deny),而创建或管理他人的admin:CreateUser仍必须显式Allow。在配套升级后的 Server 与 Console 中,仅拒绝admin:CreateUser不再禁止调用者修改自己的密码;若需禁止自助改密,须显式拒绝admin:ChangeMyPassword。 - 内置
readonly策略移除了原先的admin:CreateUserDeny规则(若同一主体还绑定了另一份授予admin:CreateUserAllow的策略,该授权将正常生效)。新增的consolereadonly策略包含存储桶列举能力,并采用相同的权限拆分;两份内置策略本身均不授予任何用户管理权限。 - 集群中已保存的自定义策略与用户级权限覆盖不会自动改写。
如果希望维持旧版“同时禁止创建用户和自助改密”的限制,请在升级前将 admin:CreateUser 与 admin:ChangeMyPassword 写入同一条 Deny 语句(保持原有的资源范围与条件),并在整个回滚窗口期内保留该配置——旧版 Server 不会在改密端点上单独识别 admin:ChangeMyPassword 的拒绝规则。详见密码权限迁移指南。
后续配套版本(9 月 16 日): Console v2.4.1 已发布配套密码管理界面,并搭配 silo-pkg v3.14.1 与 mcli 20260916。全栈协调升级要求见组件版本矩阵。
依赖与验证
- 上游
minio-go升级: 锁定至v7.3.1-0.20260910142817-60bd07042d49,纳入可配置上传大小上限、流式请求Content-Type签名、RDMA 调用方 TLS 信任传递、列举操作校验和一致性以及归档恢复状态解析修复。 - 工具链与依赖刷新: 更新
golang.org/x/*依赖,维持 Go 1.26 最低版本要求与 Go 1.27.1 构建工具链,所有导出 Go 函数签名保持不变;维持go-systemd v22.6.0replacement 以确保 NetBSD 交叉编译兼容。 - 测试与安全扫描: 通过 Go 1.26.8 与 1.27.1 全量
-race测试、lint 及 LDAP 配置校验,并确认可通过 Go module proxy 与校验和数据库正常拉取。govulncheck 1.8.0扫描确认无已导入的可达漏洞包(仅未导入受影响子包的 OpenPGP 模块保留GO-2026-5932记录)。
本版继续包含 v3.13.3 的策略 Deny/NotResource 序列化修复与有界通配符匹配算法(此前因旧版 bug 丢失语句的策略仍需从原始备份恢复)。自 v3.13.0 引入的 github.com/pgsty/silo-pkg/v3 模块路径迁移已在四个维护组件中全部完成。
2.15 - SILO Console 2.4.0 发布
v2.4.0 于 2026-09-08 发布,
源码为 c103d08e。
已发布行为
- 对象目录默认每页 100 条,可选 50、100、250、500、1000。每页只发一次 S3 continuation-token 请求;排序、筛选和选择作用于当前页,不扫描整个目录,没有无限制的“全部”模式。
- 版本删除绑定选定对象,目录删除限制在其前缀内。生命周期编辑保留独立动作和界面未暴露的字段,复制规则删除统一保存。
- 暂时网络故障不清空会话;处理空版本控制状态、损坏的侧栏偏好与过时的下拉选择。
- Server 显式启用嵌入信任策略时恢复 Console loopback 代理行为,独立部署信任默认值不变。
- 依赖 pkg v3.13.3、MC 源码
v0.0.0-20260908140805-c8aa5d25a63a与上游 SDK0e78d3f18efe。本版只引用 MC 源码,本身没有发布新的 mcli 标签。
修复后的 Deny/NotResource 语句保留行为可能拒绝曾因去重缺陷而成功的请求。 已经丢失的语句需要从原始策略恢复。嵌入方必须复制标签对应 README 的 MC replacement。
后续版本
Console v2.4.1 提供共享下载限制、密码权限拆分、 多对象流式 ZIP、浏览器恢复与可验证发布制品,使用 pkg v3.14.1 和 mcli 20260916。 v2.4.0 使用较早的浏览器内存 ZIP 路径。升级前请阅读密码权限迁移。
v2.4.0 精确发布源码通过完整 Console CI。变更详情见 标签 changelog、 对象浏览器指南与 发布附件。
2.16 - Silo Console 2.2.0 发布说明
发布日期: 2026-08-26 · 版本: v2.2.0(7dc4258a6)· 仓库: pgsty/silo-console
SILO Console 2.2.0 新增面向日志与结构化文本的有界预览,收紧下载错误处理以防止截断的压缩包被当作完整文件交付,修复数据表格行操作的权限门控,将用户启停状态与用户组变更拆分为独立 API,并在写入前校验数据库通知 DSN 与 IAM 策略文档。
在底层,依赖栈迁移到 Go 1.27.0,并切换到 SILO 维护的组件:pgsty/silo-pkg 3.12.1 取代上游 minio/pkg,pgsty/mc 取代上游 mc 库依赖,minio-go 升级到 7.3.0,etcd client 系列升级到 3.7.1,修复 CVE-2026-73500。
自 v2.1.1 以来的最终变更集共 33 个提交,涉及 176 个源文件(+8,794/−2,057 行,不含重新生成的嵌入式前端资产)。
为什么版本号是 2.2.0: 本周期切换了共享策略与证书实现的依赖提供方、跨越了 etcd client 的 minor 版本边界、拆分了一条 REST 路由,并调整了文件夹下载的失败语义。
日志与结构化文本预览
对象浏览器现在可以把 .log、.txt、.json、.xml 对象作为纯文本预览。实现刻意保持多疑——「在管理界面渲染桶里的任意内容」本质上是一份 XSS 邀请函:
- 构造上就有界。 请求携带
Range: bytes=0-1048576;响应以流式读入固定缓冲区并设硬上限,Content-Range/Content-Length与实际读到的字节严格互验。超过 1 MiB 的对象报告过大——绝不会把片段当成完整内容渲染。 - 要么是文本,要么不渲染。 字节必须通过严格 UTF-8 解码(
TextDecoder开启fatal)且不含 NUL;否则报告不可预览,而不是渲染乱码或二进制垃圾。可预览性是精确白名单——四种扩展名加精确的 MIME 类型text/plain、application/json、application/xml、text/xml——.html/.htm/.xhtml被显式排除,即使其元数据自称text/plain。 - 没有活动文档。 内容渲染进单个 DOM 文本节点——不用
innerHTML、不用 iframe、不做 JSON 到 HTML 的变换。回归套件投喂 HTML、SVG、XML 载荷并断言它们保持为惰性文本。 - 对竞态免疫。 快速切换对象时,进行中的预览按代际失效,慢返回的旧对象响应不可能覆盖当前对象。取消与重试都是一等状态。
- 匿名可用。 公开/匿名对象页面通过匿名请求路径获得同样的预览,不触发任何带凭据的 API 调用。
围绕新预览类型,既有预览管线也一并修正:空对象预览为空而不是报错,追加写入的日志按新长度重新预览而不是按过期的列表尺寸截断,快速切换对象时的元数据竞态已消除。预览与分享对话框现在拿到对象的真实大小(92e8f4e65)。
下载完整性与进度计算
下载路径(进度上报、单对象、文件夹、归档与字节范围)统一改为严格校验完成状态与错误返回。
未知总大小时的进度显示
缺失、非法或自相矛盾的总大小不再变成编造的百分比:进度指示器保持不确定态,直到真实总量已知。零字节对象被显式归一化而不是落进未知总量路径;中止与取消是终态——迟到的进度事件不可能复活一个已取消的下载。下载请求恰好结算一次,JSON 错误体被安全解析,生成的对象 URL 会被回收。
流式文件夹下载
单文件夹下载改用浏览器原生的流式下载路径,不再把整个 ZIP 累积在 JavaScript 内存里——几个 GB 的文件夹不再有标签页崩溃的风险。交接模型的一个后果:控制台的传输管理器在把流交给浏览器的那一刻即报告完成(有 toast 说明),此后由浏览器自己的下载 UI 接管——交接后在控制台里取消不会停止浏览器侧的传输。多选下载仍走既有的 POST 响应,因此仍是内存 Blob;改变这一点需要单独的 API 决策,不在本版本范围内。
ZIP 完整性——一项刻意的行为变更
这是最可能被当作「下载坏了」上报的变更。 在 2.1.1 及更早版本中,文件夹/ZIP 下载若有对象读取失败,会静默跳过这些对象,交付一个 HTTP 200 但缺文件的归档。2.2.0 中任何单对象失败——列举、stat、读取、条目创建、关闭或拷贝——都会中止归档:尚未发送任何数据时返回干净的错误,流传输中途则中断连接,截断的 ZIP 再也不可能冒充完整归档。
实际后果:策略只授予某前缀的 List 而 GetObject 只覆盖其中一部分对象的用户——一种常见的 IAM 配置——以前会拿到局部归档;现在下载会在第一个被拒对象处失败。旧行为是静默的数据缺失,2.2.0 把它当作 bug 对待。请下载你能读的内容,或把文件夹下载限定在可读前缀内。
字节范围与状态码
- 畸形或不可满足的非空
Range头现在返回416并携带Content-Range: bytes */N,而不是 500。Range 解析比 Go 的宽容默认更严格:带符号、内嵌空白、bytes=-0、空 range 元素都会被拒绝。 - 对零字节对象的 range 请求返回空 200 而不是 500——这正是空对象预览 bug 的根源。
- 惰性对象
Stat的失败现在透传真实的 S3 状态码(403、404……)而不是一律 500。 206响应在写响应头之前设置Content-Length,部分响应从此携带正确的帧信息。- 对象大小现在总是序列化——REST 与 WebSocket 列表对零字节对象返回
"size": 0而不是省略字段,UI 显示0 B。这是「诚实进度」赖以成立的地基。
版本历史
S3 合法的 null 版本 ID 保持可见而不被丢弃,版本计数把前缀匹配过滤到精确对象,桶版本控制被暂停或禁用后历史仍然保留。
真正生效的权限门控
行操作按钮从未被禁用过
每个带数据表格的页面都把权限谓词传给了一个表格组件早已不读取的属性名(disableButtonFunction)——于是 10 个受影响页面(用户、用户组、策略、IDP、Webhook 设置、桶的访问/复制/生命周期面板)上的查看/编辑/删除按钮无论有无权限一律可点。谓词现在接到组件真正接受的属性上,并有一个源码守卫测试:死属性名再次出现即构建失败。服务端授权从未受影响——没权限点了也只会报错——但 UI 从此如实传达权限,而不是撒谎。
独立的服务账号能力
访问密钥管理过去把多个 UI 决策挂在一个合并的权限检查上。2.2.0 在一个模块中推导四项独立能力——List、Create、Update、Remove——并在自助 Account 页面与管理员的用户详情页面一致地应用:
- 能 list 才显示服务账号标签页,能 create 才显示创建按钮,能 remove 才允许行选择与批量删除,能 update 才显示编辑铅笔;
- View 现在是真正的只读:所有字段禁用、没有提交路径、回车键失效——以前「查看」打开的是可编辑对话框;
- 会话通告的 Create Access Key 能力对请求作用域策略的计算修正为:以
svc:DurationSeconds为条件的Deny(一种只有在具体请求存在后才能求值的有效期限制)保持该能力可见——包括通配符管理动作——而无条件或登录期即可判定的 deny 现在会正确隐藏它。最终授权仍由 SILO 在请求时裁决。旧代码对任意条件 deny 都保持能力可见,属于过度通告。 - 新增一条 OIDC 创建/列举/查看/删除的集成回归,走 UI 的显式凭据端点,并断言预期中的自我更新拒绝。
写入前校验 IAM 策略
命名策略与服务账号策略写入现在会在调用 Admin API 之前拒绝畸形文档和裸 S3 资源 ARN,并返回客户端错误。为兼容历史数据,旧策略读取仍保持宽容;但不兼容的已存策略必须先修正,才能再次保存。严格解析与资源检查位于控制台自身写入路径,而不是导入分支专有的策略 API,因此仍能维持对上游 minio/pkg v3.6.1 的源码构建下限。
匿名页面回归匿名
匿名对象浏览页不再发出只会产生 Access Denied 噪音的受保护 Object Lock/保留策略请求,并补上了语言(文/A)与暗色模式切换控件。
用户状态与用户组从此是两个操作
切换用户的启用/禁用状态与编辑用户组过去是一条合并的 PUT /user/{name} 调用,两个载荷都必填,且合并的权限门控导致仅仅编辑用户组也要求 admin:EnableUser。2.2.0 将其拆分:
PUT /user/{name}/status(新增)只改状态,校验取值为enabled|disabled,并拒绝当前登录用户启停自己。失败的切换不再让开关脱离同步——UI 反映服务器的答案,而不是乐观翻转。PUT /user/{name}/groups(既有)成为用户组编辑的唯一调用,不再要求admin:EnableUser。PUT /user/{name}在线协议上原样保留,作为已弃用的兼容端点服务既有 API 消费者;未知状态在该端点现在返回 400 而不是 500。
API 契约细节(含重新生成 Swagger 客户端的注意事项)见兼容性。
所写即所存的数据库通知表单
PostgreSQL 与 MySQL 事件目标表单围绕一个共享的 DSN 解析器/序列化器重写:
- 结构化字段与原始连接串是同一份状态:原始串在结构化字段被编辑前保持权威;一旦编辑,就重建规范化 DSN——PostgreSQL 使用 libpq 关键字/值引用规则,MySQL 使用感知 IPv6 方括号的
go-sql-driver格式。模式切换不再破坏手工输入的字符串。 - 生成的预览掩码凭据;掩码不可能进入 API 载荷(载荷始终使用原始连接值)。
- 保存要求连接串与表名同时存在,清空的值会真正传播。
- 服务器现在拒绝其配置语法会破坏的 DSN——内嵌换行、会被解析成兄弟配置键的值(比如密码里含
table=)、不平衡的引号——在存储之前返回 400,且不把提交的机密回显在错误里。 - 各通知目标(Kafka、Redis、MQTT、NATS、Webhook)的通用密码与令牌字段渲染为密码输入框,包括被环境变量覆盖的值。
准确的待重启状态
配置的新增、更新、删除、重置现在尊重服务器实际返回的是否需要重启,而不是硬编码「需要重启」(或者更糟——弄丢它)。待重启标记是单调的:一旦有操作要求重启,后续不要求重启的操作不能清除它——只有真正的服务重启才能。
上传提示
超过 5 GiB 的浏览器上传会收到不阻断的提示:控制台以单个不可续传的请求上传,这个量级应该用 mcli 的分片上传。
会话、指标认证与 i18n 加固
- 会话快速失败。 匿名/空会话立即得到 401,而不是挂在一个空凭据的 Admin 请求上。控制台同时接受规范的
401与旧式403无效会话响应;过期会话的重定向感知子路径——部署在/console/下的控制台在自己的基路径内重定向。 - Prometheus Basic 认证全路径生效。 健康检查与根路径回退探测现在都发送 Basic 凭据(以前只发 Bearer,导致 Basic 认证的 Prometheus 让所有仪表盘组件被禁用),Bearer 令牌保持优先,且所有响应体都被排空,keep-alive 连接得以真正复用。
- 占位符替换在构造上转义安全。 翻译占位符填充使用一趟式字面格式化器:值中的
$&、$1、$`、反引号、花括号保持字面,重复占位符全部填充,并有一个 AST 源码守卫测试禁止不安全的String.replace模式回归。
工具链与依赖
Go 1.27 基线
| 组件 | 2.1.1 | 2.2.0 |
|---|---|---|
go 指令、构建镜像、CI 矩阵 |
1.26.5 / 1.26.x |
1.27.0 / 1.27.x |
golang.org/x/crypto |
v0.54.0 | v0.55.0 |
golang.org/x/net |
v0.57.0 | v0.58.0 |
golang.org/x/text |
v0.40.0 | v0.41.0 |
golang.org/x/mod |
v0.37.0 | v0.40.0(关闭 CVE-2026-56864/-56865) |
golang.org/x/tools |
v0.47.0 | v0.49.0 |
SILO 分支
silo-pkg3.12.1 是正式发布依赖。pgsty/silo-pkgv3.12.1 已于 2026-08-25 发布,控制台直接固定它。它建立在 3.12.0 之上,继承策略资源边界加固、条件键查找、LDAP 与证书监视器修复,以及 Go 1.27 / etcd 3.7 依赖基线。mc库依赖切换到pgsty/mc分支,使用日期式伪版本;导入路径不变。require行刻意停留在上游v3.6.1。 Go 会忽略依赖模块中的replace指令:下游模块 require 本控制台时,解析到的是上游minio/pkg,而 SILO 的v3.12.1标签在上游并不存在。要求真实上游标签保证控制台对下游可解析;replace 只在控制台自身构建时应用分支。最终 CI 针对上游 v3.6.1 验证了公共源码构建表面。注意这只是编译层兼容——没有自带顶层 replace 的下游构建得到的是上游行为,不含分支的 SILO 专有 IAM 语义。go-systemd回钉到 v22.6.0:v22.7.0 在 NetBSD 上使用了那里不存在的CLOCK_MONOTONIC,无法编译;钉住直到上游修复发布。
etcd 3.7.1——仅客户端库
三个 etcd Go 模块一起从 3.6.8 移到 3.7.1,关闭 TLS 监听器拒绝服务漏洞 GO-2026-6107 / CVE-2026-73500。etcd 3.7 移除了遗留 protobuf 实现并使 clientv3.New 变为非阻塞——这些迁移只影响自行构造客户端或内嵌服务器的消费者。控制台两者都不做:它唯一的 etcd 路径是 silo-pkg/quick 操作一个已创建的客户端,只用普通的 v3 Get/Put。本次升级只涉及编译进二进制的客户端库——不触碰运维方的 etcd 服务器、集群数据或部署拓扑;服务器升级到 3.7 仍需遵循 etcd 官方的逐 minor 升级流程。
第三方维护保持克制
最终版本在独立兼容性审查后把 minio-go 升级到 v7.3.0,迁移其 INI 导入路径,并增加 lifecycle-filter XML 兼容覆盖。控制台侧的兼容解码器可以接受旧服务器返回的 legacy AccountInfo tag payload。其他已接受的维护变更包括 jwx v2→v3 与 httprc v3、go-openapi/swag/conv+typeutils 0.28.0、grpc-gateway 2.29.0、cheggaaa/pb 1.0.30 和 go.yaml.in/yaml/v3 3.0.5;无关的更大规模 go-openapi、pb/v3、压缩库与测试库升级仍然推迟。
前端方面,漏洞检查工作流扩展到 push、手动触发与开发依赖,使用不可变安装;刷新了有漏洞的传递依赖解析(fast-xml-parser 5.11、nanoid 3.3.18、@babel/core 7.29.7),移除了死导出与未使用的 http-status-codes 依赖。fast-xml-parser 5.x 的传递依赖树仅用于开发阶段,不进入浏览器构建产物。
安全评审
- 发布树上的
govulncheck:零个可达的漏洞符号,零个受影响的导入包。Swagger 构建工具单独扫描,同样干净。 - 依赖迁移关闭的漏洞:CVE-2026-73500(etcd TLS 监听器 DoS)、CVE-2026-56864 / CVE-2026-56865(x/mod 校验)。
- 仍会报告、但依旧不可达:模块级 GO-2026-5932 openpgp 公告——控制台不导入
x/crypto/openpgp,且该公告没有修复版本。 - 文本预览按 XSS 攻击面评审(见上文);其回归套件包含活动载荷测试。
- 权限门控修复属于 UI 真实性修复:2.1.1 中服务端授权从未被绕过;只是控制台显示了不该显示的控件。
兼容性
部署层面一切不变: 环境变量、配置格式、命令、二进制名、systemd 单元、端口、嵌入数据布局均无变化。发布二进制保持自包含;Go 1.27.0 仅是构建期要求。
HTTP API 契约(控制台自身的 REST API):
| 路由 | 变更 |
|---|---|
PUT /user/{name} |
线协议不变;标记弃用。OperationId 由 UpdateUserInfo 改名 UpdateUserInfoLegacy,请求体模型改名 legacyUpdateUser(结构相同)。未知状态:500 → 400。 |
PUT /user/{name}/status |
新增。 仅状态请求体(enabled/disabled 枚举,违反返回 422),返回的用户对象只含访问密钥与状态——客户端不得从中读取用户组数据。 |
updateUser 模型 |
变为仅状态且带枚举——对按 spec 生成代码的消费者是破坏性变更;旧结构以 legacyUpdateUser 延续。 |
| 对象下载 | 错误语义变化:坏 range 500→416(附 Content-Range: bytes */N),空对象 range 500→200,Stat 失败 500→真实 S3 状态码,ZIP 失败从静默局部 200→可见失败,206 响应携带 Content-Length。 |
| 列表与 WebSocket | size 总是序列化,包括 0。纯增量。 |
PUT /configs |
新增一类 400:拒绝服务器配置语法会破坏的数据库 DSN。 |
| 配置重置/删除 | 响应中的 restart 反映服务器的真实答案,不再恒为 true。 |
GET /session |
空凭据主体返回 401;通告的 Create-Access-Key 能力计算更严格(见服务账号)。 |
如果你从 swagger.yml 生成客户端 SDK: UpdateUserInfo 操作现在指向 /user/{name}/status 且请求体仅含状态。调用生成的 UpdateUserInfo 符号的代码依旧能编译,但目标已是新路由;旧的合并调用是 UpdateUserInfoLegacy。重新生成时请审计调用点。
运维者可能注意到的行为变化:
- 对部分可读前缀的文件夹/ZIP 下载失败,而不是静默省略不可读对象(详情)。
- Range 解析比 Go 的宽容默认严格;畸形 range 头(
bytes=-0、空元素)现在得到 416 而非尽力处理。 - 只是碰巧能用的数据库通知配置(DSN 在写入途中被配置语法破坏的那种)现在被前置拒绝并返回 400。
- 策略无条件拒绝创建访问密钥的会话不再显示创建控件(以前任意条件式拒绝都保持可见)。
- 待重启指示会持续到真正重启为止,不再会被后来一次无关的配置变更清除。
- 如果控制台前面的反向代理压缩了
/api/v1/…/download响应,文本预览的严格Content-Length校验会把所有预览判为错误。控制台自身只压缩静态资产——请让代理对 API 响应保持不压缩。
已知限制与后续跟进
修复表格操作按钮禁用属性后,暴露出两处既有谓词把行对象与字符串直接比较的 UI 缺陷:
- 「不能删除 Default IDP 配置」的行守卫将行对象与字符串
"Default"比较,导致按钮未禁用(IDPConfigurations.tsx); - 「不能删除环境变量覆盖的 Webhook 端点」的行守卫存在同样的对象与字符串类型不匹配(
WebhookSettings.tsx)。
两处操作在提交时仍由服务端拦截。此外,新的 PostgreSQL DSN 解析器在填充结构化字段时仅解析规范的 key=value 语法(非常规但 libpq 合法的 DSN 会显示为空结构化字段,编辑结构化字段时会用这些字段重建 DSN);TestCafe/Playwright 断言覆盖 UI 门控,服务端拒绝路径由集成测试套件覆盖。
验证
发布决策综合了本地候选阶段证据,以及在精确标签树 7dc4258a6 上执行的远端门禁:
本地发布准备门禁 —— go build ./...、go vet ./...(含 -tags testrunmain)、gofmt、golangci-lint、go test -race ./...、go tool swagger validate、govulncheck、TypeScript tsc、Playwright、Prettier、knip、linux/amd64 与 linux/arm64 发布标签交叉编译、go mod verify。
嵌入资产确定性 —— 前端经完整流水线(yarn build + 嵌入优化)从源码重建,与提交的 web-app/build 对比:逐字节一致,零脏文件。早期候选 19047161f 之后的九个提交修改 Go 兼容性、workflow 与浏览器测试时序,没有修改产品前端源码或嵌入资产。
最终树 CI 完整矩阵 —— Workflow 32888892876 报告 32 个成功 job,另有一个明确禁用的 React 测试占位项。覆盖 lint、semgrep、Go 与 API 测试、五个交叉编译目标、Swagger 漂移、最新 MinIO 源码构建、分布式集成、站点复制、封闭 SSO、完整 TestCafe 权限矩阵、subpath-nginx、Playwright 与覆盖率门禁。Vulnerability Check 32888899120 在同一提交上两个 job 全部通过。
发布流水线 —— goreleaser 32916237254 针对 v2.2.0 的两个 job 全部通过,并发布公开 GitHub Release。
下游契约 —— 在删除全部 replace 的临时树中,go mod tidy + go build ./... 针对上游 minio/pkg v3.6.1 成功,证明分支替换不会把分支独有符号泄漏进公共模块表面。
本周期一并交付的测试基础设施修复(让上述门禁真正起门禁作用):Docker 承载的集成/复制/SSO 套件回到 testrunmain 构建标签之后(裸 go test ./... 不再试图启动容器);SSO 门禁封闭化——不再 sudo 修改 /etc/hosts、不再临时 pip 安装、固定 SILO 镜像、自选端口、真实清理;集成门禁断言新的 416 range 语义并停止把 PostgreSQL fixture 绑定到主机端口;浏览器门禁通过发布 fixture 端口实现脱离 Linux 运行;修复了残存的品牌重塑前「MinIO administrator」选择器;有状态的 TestCafe 套件改为串行;Playwright CI 以不可变方式安装提交的锁文件。
发布制品
v2.2.0 GitHub Release 公开发布:
- 六个独立二进制:Linux amd64/arm64/armv6、macOS amd64/arm64、Windows amd64;
- 九个 Linux 软件包:amd64、arm64、armv6 各自的 DEB、RPM 与 APK;
silo-console_2.2.0_checksums.txt,以及 GitHub 为每个制品记录的 SHA-256 digest。
相关提交
16960f7ab— fix: keep unknown downloads indeterminate5968bb37d— chore(deps): align the SILO Go dependency stack288ab1240— fix: harden sessions, metrics, and translationsecf3bb492— fix: harden object previews and downloads902d9650d— chore: tighten dependency and test gates194c70c7a— build: prepare SILO Console v2.2.0927b44e26— fix: harden database notification formsda2191be9— fix: clarify console upload and secret limits6141c2445— build: refresh SILO Console v2.2.0 assets097e76155— chore(deps): bump the shared package fork to v3.12.099ca523d6— fix: split user status updates out of the combined user routef4097992f— fix: honor the server restart result for configuration changesf1280032a— fix: restore permission-gated table row actions24ce0af97— fix: keep request-scoped access key conditions visible in the console92e8f4e65— fix: pass the object size into the preview and share dialogs8f6fb3c78— test: make the SSO gate hermetic and pin it to a SILO release384a2cb95— build: refresh SILO Console assets and record the changesa73cda376— test: fix the integration gate’s stale range and host portcf5049c1d— test: make the browser gates runnable and fix a stale selector6fa19d857— fix: complete service account permission boundaries7e57771a4— build: refresh SILO Console assets57cfe7aa0— fix: restore downstream and browser release gates19047161f— test: stabilize permissions browser gatese37dec873— fix: validate IAM policies before writes28505ed23— chore: update minio-go to v7.3.016abb971e— ci: harden validation and release gates2ddfcd036— fix: accept legacy AccountInfo tag payloads31332bca9— fix: preserve policy source compatibility3a8251086— ci: allow permission tests to finishc159fff78— ci: serialize shared-role permission tests2e91cdf9a— test: wait for watch controls to become ready7dc4258a6— test: allow asynchronous UI controls to settle
链接:
2.17 - SILO Console 2.5:界面与管理功能更新
SILO Console v2.5.1 现已正式发布(源码提交 fff1618d9abb),同时作为 SILO 20261026 的内嵌控制台交付。本文完整汇总自 v2.4.2 以来的 2.5 系列变更,涵盖 v2.5.0 的界面重构与 v2.5.1 的问题修复。
GitHub Release · 下载 · 完整源码对比
导航、概览与监控指标
2.5 系列重构了整体界面视觉与交互:采用柔和的明暗主题配色、系统原生字体、统一的信息密度、Lucide 图标库、清晰的键盘焦点样式,并支持系统的“减少动态效果”(Reduced Motion)偏好。移动端抽屉与平板窄侧栏针对小屏做了专门适配,对象详情在移动端以可恢复焦点的底部抽屉呈现,同时完整保留中英双语切换、无障碍控件与开源许可署名。
左侧导航按当前会话的实际权限组织 Identity、Monitoring、Settings 与 Tools 分组,合并了统一的 Buckets 入口与最近访问存储桶列表,并保持登录重定向目标与既有对象深链接兼容(/browser 自动跳转至 /buckets,桶管理根路径默认进入 Summary 页)。Help 菜单继续提供文档、视频与 Blog/RSS 入口。
Overview 概览页直观展示节点与磁盘健康状态、原始物理容量、活跃告警、快捷操作及数据刷新时间。容量直接采用 Server 上报的实测值(不凭空估算纠删码可用容量),缺失的统计项明确显示为未知。Monitoring 下的 Metrics V3 面板严格区分“样本缺失”与“真实零值”,并正确处理逐节点重复导出的集群指标。注意:尽管 Server 20261026 已支持返回实时存储桶清单,存储桶与账户的对象用量统计仍由后台扫描器异步汇总。
存储桶、对象浏览器与任务中心
- 存储桶列表: 支持排序、名称筛选、URL 查询参数持久化、表格/卡片视图切换、大列表虚拟滚动及未知用量标注;使用稳定的行标识防止排序后选错目标桶,并在页面跳转后自动丢弃在途的旧搜索结果。
- 细粒度权限体验: 仅具备写入权限的用户也可正常进入对象工作区;完整保留桶根通配授权与前缀访问限制;修改标签等操作被服务端拒绝时直接展示服务端返回的错误原因(列举、读取与修改权限仍各自独立判定)。
- 在线预览与命令提示: 支持最大 1 MiB 的文本、Markdown、CSV/TSV 表格预览、源码高亮、安全 HTML 渲染及明确的截断提示;常用配置表单可直接生成脱敏的等价
mcli命令。 - Tasks 任务中心: 统一跟踪文件上传下载、原生流式 ZIP 打包下载、批量删除、历史版本清理与对象恢复任务;写操作并发受控,重试仅针对失败条目(取消任务不会回滚已完成的写入;大文件或流式 ZIP 下载触发浏览器保存后,请在下载完成后核对本地文件完整性)。
- 键盘导航与分页: 新增键盘上下移动、Shift 范围选择、当前页全选、快捷键面板与按权限裁剪的右键菜单。对象列表继续采用游标分页,排序、名称筛选与全选仅作用于当前页。
身份管理与高延迟诊断
用户详情页集中展示账号状态、直接绑定策略、用户组继承策略与 Access Key 数量;策略管理页明确区分原生内置策略、已被覆盖的内置策略与自定义策略,并显示策略分配计数及受影响身份。原生内置策略默认只读(需显式确认后才允许覆盖),已被覆盖的内置策略提供一键 Reset 恢复原生定义。重新加载失败时会清空过期的编辑器内容,切换界面语言时保留未保存草稿,表单统一补齐无障碍标签关联并默认遮蔽远端密钥。
注意:内置策略 Reset 与实时存储桶清单需要配合 Server 20261026 使用。 单独升级独立部署的 Console 不会修复旧版 Server 的策略缓存,也不会替换旧版 Server 内嵌的前端界面。
在诊断工具面,Health、Trace、Logs、Watch、Speedtest 与 Profile 六类实时诊断流统一复用唯一的读取协程完成 WebSocket 关闭握手(Close 帧发送与等待共享 5 秒超时预算,且不打断正常活跃流),彻底修复了 Azat / @azataiot 在 #63 中报告的高延迟链路下健康报告截断与 1006 异常关闭问题(超时错误返回标准 1008 状态码,不泄露内部堆栈细节)。同时,健康报告下载在版本探测与正文读取之间复用同一个 JSON 解码器以保留预读缓冲帧,拒绝被截短、已取消或失败的响应流,并保持 madmin v3 的签名传输与重试策略。
2.5.1 补丁修复
- 用户组状态切换(#67): 单独切换用户组启用/禁用状态时保留组内现有成员;显式传入
members数组时仍按原语义替换成员列表。 - OIDC 组权限与反向代理会话(#66): 正确保留 OIDC 字符串数组格式的组声明权限;仅在启用了 HTTPS 或配置了可信 TLS 反向代理转发头时设置
Secure会话 Cookie,不再盲目信任未校验的客户端转发头。 - 凭据导出与流式 ZIP 错误状态(#68): Access Key 导出完整保留原始 UTF-8 JSON 字节;流式 ZIP 下载若在写出首个响应字节前失败,直接透传底层 S3 错误状态码。
- 无障碍与焦点恢复(#68): 修复表格与表单控件的键盘焦点恢复、可访问名称及进度条数值属性;窗口尺寸变化导致标签栏重排时保持当前焦点标签可见。
- 配套依赖升级(#69): 升级至正式版
mcli 20261026、silo-pkg v3.15.2以及支持 HTTP204移除复制配置的上游 SDK。
依赖与交付验证
本版基于 Go 1.27.3、Node 24.22.1、Vite 8.3.5、GoReleaser 2.19.0、silo-pkg 3.15.2、mcli 20261026 与上游 minio-go 320af050c445 构建;为保持终端输出格式与跨平台兼容,继续固定 tablewriter v0.0.5、go-humanize v1.0.1、go-runewidth v0.0.29 与 go-systemd v22.6.0(详见统一依赖与安全审计清单)。
GitHub Release 提供各平台二进制归档、Linux 软件包、源码包、SHA-256 校验和、SPDX SBOM 以及 Sigstore/GitHub 构建溯源证明。Windows 归档改用 .zip 格式(Linux 与 macOS 保持 .tar.gz),官方多架构镜像已发布至 Docker Hub(docker.io/pgsty/silo-console:v2.5.1),核验步骤见发布契约。
前端静态资源与内嵌产物在隔离工作目录中完成逐字节可复现构建。SILO Console 正式维护的管理目标为 pgsty/silo(对上游 MinIO 保持尽力而为兼容),既有 CONSOLE_* 环境变量、API 路由、服务单元名称与 Go 模块路径均保持不变;详细的 API 与内嵌依赖说明见 Console 兼容性指南。
2.18 - silo-pkg 3.15:Go 1.27.3 与 S3 SDK 修复
silo-pkg v3.15.2 现已正式发布(源码提交 0416eb1f4505),由 SILO 20261026 组件栈以 github.com/pgsty/silo-pkg/v3 模块路径直接引入。本文完整汇总自 v3.14.3 以来的 3.15 系列更新(v3.15.0、v3.15.1 与 v3.15.2)。
3.15.0:Go 工具链与共享依赖升级
模块最低 Go 版本要求由 1.26 提升至 1.27.3,并同步更新了 13 个直接依赖模块:JWX 3.4.0、etcd api / client/pkg / client 3.7.3、gRPC 1.84.0、grpc-gateway 2.32.0、go-json 0.11.2、swag/conv 0.29.2、go-colorable 0.1.16、cpuid 2.4.1、fwd 1.2.1,以及固定在 ce908d29299b 的两个 Google API/RPC 生成模块。
下游应用需使用 Go 1.27.3 重新编译以纳入标准库证书校验修复。公开包 API、IAM 策略格式、授权判定逻辑以及 v3.14.3 的策略匹配性能优化均保持不变。
3.15.1:上游 S3 SDK 修复
v3.15.1 将上游 minio-go 从 v7.4.1 推进至 ab93db7ba435,合入了以下修复:
- 在发送不完整分片前直接拒绝被截短的顺序或并行流式分片上传。
- 修复
ReadAt到达 EOF 后的顺序读取状态,并在访问元数据前拒绝负偏移参数。 - 修复调用
Object.Stat之后的读取与Seek边界计算。 - 对批量删除回退路径中的重复错误去重,确保每个失败项只上报一次。
3.15.2:移除桶复制配置兼容
v3.15.2 进一步选用上游 v7.4.2-0.20261025165926-320af050c445(伪版本,上游尚未打 v7.4.2 标签)。移除存储桶复制配置(含向 SetBucketReplication 传入空配置)现在同时接受服务端返回 HTTP 200 与 HTTP 204,而 AccessDenied 等真实鉴权或请求失败仍正常返回错误(见上游提交对比)。
兼容性与验证
为保持既有 CLI 输出格式与 NetBSD 编译兼容,下游应用继续固定 go-humanize v1.0.1、go-runewidth v0.0.29 与 go-systemd/v22 v22.6.0。由于 Go 不会传递继承依赖模块的 replace 指令,各嵌入方需在自身 go.mod 中显式声明所需的模块替换。
v3.15.2 已通过含 LDAP 集成在内的全量 lint 与 -race 竞态测试、模块校验及三项 CI 流水线(Go、LDAP、VulnCheck)。HTTP 协议夹具验证了两个复制移除入口均同时接受 200/204 并保留 403 错误;此前的 3.15 依赖回归测试亦覆盖了 JWT 校验边界、本地 etcd 认证/Watch/租约持久化及 CLI 输出格式。模块级扫描中的 GO-2026-5932 位于未被导入的弃用 OpenPGP 包中,不影响运行代码(详见统一安全审计清单)。
silo-pkg 仅作为 Go 源码模块发布,Server、Console 与 mcli 通过重新编译集成该版本。正式维护的依赖组合为 PGSTY 组件栈,对上游 MinIO/MC 保持尽力而为兼容。GitHub Release · 完整源码对比。
2.19 - Silo Console 2.1.0 发布
发布日期: 2026-08-06 · 版本: v2.1.0 · 仓库: pgsty/silo-console
SILO Console 2.1.0 是独立发布 2.0.0 之后的第一个功能版本,主要包含三项改进:
- 双语界面——全部控制台页面、帮助条目与文档链接支持中英文切换,切换按钮出现在每一页,且不引入新的运行时依赖;
- Metrics V3 仪表盘——仪表盘从 MinIO Metrics V2 指标迁移到 V3,并针对 V3 改变的底层语义做了显式适配;
- 边界场景正确性修复——全选框与批量操作作用于同一批对象,占位符兼容包含
$&的对象名,时间戳统一携带时区,空指标显示“无数据”而非虚构的0。
这是一个 小版本。环境变量、模块路径、API 契约、二进制名称和数据布局均无变化,升级就是换二进制或换镜像。
同日已发布 2.1.1 补丁
v2.1.1 与本版本同日发布,补全了下文所述的图例加固:图例构造器无法解析的标签占位符会被直接移除,而不是把字面花括号漏进 Traffic 图表的图例;最后一处替换分支也完成了转义加固,标签值中的 $& 或 $1 不会再被当作替换指令;License 页面显示的版本号也从 2.0.0 修正为实际版本。除此之外没有任何变化——直接升级到 2.1.1 即可,本文所有内容照旧适用。
如果你内嵌了这套控制台,请重新生成嵌入资产
2.1.0 修复了 2.0.0 之后 main 分支上的一个打包缺陷:go:embed 负载中仍是 2.0.0 的前端构建产物,因此从中间提交构建出的二进制会提供旧版界面。2.1.0 的正式发布产物基于重新生成的负载构建,不受影响。
双语控制台
这是一套对象存储的管理界面,而它的运维使用者中有相当一部分以中文为第一语言。2.1.0 在不引入 i18n 框架的前提下把界面变成双语——嵌入式交付意味着每一 KB 都要计入二进制体积。这对应 issue #6:该 issue 原本提议使用 i18next,而"零依赖"是本次实现相对它唯一一处有意的偏离。
实现方式
设计约束是:不加新依赖、不加构建步骤、不引入字符串抽取流水线,并且 部分覆盖绝不能让页面出错。
- 英文原文就是字典的 key。
t("Create Bucket")查找对应的中文条目;查不到则原样返回英文。因此覆盖度可以增量生长,写错 key 的后果是退化成英文,而不是暴露console.bucket.create这样的裸标识。 - 三份字典,一次合并。
zh.ts(165 条界面框架)、zhHelp.ts(247 条帮助内容)、zhScreens.ts(1373 条功能页面)合并时以框架优先,合计约 1785 条。 - 语言偏好完全复用暗色模式的模式:
localStorage→systemSlice→setLanguage。不做浏览器语言探测,默认英文,选择完全显式。 - 集中拦截而非逐点改写:页头包装器、确认对话框、帮助条目、路由定义、仪表盘面板渲染器各自在输出的最后一步翻译。这正是 220 个页面文件能够在不触碰业务逻辑的情况下完成本地化的原因。
- 模块拆分是必要的。
i18n/lang.ts只存放纯函数原语(translate、localizeUrl)且不导入 store——systemSlice依赖它,反向导入会形成循环依赖。Hooks(useT、useLanguage、useLocalizedLink)与interpolate()放在i18n/index.tsx。
切换控件是一个描边风格的 文/A 图标,挂载在所有页面的页头,登录页复用同一控件。
覆盖范围
登录与 SSO 流程、导航与命令面板、仪表盘与全部指标面板、存储桶与完整对象浏览器(上传、预览、分享、版本、回溯)、用户/用户组/策略/访问密钥、配置与事件目标、IDP 与 KMS、日志、健康报告、性能测试、性能剖析、对象检查、跟踪、监视,以及许可证页面。
除可见文本之外:
- 文档链接会本地化。
silo.pgsty.com的链接在中文下加/zh前缀;Pigsty 站点做域名对调(pigsty.io↔pigsty.cc)。GitHub、MinIO、AWS、YouTube 链接保持不变。 - 帮助面板的博客源按语言区分,中文下拉取
/zh/blog/index.xml,并为每种语言维护独立缓存。 - 命令面板在两种语言下都能搜到。菜单项显示时翻译,但保留英文原文作为关键词,因此"桶"和 “buckets” 都能命中。
- 图表图例只翻译静态前缀。
translateLegend保留[server:drive]这类实例后缀;数据层保留原始图例,所以那些依赖图例做数值匹配(容量求和)的组件继续正常工作。 - 时间戳统一携带时区格式,详见下文。
代价
嵌入负载增加约 61 KB(2.79 MB → 2.85 MB,+2.2%),零新增依赖,字典进入独立的按需加载分块。英文渲染路径字节稳定:使用默认语言时,输出与 2.0.0 完全一致。
保持英文的字符串
后端错误信息(182 条)由 Go 服务端产生,前端无法翻译。少量硬编码在 vendored mds 组件库内部的字符串——收起态菜单的 “Sign Out” 提示,以及数据表格的 “Columns”、“Loading…” 和 ON/OFF 开关——仍是英文;其中两处(“Sign Out”、“Actions:")通过作用域 CSS 规则做了中文替换,其余需要改动 vendor 才能处理。
Metrics V3 迁移
仪表盘此前查询的是 MinIO Metrics V2 名称,而 SILO 部署抓取的是 V3(/minio/metrics/v3)——也就是说,仪表盘依赖的是监控流水线已经不再采集的端点。2.1.0 将全部 26 个部件 重写到 V3 目录——31 条查询,涉及 29 个不同的指标名——并移除三个从未被任何布局引用的部件(51/61/62)。这对应 issue #7,Info 页面的那一半是 #8。
这里的决定是 V3 only:不做运行时回退、不做探测、不提供版本选择开关。SILO Console 面向的是 SILO 部署,服务端、抓取流水线与控制台是一同交付的。SILO 服务端继续为外部消费者提供 V2 端点,只是控制台不再使用。回退机制在这里反而有害——保留 15 天 V2 序列的指标库会让 or 回退悄悄读到过期数据。
V3 改变的语义
有三条 V3 特性会让"照名字改写"的迁移出错,每一条都需要明确的应对:
- 集群组指标由每个节点重复导出。
/cluster/*指标不带 server 标签,也不做 leader 门控,因此 N 节点的抓取会得到 N 条重复序列。查询统一用max()/min()聚合——绝不能用sum(),那会把集群总量乘以节点数。 - 零值默认不导出。任何取值 ≤ 0 的指标都会被跳过,离线磁盘数、修复中磁盘数、纠删集健康状态在取值为零时会直接从指标流中消失,导致统计卡片渲染成空白面板。所有受影响的查询都配了伴随守卫,让面板读出真实的
0。 - 移除
minio_heal_*命名空间。V2 的修复活动信号本身是内存态的(重启即清零,任意扫描都会刷新),现替换为两张语义明确的卡片:纠删健康(以写入法定人数为基线)和 用量数据时效(扫描器用量快照的陈旧程度)。
零值语义
本次迁移针对八类边界场景明确区分了 零值、无数据 与 尚未完成首次扫描:
- 容量 的空闲/已用以恒定存在的总量为基线,因此写满的集群显示
0 空闲,而不是整个消失。 - 在线磁盘 针对全部离线的场景加了守卫——恰恰是最需要看到这个数字的时候,零值跳过会抹掉面板。
- 桶与对象计数 改用用量组自身的新鲜度指标做守卫,因此尚未完成首次扫描的集群显示 无数据,而不是编造一个
0。 - 空的单值结果 渲染为
—,而不是0。 - 空的大小分布 不再伪造七个零高度的柱子。
- 小数速率保持可见(
parseFloat轴域、两位小数的 CPU 格式化器),不再被压成0。 - 不足一秒的用量数据时效 收敛到"1 秒”,而不是渲染成空白。
回归测试套件(api/admin_info_metrics_test.go)现在把每条部件查询钉死在 V3 目录上,校验部件 ID 唯一性,并强制执行逐部件的守卫分类法:健康与流量类需要在线节点数伴随项,用量计数类需要用量组新鲜度伴随项,容量类需要总量基线。完整映射记录在 docs/metrics-v3.md。
顺带修复
- 部件 17 把
sent_bytes查了两次,部件 11 把syscall_read查了两次——两组节点间/系统调用的收发配对都退化成了重复项。 - 无标签矩阵(
max()聚合的结果)序列化时 完全没有metric字段,导致前端的标签提取崩溃,表现为容量环图显示0 B、用量增长图为空。已加守卫。 - 一个从未被使用的逐部件 Prometheus label-values 预取请求,让每次部件请求最多多等一秒。已删除。
- 仪表盘的用量卡片、图表控件以及密集的流量/资源面板按统一语法重建,现在在平板宽度下也能正常重排。
工作中还发现两个 服务端 缺陷,选择上游跟踪而非在此绕过:minio_cluster_usage_buckets_since_last_update_seconds 发出的是纳秒(对象变体是正确的),以及 V3 桶级发送/接收流量被对调。
正确性修复
能扛住真实对象名的占位符
String.prototype.replace 会把 替换值中 的 $&、$'、$`、$1 当作指令解释。而 S3 的 key 合法地允许包含 $。于是一个名为 report$&.csv 的对象不会按原样渲染——它会把匹配到的占位符文本重新注入输出,破坏整条消息。全部 37 处 字典占位符替换现已改为传入函数形式的替换值,函数形式不做任何此类解释。该缺陷原本就存在于英文界面中,在本次本地化排查时一并检出修复。
与批量操作一致的全选
vendored 数据表格在缺少 onSelectAll 时会渲染一个无法翻译的纯文本 “Select” 表头——七张可选表格全都如此。更麻烦的是,直觉的修法是错的:直接用可见行替换整个选择集,会 丢掉被当前过滤条件隐藏的行,于是表头复选框与随后的批量操作可能作用于不同的集合。实现只切换当前可见行,并保留被过滤隐藏的选择,因此表头状态不可能再暗示一个与实际操作对象不同的集合。
带时区的时间戳
桶、对象、版本、回溯与访问密钥的时间戳此前混用冗长英文格式,并且有几处使用 不带 AM/PM 的 12 小时制——这根本是歧义的。现在它们在两种语言下统一渲染为 yyyy-MM-dd HH:mm[:ss] (ZZZZ)。
扛得住实时数据的翻译运行时
t() 同样会收到运行时字符串:User-Agent、RSS 标题、对象名。由此做了两项加固:
- 未命中时 无条件原样返回——隐式的
@context后缀剥离被移除,因为它会悄悄改写恰好含有@的实时数据; - 字典查找加上
hasOwnProperty守卫,因此恶意输入即便命中继承自Object.prototype的成员(constructor、toString)也无法把函数泄漏到界面上。
交互与可访问性
- 会话过期后打开深链会在
/login之间 来回弹跳,不断累积重定向链,而不是干脆落到登录表单上(#1)。 - 收起态的侧边栏按钮没有可访问名称,屏幕阅读器只能报为未命名控件(#4)。访问密钥输入框现在显式声明 autocomplete 意图,而不是让密码管理器去猜(#5)。
- 移动端的指标与存储桶面板改为滚动,不再被裁切(#3)。
- 性能测试的控件行改为换行而不是溢出卡片,时长可填秒或分钟,大小的默认单位改为 MiB 以匹配其自身的单位列表。
- 侧边栏桶列表的虚拟行距与 44px 的行高对齐,选中与悬停高亮不再互相重叠。
- 单位标签渲染所选单位的 显示名,而不是原始取值。
没有 SUBNET,没有遥测
上游已移除 Subnet、Registration 与 Call Home,本项目继承了该状态,但仍残留三处痕迹。2.1.0 予以清除:
- 健康诊断 websocket 的
subnetResponse字段从来就不指向任何 subnet——它只是一个"报告已生成"的哨兵值——现改为reportStatus: "ok"; - 两条帮助文案声称健康报告"会自动上传到 SUBNET"、检查输出"传输到 SILO SUBNET"。两者都不属实。它们现在描述真实行为:报告在部署端生成,由浏览器下载;
- 删除从未被引用的
CONSOLE_SUBNET_PROXY常量。
顺带说明,2.1.0 的出网行为没有变化,仍然是:无分析、无遥测、无埋点、无外部脚本与字体。silo-console update 依旧禁用。版本目录仅在显式设置 SILO_RELEASE_SERVICE_HOST(或 RELEASE_SERVICE_HOST)时才会连接——没有默认值。浏览器唯一的自动出网请求是帮助面板的博客源,且只在用户打开 Blog 标签页之后发生。
升级指南
没有任何需要迁移的内容。2.0.0 到 2.1.0 之间,环境变量、模块路径、协议字段、systemd 单元、二进制名称与数据布局均无变化。
有两点值得知道:
- 仪表盘现在要求 Metrics V3。如果你的 Prometheus 只抓取 V2 端点,仪表盘面板会显示无数据。请把抓取目标指向
/minio/metrics/v3;由 Pigsty 管理的部署已经如此。 - 语言默认为英文,按浏览器选择并存入
localStorage。不存在服务端默认值,也不做浏览器语言探测,因此现有部署升级后外观不会发生变化。
验证范围
打标签之前,针对最终代码树执行了以下门禁:go build、go vet、golangci-lint(0 问题)、全部 Go 包的单元测试、gofmt、TypeScript 类型检查、前端生产构建、全量 Prettier 检查、字典重复 key 检查,以及对完整 diff 的调试残留扫描。
嵌入负载从干净目录重建两次并确认逐字节一致,满足发布流水线零差异门禁的要求;Metrics V3 查询已在含真实集群数据的线上指标库上完成验证。
已知限制
- SSO 端到端测试套件依赖外部 OpenLDAP/Dex/MinIO 拓扑,本轮未在该环境中运行;OIDC 代码路径已由单元测试覆盖。
- 后端错误信息与若干 vendored
mds组件字符串仍为英文(见保持英文的字符串)。 - 中文翻译覆盖控制台自身的界面;帮助条目正文已翻译,但它们所链接的文档页面遵循文档站自身的语言覆盖情况。
- 两个服务端 V3 指标缺陷(桶用量时效的纳秒单位、桶级流量对调)在上游跟踪,控制台侧未做绕过。
- 自动自更新仍然禁用,升级需要显式执行。
已关闭的 Issue
2.1.0 关闭了针对 2.0.0 提出的全部 issue。每个 issue 在 tracker 上都留有一条评论,说明修复方式、涉及的提交,以及补充的测试覆盖。
| Issue | 解决方式 |
|---|---|
#1 — 未认证深链无限递归 /login |
改为绝对且感知 base path 的登录目标;补充深链与子路径部署的测试 |
| #2 — Uptime 陈旧、图例畸形、菜单局促 | Uptime 取自真实服务器状态,图例改用 V3 的 name 标签解析,图表控件 32 px,弹出菜单设最小宽度 |
| #3 — 390 px 视口裁切内容 | 指标标签页改为可横向滚动;桶列表在移动端使用明确的列宽预算 |
| #4 — 收起态侧边栏按钮无名称 | 标签改为视觉隐藏而非从可访问性树中移除;折叠开关具名且可键盘操作 |
| #5 — 访问密钥字段缺少 autocomplete 元数据 | 在独立的自动填充 section 中声明 username / new-password 字段级 token |
| #6 — 中英文本地化 | 手写双语层,零新增依赖,以英文原文为 key 兜底 |
| #7 — 监控查询迁移到 Metrics V3 | 仅 V3;26 个部件、31 条查询、29 个指标名,配套守卫分类与回归测试 |
| #8 — 用可用的 V3 健康信号替换 N/A | Erasure Health 与 Usage Data Age,与高级仪表盘共用同一份部件数据 |
有三项验收标准如实记为未达成,而不是含糊勾掉:web-app 目前没有单元测试运行器,因此 #6 的 i18n 测试套件与 #2 中针对 constructLabelNames 的专项测试都需要先引入测试工具链;另外 #6 要求的"如何新增翻译 key"的贡献者文档尚未编写。
关联提交与链接
v2.1.0 的完整变更由以下 20 个逻辑提交构成。v2.1.0 标签另外还带有三个更晚的文档提交,它们重写了仓库 README,不改变任何已交付的行为。
8764f5d— fix(web): stop recursive login redirects437c56c— fix(ui): make the dashboard and bucket list usable on narrow screens85fc0c6— fix(a11y): name collapsed sidebar controls and credential fieldse3fed07— fix(metrics): rebuild dashboard cards, chart controls, and layoutfa11576— feat(login): polish controls and legal attribution9fc17c1— feat(i18n): add hand-rolled EN/ZH core, dictionaries, and language toggle622c02e— feat(i18n): localize login, navigation, and the help system6a03719— feat(i18n): localize dashboard and metrics screens14b1c2d— feat(i18n): localize bucket and object browser screens0298062— feat(i18n): localize identity, configuration, and event destinations41094f6— feat(i18n): localize observability, admin tools, and shared componentse964992— feat(metrics): migrate the dashboard to MinIO Metrics V30b2251f— fix(i18n): harden the translation runtime for live data and chart legends9b60148— fix(console): unify timestamps on a timezone-carrying standard formatbf110ae— fix(console): give selectable tables a visible-rows select-all5fc8f22— fix(i18n): escape-proof all placeholder substitutionsfef8fab— fix(console): polish speedtest, sidebar, and help chromec4911e8— chore(console): drop SUBNET remnants from health reporting1d631c4— docs: record the SILO Console v2.1.0 changelog912d847— build: regenerate optimized embedded web assets
相关链接:
2.20 - Silo Console 2.0.0 发布
发布日期: 2026-08-04 · 版本: v2.0.0 · 仓库: pgsty/silo-console
SILO Console 2.0.0 是这套对象存储管理控制台以独立项目身份发布的第一个主版本。它从 georgmangold/console 的 v1.9.1 维护线继续演进,完成了三件事:
- 建立独立身份——产品名称、视觉系统、文档入口、源码归属与发布链路全部迁移到 SILO 项目体系,同时保留 Go 模块路径、环境变量等兼容契约;
- 重塑用户界面——登录页、主题系统、仪表盘与全站细节按统一的设计语言重新打磨,配套全新的品牌图标集;
- 强化工程质量——嵌入式前端资产从约 10MB 压缩到 3.5MB,已知依赖漏洞清零,并修复了包括运行时数据竞争在内的一批上游遗留缺陷。
升级前先看兼容边界
2.0.0 的主版本变更集中在 公共身份和交付契约,对象数据格式与 S3 协议保持不变。使用旧仓库、旧发布二进制名或旧容器镜像名的安装脚本必须更新;使用 CONSOLE_MINIO_SERVER、CONSOLE_MINIO_REGION、github.com/minio/console 或 MinIO 兼容 Admin API 的现有集成保持原样,不应做全局替换。
为什么是 2.0.0
这套控制台最初源自 MinIO Console,随后由 Alevsk/console 与 georgmangold/console 两条社区维护线继续发展。SILO Console 在此基础上由 Pigsty 社区接续维护,为 SILO 提供浏览器管理界面。
版本号从 v1.9.1 提升到 v2.0.0,主要是因为以下对外契约同时发生变化:
- 产品名称统一为 SILO Console,主仓库迁移到
pgsty/silo-console; - 正式发布二进制从
console改为silo-console; - 发布资产、校验和、软件包元数据、命令行说明与项目链接全部切换到 SILO;
- 界面中的项目身份、帮助入口、版权归属、源代码供应与商标说明重新建立。
2.0.0 采取的是"对外身份清晰、对内兼容克制"的迁移策略:需要运维人员明确感知,但不对底层兼容接口做机械式改名。
名称与交付契约
| 范围 | 旧名称或旧位置 | 2.0.0 契约 |
|---|---|---|
| 产品 | Console / MinIO Console 的遗留表述 | SILO Console |
| 源码仓库 | georgmangold/console |
pgsty/silo-console |
| 正式二进制 | console |
silo-console |
| 二进制资产 | console-<os>-<arch> |
silo-console-<os>-<arch> |
| 校验和文件 | console_<version>_checksums.txt |
silo-console_<version>_checksums.txt |
| 网站与文档 | 上游或前维护者入口 | silo.pgsty.com 与 silo.pgsty.com/docs/ |
容器部署使用 Docker Hub 的 pgsty/silo-console,当前版本及拉取命令见 Console v2.4.1 发布说明。
命令行程序的作者、用途、帮助文本和项目描述已切换为 Pigsty 与 SILO Console。DEB/RPM/APK 软件包的厂商、维护者、主页、描述与许可证元数据相应更新,可执行文件安装到 /usr/local/bin/silo-console。
刻意保留的兼容标识
以下名称保留 minio 或 console,属于接口、协议或安装兼容层的一部分:
| 兼容面 | 2.0.0 中的状态 | 原因 |
|---|---|---|
| Go 模块 | 保持 github.com/minio/console |
改动会破坏全部 Go import 与生成代码 |
| 服务端地址 | 保持 CONSOLE_MINIO_SERVER |
现有部署广泛使用的环境变量 |
| 服务端区域 | 保持 CONSOLE_MINIO_REGION |
现有部署兼容契约 |
| 其他配置 | 既有 CONSOLE_* 变量继续有效 |
避免无收益的配置迁移 |
| S3/Admin API 名称 | 保留 MinIO-compatible 字段与枚举 | 它们描述实际协议和 SDK 接口 |
| 源码开发产物 | make console 仍生成 ./console |
保持开发工作流和脚本兼容 |
| 软件包 systemd 单元 | 保持 minio-console.service |
避免升级时出现两个服务或丢失原服务状态 |
| systemd 用户与配置 | 保持 console-user 与 /etc/default/console |
避免不必要的账户和配置文件迁移 |
因此,升级脚本不能执行全仓库或全配置的 minio → silo、console → silo-console 替换。未来若要迁移这些兼容接口,需要提供别名、弃用周期和明确的双读策略。
全新界面
2.0.0 对整套控制台界面进行了端到端重构。
登录页
登录页完全重写:左侧品牌面板以纯 Canvas 生成缓慢流动的正弦光网动画(零外部依赖,遵循 prefers-reduced-motion,切至后台标签页时暂停),文案以 “Keep the S3 Interface / Own the Object Store” 呈现项目主张,底部保留完整的 MinIO 商标声明;右侧表单功能与既有自动化测试选择器完全不变。SILO 字标所用的 Chakra Petch 字体以约 20KB 的本地子集打包,不产生任何外部网络请求。
统一主题系统
控制台的全部颜色收敛到一个亮暗两套的主题层:中性灰阶承载正文与边框,品牌钢蓝色承载主操作与选中态,侧边栏在明暗模式下统一使用与登录页同源的深色系。表单控件与卡片采用统一的圆角与过渡,输入框获得键盘焦点光环,模态框有入场动画(同样遵循 reduced-motion)。服务端下发自定义样式(customStyles)的优先级保持不变。
控制台细节
- 仪表盘(Metrics):统计卡片按统一语法重构——弱化标签、等宽数字、状态圆点对齐;环形图与信息条接入主题;移除了上游实现中的绝对定位布局。
- 空态统一:Watch、Trace、桶事件/复制/生命周期等所有数据面板的占位文本统一为居中弱化样式,不再是左上角的裸文本。
- 垂直选项卡:桶详情等页面的选项卡从带边框的灰色矩阵改为安静的药丸列表,并消除了栏底的空白残留格。
- 许可证页:新增 VERSION 区,同时显示所连接服务端的版本与 Console 自身版本;无
admin:ServerInfo权限的账号不会发起请求,该行自动隐藏。页面同时集中说明 AGPLv3 授权、AGPL 第 13 节下的源码获取权利、来源谱系与商标边界。 - 一批交互修复:移动端首次加载即收起侧边栏(原先要等窗口 resize 事件);侧边栏底部导航不再在窗口高度变化时延迟跟随;桶列表手风琴的高亮条铺满整行;仪表盘在窄屏下不再产生隐式横向溢出;帮助面板改为真正的按需加载——登录页不再向任何外部站点发起请求。
品牌图标集
favicon、PWA 与 Apple Touch 图标此前仍是上一代手绘徽标的 PNG 导出。2.0.0 将全部尺寸(ico 16+32、favicon 16/32/96、apple 180、manifest 192/512)从官方 silo.svg 矢量徽标重新光栅化,主屏尺寸带防裁切安全边距;Web App Manifest 裁剪为现代图标集,移除 2014 时代的 legacy density 条目。图标总体积从 473KB 降至 160KB,浏览器标签页图标从此与站内品牌完全一致。
更小、更快
嵌入式交付是这套控制台的核心形态——前端资产通过 go:embed 打进二进制。2.0.0 对这条链路做了系统性优化:
- 嵌入负载从约 9.6MB 降至 3.5MB。文本资产(JS/CSS/SVG 等)在构建期以确定性 gzip 预压缩后嵌入;仅被现代浏览器忽略的 legacy WOFF 字体(约 1.25MB)、以及一批完全未被引用的孤儿图片资产被移除。
- 首屏传输从约 5.7MB 降至约 1.7MB。此前静态资产完全未压缩传输;现在预压缩资产直接以
Content-Encoding: gzip发出(零运行时压缩开销),对极少数不接受 gzip 的客户端动态解压回退。 - 正确的 HTTP 语义。Accept-Encoding 按 RFC 9110 完整解析 q 值(
gzip;q=0会得到未压缩内容),响应携带Vary: Accept-Encoding;静态路径与 SPA 入口对非 GET/HEAD 请求返回 405 并带Allow头。 - 可复现构建。压缩使用纯 JS 实现(fflate)以保证跨平台字节级确定性;发布流水线新增强制门禁——在干净环境重建嵌入资产后必须与提交内容零差异。
发布二进制(含全部前端资产、strip 后)约 35–40MB;对下游 SILO 服务端而言,内嵌这套控制台的体积代价从约 10MB 降到约 3.5MB。
安全与依赖
Go 侧:构建基线升级到 Go 1.26.5,golang.org/x 系列全部更新至最新。govulncheck 报告的全部可达漏洞已清零:
| 依赖 | 修复版本 | 公告 |
|---|---|---|
google.golang.org/grpc |
v1.82.1 | GO-2026-6061 |
github.com/prometheus/prometheus |
v0.311.3 | GO-2026-5710 / -5662 / -5381 / -5264(含远程读 DoS) |
github.com/klauspost/compress |
v1.18.7 | GO-2026-5841 |
唯一剩余公告位于 golang.org/x/crypto,官方尚未发布修复且代码路径不可达,作为已知事项记录。
前端侧:生产与开发依赖树的完整审计清零,包括 form-data 的高危 CRLF 注入、DOMPurify 与 qs 的多项公告;React Router 迁移至 7.18.2(保留 v6 兼容的声明式 API,全站路由经过完整回归验证)。唯一被显式豁免的公告仅涉及本项目未使用的 unstable API。
运行时正确性:修复了 HTTP 日志目标在初始化与关闭之间的真实数据竞争,以及测试套件中共享 mock 的竞态;受支持的 Go 包全量通过 -race 检测。作为附带收益,go-m1cpu 升级修复了新版 macOS 上本地 go run 的 cgo 崩溃。
更新检查与默认网络行为
本版本对升级和版本目录功能采取保守默认:
silo-console update的自动自更新被禁用,命令只给出提示,不会下载或替换二进制;- 版本目录新增
SILO_RELEASE_SERVICE_HOST配置,原有RELEASE_SERVICE_HOST作为兼容回退;两者均未设置时不连接任何远端版本服务; - 帮助面板的 Blog 内容仅在用户打开时按需拉取,跳转链接只接受
https://silo.pgsty.com。
自动更新会在发布资产签名与回滚链路稳定后再重新评估。
发布产物与平台矩阵
正式发布包含 16 个资产:
| 类型 | 覆盖范围 |
|---|---|
| 独立二进制 | Linux amd64/arm64/arm、macOS amd64/arm64、Windows amd64 |
| 系统软件包 | DEB / RPM / APK × amd64/arm64/armv6 |
| 校验和 | silo-console_2.0.0_checksums.txt(SHA-256) |
发布流水线在 tag 推送时触发,工作流的第三方 Action 已固定到具体提交,并在构建前强制执行"干净检出 + 资产重建零差异"门禁。
升级指南
独立二进制
从源码构建时,make console 仍生成 ./console;用于正式服务前按发布名称安装。
DEB/RPM/APK 与 systemd
软件包继续安装 /etc/systemd/system/minio-console.service,单元内部启动 /usr/local/bin/silo-console。EnvironmentFile=/etc/default/console、console-user 与既有 CONSOLE_* 变量不变。这个保留让软件包升级继续作用于原有服务,而不是平行创建一个新服务。
配置与集成
- 不要重命名
CONSOLE_MINIO_SERVER或CONSOLE_MINIO_REGION; - 不要修改 Go import 中的
github.com/minio/console; - 自建版本目录优先迁移到
SILO_RELEASE_SERVICE_HOST; - 依赖
console update的脚本改为显式下载、校验并部署发布资产; - 按进程路径识别服务的监控更新为
/usr/local/bin/silo-console。
本版本不改变对象数据布局,也不要求对存储桶和对象执行迁移。
验证范围
发布验证覆盖 Go 全包 -race、go vet、golangci-lint、govulncheck、前端类型检查、生产构建、Prettier、死代码检查与完整依赖审计,并执行了以下专项校验:
- 对全部 184 个嵌入文件各发起 gzip 客户端、普通客户端与 HEAD 三种请求,响应体与嵌入源做逐一哈希比对;
- RFC 语义探测(含
gzip;q=0, *;q=0.5等组合 q 值)、方法限制、OIDC 回调与 SPA 深链; - React Router 7 全站路由回归:深链、客户端导航、桶详情选项卡切换与浏览器历史后退;
- 移动端首屏侧边栏行为、登录页外部请求监听、明暗主题全站巡回;
- 下载正式发布资产验证校验和逐字节匹配、二进制版本自报一致,并用发布产物直连真实服务端完成冒烟测试;
- macOS 与 Linux 双平台的资产重建零差异验证。
已知限制
- 自动自更新暂时禁用,升级需要显式执行;
- SSO 端到端测试套件依赖外部 OpenLDAP/Dex/MinIO 拓扑,本轮未在该环境中运行(OIDC 代码路径已由单元测试与 HTTP 层验证覆盖);
golang.org/x/crypto存在一条官方尚未发布修复、且代码路径不可达的公告;- SILO 尚未维护独立的视频资料库,帮助面板中的视频是明确标注的上游兼容性资料;
- 管理能力依赖 MinIO-compatible Admin API,不能把 SILO Console 当作适用于任意 S3 服务的通用浏览器;
- 保留的 Go 模块、环境变量、协议字段和 systemd 服务名仍会在代码、配置和进程管理界面中出现。
关联提交与链接
v2.0.0 的完整变更由以下 8 个逻辑提交构成:
50797de— feat: establish SILO Console identity and compatibility23ae6e8— feat: redesign and harden the SILO Console web app7a83a77— build: update Go toolchain and dependencies1330d25— fix: eliminate logger shutdown and test mock races06b3a34— docs: publish the SILO Console v2.0.0 guide4b24372— build: regenerate optimized embedded web assetsc38eb64— ci: package and publish SILO Console v2 releasesb952a12— brand: regenerate the icon set from the official silo.svg emblem
相关链接:
2.21 - silo-pkg 3.13.0 正式发布
发布日期: 2026-08-30 · 版本: v3.13.0 · 提交: 215f116 · 仓库: pgsty/silo-pkg
3.13.0 是一个破坏性版本:本模块正式启用独立导入路径 github.com/pgsty/silo-pkg/v3,同时退役 pgsty/silo-go 分支,直接依赖上游 minio-go。维护栈各组件(silo、silo-console 与 mcli)均已在 2026-09-03 / v2.4.0 版本波次完成新路径迁移,详见组件版本矩阵。
版本速览
- 已发布:
silo-pkg v3.13.0标签与 GitHub Release,声明module github.com/pgsty/silo-pkg/v3。 - 已终结:
github.com/minio/pkg/v3模块身份,以及每个消费者都必须重复一遍的replace指令。 - 已终结:
github.com/pgsty/silo-go/v7分支,本版本直接依赖上游minio-go。 - 未变: 所有包、符号与行为。移动的只是它们被导入的路径。
- 下游接入: 已在 SILO 20260903、Console v2.4.0 与 mcli 20260903 完成同步迁移。
为什么要迁移路径
此前本分支保留上游的 github.com/minio/pkg/v3 路径,以便充当直接替代品——消费者用一条 replace 指令即可选中它。但 Go 不会继承依赖模块声明的 replace,在准备下一个 SILO 服务器版本时,这套安排的三笔账同时到期。
每个消费者都要重复一遍重定向,漏写的那个会静默构建到上游。 一个依赖了本包却没写 replace 的模块,会解析到真正的 minio/pkg,编译通过,然后悄无声息地失去本分支的行为。
require 那行必须写一个源码早已不匹配的版本。 ParseConfigStrict 在上游要到 v3.11.0 才出现,Resource.IsBareARN 则在任何上游版本都不存在。当源码需要 v3.12 的 API,旁边却写着 require github.com/minio/pkg/v3 v3.6.1,这是靠改数字无法变真的元数据。
为此打的补丁还会传播。 pgsty/mc 里有一行纯粹为此存在的编译期哨兵——引用 Resource.IsBareARN——目的是把静默降级变成响亮的构建失败。而把 mc 的下限抬高、让元数据变诚实,又会通过模块图把要求推上去,顶高 silo-console 那条刻意压低的下限——那条下限存在的意义恰恰是防止这件事。
上游路径在这里能提供的回报很少:minio/pkg 是一个小型内部库,而本模块的消费者只有 silo、silo-console 和 mcli。Silo Go SDK 则保留上游的 github.com/minio/minio-go/v7 路径——那里的直接替代能力有真实价值,上游也仍在积极维护。
上游 minio-go 取代 Silo Go SDK
pgsty/silo-go 已经不再包含任何功能性差异。它唯一独有的改动 Return CopyObject checksums in UploadInfo 已于 2026-08-24 合入上游,其余内容只是一个版本字符串、一个 logo、一份 README 和一段 lint 工具声明。
上游最新标签 v7.3.0 比该合并早 14 个提交,其中包括并行分段上传中共享校验和哈希器的数据竞争修复,因此钉住这个标签反而是倒退。本版本改为依赖伪版本 v7.3.1-0.20260828014306-0e78d3f18efe,待上游切出新标签后再换成标签。
兼容性
所有包、导出符号与行为均未改变。本版本移动的是它们被导入的位置,仅此而已。
请与 silo、silo-console、mcli 的配套改动一起采用。单独升级本包的消费者将无法构建——新旧路径是两个不同的模块,类型无法在它们之间传递。
变更前:
变更后:
并把 github.com/minio/pkg/v3/... 的导入改写为 github.com/pgsty/silo-pkg/v3/...。继续沿用旧安排的消费者仍可构建于 v3.12.2 及更早版本之上,这些版本依然可用。
有一个后果值得预期:两条路径现在是不同的模块,因此一次构建里可能同时包含两者——某个第三方依赖若导入 github.com/minio/pkg/v3,它的导入不再被重定向。在 SILO 技术栈中这种情况只出现一次,来自 minio/colorjson 与 minio/dperf,二者都只用到 pkg/v3/console。而该包的颜色开关是 fatih/color 的进程级全局变量 NoColor,所有副本共享同一个,因此关闭颜色仍然会在各处一并生效。
验证
go build ./...、go vet ./...、go mod tidy -diff、gofmt -l .全部干净;go test ./...通过 23 个包。- 发布提交上的 CI:Go、LDAP Config Validator、VulnCheck。
- 新路径已由一个不带任何
replace指令的全新模块从模块代理解析成功,导入policy并运行通过。 - 下游迁移在发布前已端到端验证:
mcli(194 个文件)、silo-console(36 个文件)与silo(181 个文件)均通过构建、vet 与各自测试套件;silo的 rebrand guard 通过,兼容基线差异恰为 19 条被删除的导入记录,环境变量、指标、请求头、路由、策略取值与导出符号均无变化。
2.22 - silo-pkg 3.12.0 正式发布
发布日期: 2026-08-24 · 版本: v3.12.0 · 提交: 2b087a1 · 仓库: pgsty/silo-pkg
3.12.0 是直接从主线发布的中版本,包含两条主线:第一,为“只有 ARN 命名空间前缀、却没有指定任何资源”的策略增加严格写入保护;第二,把维护基线推进到 Go 1.27 与 etcd 3.7。它新增两个导出的检查/校验方法,将验证过的消费者下限提高到 Go 1.26;同时也是第一个由 SILO 服务端(eee05a17c,SN-2026-005,随 SILO 20260903 发布)在具名策略与服务账号策略写入路径上启用严格校验的 silo-pkg 版本。
发布状态一览
- 已经发布:
silo-pkg v3.12.0标签、GitHub Release,以及裸 ARN 前缀的严格库级校验。 - 服务端接入:
eee05a17c(SILO 20260903)在具名策略与服务账号策略写入路径启用严格校验。 - 刻意保持宽松: 既有 IAM 策略加载、IAM 导入与站点复制接收路径。
- 延后: STS 内联策略严格校验与 Console 客户端侧提前校验;服务端仍是权威边界。
裸 ARN 前缀为何会成为策略哑弹
正常的 S3 资源 ARN 必须在命名空间前缀后指定资源:
但原策略解析器也接受只有前缀的形式:
这个字符串没有指向任何 bucket 或对象。原解析器把它归一到通配资源类型,同时把 arn:aws:s3::: 保留为匹配模式;而真正进入 S3 鉴权的候选资源长成 bucket 或 bucket/object。因此整份策略虽然校验成功,这个模式通常什么也匹配不到。
对真正执行资源匹配的 Statement,实际影响取决于 Effect,以及前缀写在 Resource 还是 NotResource:
| 语句形状 | 原有运行结果 |
|---|---|
Allow + 裸 Resource |
什么也不授权 |
Deny + 裸 Resource |
本应生效的拒绝没有触发 |
Allow + 裸 NotResource |
没有排除任何东西,可能远比预期授权得更宽 |
Deny + 裸 NotResource |
可能远比预期拒绝得更宽 |
危险的两格属于依赖错误策略配置的 fail-open,而不是未认证远程漏洞,也没有 CVE。管理员、策略模板或自动化必须先提交这种错误资源,问题才会出现。同一问题也适用于已经注册的 S3 Tables 与 KMS ARN 前缀。
不操作资源的 Admin Action、sts:* Action Statement,以及 KMS 两阶段鉴权的第一阶段,本来就跳过资源匹配;裸前缀不会改变它们现有的运行时判定。严格写入仍拒绝这个具有误导性的字段,避免策略看起来像“限定了资源”,实际上该范围根本没有参与判定。
历史 *arn:... 形式
在沿用旧版本的宽松兼容路径中,把精确裸前缀解析后重新序列化,仍会在前面带上通配资源类型标记:
这两种写法重新解析后得到完全相同的内部资源值。因此 3.12 同时识别精确前缀与历史星号形式。存量/导出策略,以及会在发送前先解析再序列化策略的客户端,都可能出现后者。
原有通配语料保持不变:*、**、***、*foo,以及 arn:aws:s3:::* 这样的显式资源仍按原方式解析。
严格写入,不做存储迁移
修复刻意把“加载已有策略”与“创建新策略”分开:
ParseConfig与Validate继续宽松;既有存量策略保持相同的加载、匹配与序列化行为。ParseConfigStrict与ValidateStrict在Resource和NotResource中都拒绝“只有已注册 ARN 前缀、没有资源”的形式。Resource.IsBareARN()识别精确/历史形式归一后的值,不改变导出的Resource结构、ParseResource、匹配算法或 JSON 表示。ResourceSet.ValidateStrict()向消费者提供严格资源集合校验。
保留原资源表示对混合版本站点很重要:v3.11 与 v3.12 节点仍以相同方式比较、序列化存量策略,因此本修复不会制造站点复制 mismatch,也不要求迁移 IAM 存储。
SILO 在三个边界启用保护
SILO 提交 eee05a17c 选用 silo-pkg v3.12.0,并在以下场景使用严格解析:
- 创建或替换具名 IAM 策略;
- 创建带内联 Session Policy 的服务账号;
- 更新服务账号的内联 Session Policy。
本轮中,以下兼容敏感路径仍保持宽松:
- 加载已经落盘的具名策略与嵌入策略;
- IAM 导入/恢复;
- 站点复制接收与应用;
- STS 内联 Session Policy;
- Bucket Policy——它现有的 bucket/action 校验已经会拒绝这些形式。
启用 ParseConfigStrict 还会激活两条早已存在的 Admin Policy 规则:同一条 Admin Statement 不能同时携带 Resource 与 NotResource;作用于 bucket 的 Admin Action 不能使用非 S3 资源。这些都是刻意的鉴权收紧,已经记录在安全公告台账 SN-2026-005 中。
本文所说的 裸 ARN 前缀,是 arn:aws:s3::: 这种命名空间后没有任何资源的形式;它不同于 3.11 桶/对象边界修复中的合法 裸 bucket ARN,例如 arn:aws:s3:::my-bucket。
运维需要做什么
工具不会自动改写存量策略,因为无法推断原作者真正想指定哪个资源。部署包含服务端严格接入的 SILO 构建前,应当:
- 检查具名 IAM 策略中的精确/历史裸前缀;
- 检查服务账号的内联策略;
- 将每一处改成真正想要的具体资源;只有确实想表达整个命名空间时才使用尾部通配符;
- 所有站点完成滚动升级后再审计一次。
不要机械地把每个命中改成 arn:aws:s3:::*:那可能把原本不起作用的语句变成集群范围的授权或拒绝。含裸前缀的旧策略仍可加载,但不能通过三个严格写入端点原样交回;在修改同一策略或服务账号的其它属性前,必须先纠正该资源。
更安全的审计方式,是使用 policy-info 与 access-key-info API,而不是完整 IAM Export——后者包含普通用户与服务账号的密钥。STS 严格校验继续延后,直到可以单独审计仍在使用的机器客户端及其 Session Policy 模板。
为什么是中版本
本次使用 v3.12.0 而不是 v3.11.1,因为它同时跨越三条兼容边界:
- etcd 客户端从 3.6 中版本线进入 3.7;
- 模块验证过的
go下限从 1.25.0 提高到 1.26.0; - 新增了导出的裸 ARN 检查与严格资源集合校验 API。
模块路径仍是 github.com/minio/pkg/v3;/v3 导入后缀与全部现有 import 都无需修改。
Go 与依赖基线
go 与 toolchain 指令承担不同职责:
go 1.26.0是选定 etcd 3.7 模块要求的消费者下限。toolchain go1.27.0是维护开发与 CI 基线。- CI Actions 迁移到 Node 24 runtime。
主要选中版本变化如下:
| 模块 | 3.11.0 | 3.12.0 |
|---|---|---|
go.etcd.io/etcd/{api,client/pkg,client}/v3 |
3.6.6 |
3.7.1 |
golang.org/x/crypto |
0.54.0 |
0.55.0 |
golang.org/x/net |
0.57.0 |
0.58.0 |
golang.org/x/text |
0.40.0 |
0.41.0 |
github.com/minio/minio-go/v7 |
7.0.97 |
7.0.99 |
github.com/minio/mux |
1.8.2 |
1.9.2 |
github.com/cheggaaa/pb |
1.0.29 |
1.0.30 |
github.com/lestrrat-go/jwx/v3 |
3.0.12 |
3.0.13 |
github.com/lestrrat-go/httprc/v3 |
3.0.1 |
3.0.6 |
github.com/grpc-ecosystem/grpc-gateway/v2 |
2.27.3 |
2.29.0 |
go.uber.org/zap |
1.27.1 |
1.28.0 |
其它小型更新包括 uax29 2.3.1、fastjson 1.6.10、secp256k1 4.4.1 与 goccy/go-json 0.10.6。旧 lestrrat-go/option v1、gogo/protobuf 与陈旧的测试专用依赖退出选中依赖图。
etcd 3.7.1 是 3.7 版本线上第一个修复 GO-2026-6107 / CVE-2026-73500 的版本;该问题是未经认证即可触发的 TLS listener 拒绝服务。升级这些 Go 客户端模块 不会 自动升级运维使用的外部 etcd 服务端。SILO 不依赖被移除的 grpc.WithBlock 行为,发布验证也实际覆盖了 3.7.1 客户端连接 etcd 3.6.14 服务端。
依赖图声明 coreos/go-systemd 22.7.0,但模块将其 replace 为 22.6.0,因为 22.7.0 无法在 NetBSD 编译。SILO 服务端保留相同的可移植性覆盖。
兼容性
- import 路径与模块主版本不变。
- 既有策略继续按原方式加载和执行;只有严格创建/更新调用会拒绝错误前缀。
- 不执行策略、IAM 数据库、线缆协议或 etcd 数据迁移。
- 在 etcd 客户端拨号选项中使用
grpc.WithBlock的下游,需要改用受支持的 readiness 检查;SILO 不使用它。 - 真正升级外部 etcd 集群仍是独立运维流程。
- 本分支没有引入上游 AIStor Memory 与新的 AIStor 专属 S3 Tables Action 词汇。
消费者按以下方式选用本版本:
验证
已打标签的包通过:
- 仓库完整
make test门禁:固定版本 lint 加go test -race -tags kqueue ./...; - 重复运行裸 ARN 测试,以扰动 Go map 遍历顺序;
go mod verify、go vet、git diff --check与govulncheck,可达漏洞为零。
SILO 接入通过:
- 完整 IAM 服务端套件,包括具名策略精确/历史形式拒绝,以及服务账号创建/更新拒绝;
go test ./cmd -count=1、go vet ./cmd与go test ./...;- golangci-lint 2.13.1 零问题、
go mod verify与make check-gen; - 变异测试证明三个严格调用点与其集成断言都是承重的。
通过 VCS 直接解析模块,确认 v3.12.0 指向提交 2b087a1,模块校验和为:
Git 标签、GitHub Release、直接模块归档、来源提交与校验和均已验证。
相关变更
2bc3a91:CI Actions 迁移到 Node 24c8c6872:对齐 SILO Go 依赖栈2b087a1:在严格策略写入中拒绝裸 ARN 前缀;v3.12.0标签提交30c49bd:标签之后更新 README 依赖示例eee05a17c:SILO 启用具名策略与服务账号策略的严格写入56c67dacf:登记SN-2026-005
服务端发布范围
服务端严格策略写入集成(eee05a17c)已随 SILO 20260903 发布。STS 内联策略的严格校验仍保持延后,以便单独审计机器客户端与会话策略模板。
2.23 - Silo Pkg 3.11.0 发布
发布日期: 2026-08-04 · 版本: v3.11.0 · 提交: d8b1fa7 · 仓库: pgsty/silo-pkg
这是本分支的 第一个定版。它恢复了上游 minio/minio#20449 所报告的 IAM 桶/对象资源边界:策略条件键绕过修复、三个 LDAP 连接缺陷、证书监听器泄漏、有种子 RNG 的缺陷,以及模块真实的最低 Go 版本。
升级前需要确认两件事
- 本版本收紧了鉴权。 十二个桶级写操作不再能通过
arn:aws:s3:::bucket/*这样的对象级资源模式被授权。如果你自己编写桶级策略,请阅读 IAM 桶/对象边界——受影响者只需改一行策略,而MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on可完整恢复原有行为。 - 需与服务端条件键修复配套升级。 本版本的策略解析改动与服务端“保留内部条件键名”的改动各自覆盖问题的一半。服务端配套改动(
2f55347f7)已随 SILO 20260804 发布。
这个仓库是什么
silo-pkg 是 minio/pkg 的维护分支,为社区版 MinIO 分支提供上游(现由闭源产品驱动)不会再接纳的修复。仓库于 2026-08-02 由 pgsty/minio-pkg 改名而来。
模块路径刻意保持不变,仍然是 github.com/minio/pkg/v3,因此所有 import "github.com/minio/pkg/v3/..." 无需改动,只有 replace 指令的右侧需要更新:
/v3 后缀是模块的主版本号,不是目录名,不可省略。这也正是本次发布编号为 v3.11.0 而非 v4.0.0 的原因:Go 要求标签的主版本必须与 go.mod 中声明的主版本后缀一致,因此在一个 .../v3 模块上打 v4.0.0 会被工具链直接拒绝。真要发布 v4,就必须修改模块路径,并改写服务端、mc 与 Console 中约 395 处 import——那等于放弃"保留上游路径"所换来的直接替换能力,而这正是保持上游路径的意义所在。
IAM 桶/对象边界
每一个桶级 S3 操作,鉴权时对象名都是空的。IAM 匹配器把它拼成资源串,而对空对象名的情况补了一个尾斜杠:
"bucket/" 会被通配模式 "bucket/*" 命中,因为 * 匹配空串。于是一条把 s3:* 授在 arn:aws:s3:::bucket/* 上的策略——读起来是 “任意操作,但只作用于这个桶里的对象”——也连带授予了桶级操作。在多租户集群里,只持有这条授权的租户可以调用 PutBucketPolicy 装上 {"Principal":"*"},把桶变成匿名公网可读或可写,或者给自己授予桶级控制权;它也可以直接把桶删掉,而这正是上游 issue 中的复现步骤。
匿名访问所走的桶策略评估路径从来没有这个斜杠,是正确参照。只有 IAM 这条路径错了,而且只错在一个地方。
为何不修正整条边界
对所有桶级请求都不再补斜杠,是显而易见的修法,上游也试过:那次改动当天就因打破依赖旧行为的策略而被回滚。有两个性质让"完整修正"成为一次迁移,而不是一个补丁。
它会撤销真实部署所依赖的授权。 它撤销的不只是危险的桶写入——通过 bucket/* 授予的 ListBucket、GetBucketLocation、ListBucketMultipartUploads 同样会被撤销。证据就是上游自己的测试套件:11 个 STS 集成测试把 s3:ListBucket 授在 bucket/* 上,然后断言列举能成功。连写服务端的项目都这么写,生产策略里只会更多。
它切的是两个方向。 匹配器对 Allow 和 Deny 拼的是同一套资源串,所以删掉斜杠在收紧过度授予的 Allow 的 同时,也放松了过度阻断的 Deny。一个用 Deny s3:* on bucket/* 锁死某个桶的管理员,会悄无声息地失去那层保护。
保护集合是如何确定的
范围由一个问题决定:够到这个动作,能让调用者拿到它的对象级授权本来就给不了的东西吗?
之所以该问这个问题,取决于缺陷的触发条件。资源匹配发生在动作匹配 之后,所以这个 bug 只有在语句本身已经授予了那个桶级动作时才会触发——现实中这意味着 s3:*。因此受影响的主体本就对这个桶里的每个对象拥有完整的读、写、删权限。关键在于:对于一个已经控制桶内全部对象数据的调用者,触及某个桶级动作还会额外赋予什么能力。
不再接受对象级授权的十二个动作:
| 动作 | 入选理由 |
|---|---|
PutBucketPolicy、DeleteBucketPolicy |
能把访问权发给别的主体(包括匿名),也能给调用者自己补上从未授予的桶级动作。自我提权与公开暴露。 |
PutBucketObjectLockConfiguration、PutBucketVersioning |
击穿的恰恰是专门用来"防住有写权限的人销毁数据"的保护。 |
PutReplicationConfiguration、PutLifecycleConfiguration |
以服务端凭据运行,并在调用者权限被吊销后继续生效。 |
DeleteBucket、ForceDeleteBucket |
不可逆地销毁桶实体及其配置。上游 issue 的原始复现动作。 |
PutBucketCors、DeleteBucketCors、PutBucketQOS、PutInventoryConfiguration |
在今天的服务端没有挂任何行为——要么根本没有 handler,要么 handler 在鉴权之后直接返回 NotImplemented。收走它们不影响任何能用的东西,并且提前覆盖。 |
刻意不予保护,并且有测试对此做出断言——于是把其中任何一个加回去,都是一次带可见代价的明确决定,而不是往列表里添一行:
PutBucketTagging、PutBucketEncryption、PutBucketNotification。 它们都是桶级写入,早期草案确实纳入过保护。三者都不给调用者任何它还没有的访问权——受损的是所有者的合规姿态,不是访问边界;而一个拿到s3:*onbucket/*、并被告知"这个桶归你"的租户,完全合理地会去给它打标签、设默认加密、配事件通知。用很低的安全收益去换实打实的兼容成本,在维护版本里是个错误的交易。CreateBucket。 它作用于一个还不存在的桶,没有东西可篡改、可摧毁;而供应流程常用租户自己的bucket/*凭据去创建租户的桶。- 读/列举族(
ListBucket、GetBucketLocation、各类配置读取)。打破它们正是上游那次完整修复被回滚的原因,它们要等一次带迁移路径的发布。
改动只作用于 Allow 语句。Deny 语句保持历史资源串,因此任何桶级封锁都不会被削弱,NotResource 排除也保持完整覆盖范围。
单调性,以及那个错了两次的论断
上面这一切都建立在一条性质上:这个变更可以收走权限,但绝不能新增权限。 而这条性质被断言过两次,两次都是凭推理而非凭测试,也两次都是错的。把"怎么错的"记录下来,比只记录最终状态更有价值。
第一次尝试让省略的斜杠同样作用在了 NotResource 匹配上——而 NotResource 是 排除。Allow s3:* NotResource bucket/* 这样的语句,历史上不会作用于该桶的桶级请求;把排除拿去和裸桶名匹配,排除就不再命中,于是它所限定的那条 Allow 反而变宽了,而且恰恰是在受保护的那些写入上。
第二次尝试修好了这一处,并在发布时写着结论“可证明地单调”。针对定长通配模式的测试给出了反例。省略斜杠改变了模式所匹配的目标字符串,而一个模式完全可能匹配 "mybucket",却从来匹配不上 "mybucket/"。定长通配是最直接的例子:
? 恰好匹配一个字符。对九字符的 "mybucket/" 它匹配不上,所以这条语句从来没有授予过那个桶级写入;而对八字符的 "mybucket" 它匹配上了,于是这次加固授予了有缺陷的匹配器都拒绝的操作。
在受保护路径上,匹配器现在要求 两种形式同时命中——裸桶名,以及历史的 "bucket/"。结果是与历史判定取交集,因此在构造上就是单调的:不存在任何它能新满足的模式。mybucket* 照旧授予(它本来两种形式都匹配),mybucket/* 照旧被收走,mybucke? 像以前一样被拒绝。
证据
这条性质是被验证的,不是被论证的。我们生成了一份 27000 条鉴权判定 的语料——15 种资源模式 × 3 个桶 × 5 种对象名 × 20 个动作 × 6 种语句形式——分别在加固前基线与本版本上执行,并逐条比对:
| 迁移方向 | 条数 |
|---|---|
false → true(变宽) |
0 |
true → false(收窄) |
144 |
| 不变 | 26856 |
那 144 条收窄全部落在设计意图之内,没有一条溢出:动作恰好是受保护的十二个;语句形式只有三种 Allow,Deny、NotResource 排除与 deny-NotResource 三种形式 零变化;资源模式只有四种对象级模式;请求只有桶级请求,对象级请求完全未被触碰。12 × 4 × 3 = 144,严丝合缝。
两个层次都有回归测试。本仓库有十二条匹配器测试钉死每个方向,其中包括一条不变量测试,确保受保护动作个个都是纯桶级动作——ResetBucketReplicationState 名字唬人,实为对象动作,不入集合。服务端有三条端到端测试驱动真实 handler,分别位于客户端、内联会话策略与 S3 路由三个层次;三条在修复前的构建上全部失败,在本版本上全部通过。
需要改什么
只有当你的存量策略把十二个动作之一(或 s3:*)授在含 / 的资源模式上、且同一个桶没有裸桶 ARN 时,你才会受影响。修法是在对象模式旁边补上裸桶 ARN:
这种配对写法是惯例形式,也是上游自己的测试所采用的写法,在本版本之前同样有效。内置策略不受影响——readwrite、readonly、writeonly、diagnostics 全部使用 Resource: "*"。
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 在启动时读取一次,可完整恢复历史匹配行为——过度授予与过度阻断两个方向一并恢复。它目前是单一的全局开关,按动作粒度的作用域已列入待办。
策略条件键解析顺序
getValuesByKey() 此前会先按规范 MIME 拼写(http.CanonicalHeaderKey)查找策略条件键,再尝试原始名称。而它读取的那张表里,混放着服务端为当前请求计算出的值(SourceIp、SecureTransport、CurrentTime、username 等,以条件键拼写存储)和请求自带的 HTTP 头(以规范 MIME 拼写存储)。
先查规范拼写,就让客户端的请求头能够覆盖服务端算出来的值。
对 MinIO 服务端而言这是一次策略绕过。最简单的例子是 s3:prefix:一个 Prefix 请求头可以满足家目录前缀条件,而真正的 ?prefix= 查询参数仍然列举整个桶。同一条路径还能触及 aws:SourceIp、aws:SecureTransport、aws:CurrentTime、aws:EpochTime、aws:username、aws:userid、aws:principaltype、aws:UserAgent、aws:groups、ldap:username、ldap:groups、jwt:groups、s3:versionid、s3:signatureversion、s3:signatureAge、s3:authType 与 s3:LocationConstraint。匿名桶策略直接暴露。SigV4 也拦不住,因为客户端可以添加不在 SignedHeaders 列表里的头。
还有第二重后果:当服务端以一种拼写存值、而策略键解析到另一种拼写时,错误的条目会胜出。s3:object-lock-mode 可能解析到调用者的 X-Amz-Object-Lock-Mode 请求头,而不是服务端实际会施加的保留模式。
修复反转了查找顺序:先精确匹配条件键本身的名字,只对确实指代请求头的条件键(例如 s3:x-amz-* 家族)才回退到规范拼写。这移植了 minio/pkg#226,并补上了上游那次改动没有携带的回归测试。
在库的原始映射层面,如果生产者把同一个逻辑字段同时以精确条件名和规范 MIME 名存入,现在精确名胜出。这是一条库层查找规则,不是"查询参数优先"的 S3 线缆协议规则。Silo 服务端会先按真实来源规范化条件值。对于存储类别与上传标签这两处 Header 与查询形式都保持兼容的字段,Header 只要出现就胜出,包括空值,查询参数仅作回退。
LDAP 连接路径
connect() 中的三个缺陷,其中两个由本分支自己在 b0c08a7 中引入,随 v3.6.2 与 v3.6.3 发布。使用这两个版本的用户应尽快升级。
ServerInsecure 开启时 StartTLS 被跳过。 上游把 StartTLS 调用放在只由 ServerStartTLS 控制的外层块里,因此同时开启两个选项时,会先建立明文连接再升级。b0c08a7 把该调用移进了 else 分支,导致只要 ServerInsecure 为真,StartTLS 就不可达。连接保持明文,随后的 bind 就在这条连接上发送了凭据。MinIO 的 MINIO_IDENTITY_LDAP_SERVER_INSECURE 与 MINIO_IDENTITY_LDAP_SERVER_STARTTLS 是独立开关,Validate() 不拒绝任何组合,所以这个状态是可达的。
本版本恢复了上游语义:两个开关是 叠加关系,而非互斥。ServerInsecure 关闭隐式 ldaps://;ServerStartTLS 仍然执行升级。暴露窗口仅限 v3.6.2 与 v3.6.3。
没有 TLS 配置段的 Config 可能在 ldaps:// 路径上 panic。 l.TLS.Clone() 移出 StartTLS 分支之后,普通 ldaps:// 连接也会调用它。Clone() 对 nil 接收者返回 nil,而下一行却给 ServerName 赋值。MinIO 服务端总会提供 TLS 设置,但这是一个库,mc 同样在消费它。现在代码会回退到空的 tls.Config,与 DialURL 本来会构造的一致。
StartTLS 没有超时。 go-ldap 只在 requestTimeout > 0 时才启动请求计时器,而 StartTLS 本身没有超时。一台完成了 TCP 建连、随后对扩展请求不再响应的服务器,可以让连接 goroutine 永久挂住。现在计时器在 StartTLS 之前就已武装。
StartTLS 失败会泄漏连接。 这一条继承自上游。拨号失败不会返回连接,因此 StartTLS 失败是 connect() 里唯一可能同时返回连接与错误的路径。调用方只在错误为 nil 时接管所有权,于是对一台升级功能损坏的服务器,每次登录尝试都会遗留一个 socket。失败路径现在会关闭连接并返回 nil。
其它修复
- certs:文件监听器从未被停止。
Manager.AddCertificate()注册了两个notify.Watch()却一个都不停:如果第二个失败,第一个就泄漏;而且两个都会在 manager 关闭后一直存活到进程退出。Certificate.Watch()与watchFile()有同样的问题。四条路径现在统一使用watchDirSafe(),它返回一个在出错与ctx.Done()时被调用的停止函数。这移植了 minio/pkg#228 的certs/部分。在 Windows 上该函数用轮询替代文件系统通知,而不是把轮询仅作为失败回退,因此证书重载可能滞后一个symlinkReloadInterval(10 秒)。本分支没有 Windows CI,该平台仅做了交叉编译。 - rng:reader 的子密钥取自一个被清零的局部变量。
init()把 32 字节熵读进r.tmp,却从一个同名的、已清零的局部变量派生出四个子密钥,把四条按块划分的流坍缩成了一条。随后Reset()与ResetSize()会逐字节重放上一条流。MinIO 每次randreader.New()都创建新的 reader 且从不 reset,因此实际服务端影响有限;这个缺陷是 warp 暴露出来的。这移植了 minio/pkg#230。 - xtime:
Duration实现了UnmarshalJSON却没有MarshalJSON。 编码产出的是纳秒整数,而解码无条件剥掉首尾各一个字节并期待带引号的字符串,两个方向都无法往返。现在它使用time.Duration的字符串形式编码。这移植了 minio/pkg#242。
兼容性影响
- 十二个桶级写操作不再能通过对象级资源模式获得授权。 见需要改什么。对象访问、
ListBucket、CreateBucket、桶标签、默认加密与事件通知均不受影响,Deny语句与NotResource排除同样不受影响。 - 最低 Go 版本从
1.26.1降回1.25.0。go指令里的补丁号对每一个消费者都是硬性下限,而不是构建该模块所用工具链的记录。惯例做法是go行写语言版本、单独的toolchain行写开发版本。1.25.0才是依赖图实际要求的版本,也是上游声明的版本。CI 会在GOTOOLCHAIN=local下用 Go 1.25 构建完整测试套件,因此这个下限是被证明的,而不是宣称的。 xtime.Duration的 JSON 线缆格式变更,从纳秒整数改为"2h"、"30m"这样的时长字符串。已持久化的数值形式无法再读回。在 MinIO 与mc中未发现此类用法:批处理作业定义以 YAML 持久化,msgp 路径仍为 int64。- 同时开启
ServerInsecure与ServerStartTLS、且 LDAP 服务器不支持 StartTLS 的部署,在 v3.6.2/v3.6.3 上会以明文成功连接,现在则连接失败。这是正确结果,但它暴露在连接阶段而非配置校验阶段。对这类服务器请关闭ServerStartTLS。 Policy.IsAllowedActions对那十二个受保护动作可能与直接判定不一致。 它遍历SupportedActions,其中包含s3:*这个模式本身,因此返回的集合可能包含s3:*——从而看起来允许某个受保护动作——而直接求值却是拒绝。服务端没有任何地方调用它,Console 调用时传的是空桶名,永远走不到加固分支。此处选择记录而非修改:在维护版本里改变一个公开 API 的输出,风险更大。
与上游 v3.11.0 的差异
版本号跟随上游的线,不声称内容一致。以 policy/ 下的动作字符串常量为口径,实测差异如下:
| 数量 | |
|---|---|
上游 minio/pkg v3.11.0 |
291 |
silo-pkg v3.11.0 |
270 |
有 24 个动作只存在于上游: 6 个 s3:*ObjectAnnotation*、5 个 admin: 动作(DistJobStatus、Get/SetBucketCompression、两个 TablesReplication*),以及 13 个覆盖函数 CRUD 与打标签的 s3tables: 动作。它们属于本分支刻意不采纳的 AIStor 词表,因为社区版服务端并不实现这些功能。
有 3 个动作在两侧名称不同。 上游对它们做了改名与拆分,本分支保留较早的名称:
silo-pkg v3.11.0 |
上游 minio/pkg v3.11.0 |
|---|---|
s3tables:TagResource |
s3tables:TagTable、s3tables:TagWarehouse |
s3tables:UntagResource |
s3tables:UntagTable、s3tables:UntagWarehouse |
s3tables:ListTagsForResource |
s3tables:ListTagsForTable、s3tables:ListTagsForWarehouse |
因此,一条写了这六个动作名之一的策略,在两者之间 只有一个 能通过校验。Silo 服务端、mc 与 Console 都没有引用它们,所以在本生态内没有影响;但从上游 v3.11.0 切换过来的消费者应当知道,动作词表并不可互换。
rng 没有 arm64 汇编。 上游在本分支分叉点之后加入了 rng/xor_arm64.{go,s},本版本在 arm64 上回退到纯 Go 的 xor_noasm.go 路径。结果正确、交叉编译干净,但在该架构上比上游慢。它是未来同步的一个干净候选:纯性能改动,不牵涉词表分歧。
服务端配套行为
- 本版本的条件键改动 必须 与服务端"保留内部条件键名、按语义来源填充取值"的改动配对,见文首提示。
s3:signatureAge只有在 SigV4 预签名请求校验器算出它之后才会暴露。在其它任何请求类型上,客户端提供的x-amz-signature-age头都会被忽略。s3:prefix、s3:delimiter、s3:max-keys只来自查询参数。内容哈希、拷贝源、元数据指令、SSE 与对象锁条件只来自对应的请求头。校验预签名请求时消费的X-Amz-Content-Sha256查询值不会成为策略条件。s3:x-amz-storage-class保留其兼容的查询形式,PutObject与CreateMultipartUpload上的请求标签同样保留。这两个字段都是 Header 出现即胜出,只有 Header 缺失时才使用查询参数。s3:ExistingObjectTag/*只来自从已存储对象加载的标签,因此请求自带的X-Amz-Tagging不能再冒充既有对象状态。PutObject、CreateMultipartUpload与PutObjectTagging把s3:RequestObjectTag/*绑定到这些 handler 实际消费的标签输入。其余动作路径出于兼容保留了历史的X-Amz-Tagging头回退,因此只在 API 确实消费标签的地方,才把请求标签条件当作约束使用。aws:SourceIp由转发头计算得出。它是否可强制执行,取决于服务端的可信代理配置;参见服务端自己关于MINIO_API_TRUSTED_PROXIES的发布说明。
验证
以下全部在打标签的那个提交上执行,工作树干净,标签与 HEAD 一致:
make test——golangci-lint 加go test -race -tags kqueue ./...,全部包通过。go mod tidy -diff干净;gofmt -l为空;go vet ./...干净。linux/amd64、linux/arm64、darwin/arm64、windows/amd64四个组合的交叉编译。govulncheck ./...——零个可达漏洞。仅剩一条模块级提示 GO-2026-5932(x/crypto/openpgp):该包已停止维护、没有修复版本,且本仓库并未 import 它。- 从空模块缓存经公共 proxy 解析,确认发布出去的版本确实可获取。
- 上文所述的 27000 条鉴权判定语料。
依赖与工具链
依赖更新清除了 govulncheck 此前报告的九项 可达 问题:经 sftp 触达的七个 x/crypto/ssh 问题、经 etcd 触达的 gRPC GO-2026-6061,以及经 oidc 触达的 go-jose GO-2026-4945。
有五个依赖——minio-go、minio/mux、etcd client/v3、go-oidc、lestrrat-go/jwx——刻意未升级。MinIO 通过 replace 消费本模块,而最小版本选择会取整个依赖图中的最高版本,因此在这里升级它们会连带把服务端也拽着往前走。这五个都没有必须升级的已报告漏洞。
三个工作流此前都向 setup-go 索要低于 go.mod 要求的 Go 版本,并在第一条 Go 命令上失败,现已对齐。linter 此前还从 master 分支拉取安装脚本并每次重装;URL 与版本现已固定到 v2.11.3,且当已安装版本匹配时会跳过下载。
刻意未采纳的上游改动
- AIStor 策略词表(Memory/cortex、Tables/Iceberg、KMS、压缩与注解)以及带类型的动作常量重构,社区版服务端均不实现。这也是动作词表差异的来源。
securityAuditAdmin:它授予admin:ExportIAM,因而会暴露每一个密钥,与名字给人的印象相反。- rng 的 AVX2/NEON 汇编。其中 arm64 那一半已在上文列为未来同步候选。
net.BandwidthBytesPerSec(上游声明了却从未读取)、replicationAdmin与DistJobStatusAction。- 两项先采纳、复核后又移除的改动:
consolereadonly内置策略与GetAllGlobalCertificates,两者都没有消费者。一旦运维把内置策略名绑定到用户身上,再撤回就格外危险:策略映射按名字持久化,而无法解析的名字会并入一个拒绝一切的空策略;它继承的admin:CreateUserDeny 也无法与iamAdmin组合使用。那个证书辅助函数清点的是社区版服务端从不填充的缓存。 - 上游的 golangci-lint
tool指令,它会给每个下游消费者的模块图增加约 200 个 linter 依赖。
刻意推迟的部分
minio/minio#20449 的一般性问题——bucket/* 仍会触及 ListBucket、GetBucketLocation、各类配置读取、CreateBucket 以及上述三个租户可能合理使用的写入——在这里 没有 被关闭。彻底关掉它意味着撤销真实部署所依赖的授权,因此它属于一次带迁移路径的发布。
那次发布欠运维的东西,比"一份更长的动作清单"要多,因为没有人能穷举所有部署的存量策略——这给"靠猜来划定保护集合"的做法设了一个硬上限。有三件事能抬高它:
- 启动时的策略审计:遍历存量策略,逐条点名哪一条的含义会改变,授予与拒绝两个方向都点。它是只读的,甚至可以 先于 强制生效单独发布,从而把"升级后的意外"变成"升级前的清单"。
- 会自我解释的拒绝:当一个请求因为"只有对象级授权匹配上"而被拒时,就把这句话说出来,并点名那个兼容开关。一次 30 秒能自诊断的破坏,成本比静默破坏低一个数量级。
- 带作用域的开关:
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH今天是全有全无,只想要回一个动作的运维,被迫连自我提权那条路一起重新打开。
相关提交
- d8b1fa7:fix(policy): settle the bucket-write hardening’s scope and monotonicity
- 1f97549:fix(policy): extend the bucket-write hardening to every bucket-only write
- 3c24ad1:fix(policy): withhold object-only grants from sensitive bucket writes
- da6a22a:docs: say what this fork is and how to depend on it
- 4055b2f:fix(xtime): marshal Duration as a duration string
- 13c26cd:fix(rng): initialize the reader subkeys from the seeded entropy
- 88b37ac:fix(certs): stop file watchers on every exit path
- 74dd36e:fix(ldap): keep StartTLS when ServerInsecure is also set
- 424c3d0:fix(ldap): close the connection when StartTLS fails
- 045d10f:fix(ldap): guard a nil TLS config and arm the StartTLS deadline
- 5c4bf50:fix(policy): prefer the exact key name over the canonical header form
- 802539f:chore(deps): refresh the dependency set and declare the real minimum Go
- e4ec64a:ci: build on the Go version go.mod requires, and prove the declared minimum
- 747d8b8:build: pin the golangci-lint installer and skip a matching install
2.24 - Silo 20260903 正式发布
版本日期: 2026-09-03 · 正式发布: 2026-09-04 · 版本: RELEASE.2026-09-03T13-18-01Z · 上一版本: RELEASE.2026-08-06T00-00-00Z
SILO 20260903 是首个完整使用 Silo 品牌的版本之后的一次安全与正确性发布,重点收紧核心服务端不变量:客户端 header 不能凭空获得复制权限;浏览器 Origin 不能在鉴权前触发桶元数据 I/O;并发桶配置写入不能彼此静默覆盖;授权与校验和行为进一步对齐 AWS S3。
本文以 20260806 为基线,汇总 2026-09-03 交付的服务端、Console、共享包与客户端完整变更。后续修复(包括 SN-2026-011)已分别随 SILO 20260916、SILO 20261015 与 SILO 20261018 发布。
亮点
- 复制信任来自授权。
X-Minio-Source-Replication-Request与相关内部字段只有在签名验证、精确 marker 校验以及s3:ReplicateObject/s3:ReplicateDelete授权后才获得复制语义。不可信字段在鉴权后清洗,因此不会破坏 SigV4。 - 桶级 CORS 完整落地。
PUT、GET、DELETE ?cors持久化真实桶策略,覆盖服务端全局 fallback,并通过站点复制收敛。预鉴权查找只读驻留元数据;启动期与已知元数据加载失败状态都保持 fail-closed。 - 桶元数据更新串行化。 共享
metadata.lock覆盖所有整记录配置写入、迁移、导入、站点 adoption 与 healing。ForceCreate不再擦除既有配置;存在 Object Lock 文档的桶始终使用纯Enabled版本控制。 - 授权与请求动作一致。 显式删除版本需要
s3:DeleteObjectVersion;用户和组的启用/禁用按目标状态检查对应 action;策略写入拒绝空的 ARN namespace。 - 校验和进一步对齐 AWS S3。 UploadPart 可由服务端计算校验和;联邦
UploadPartCopy返回校验和;multipart completion 返回ChecksumType;非法断言使用 AWS 兼容错误;CRC64NVME与COMPOSITE的无效组合被拒绝。 - SSE-C 读取与复制统一校验密钥。 零字节对象和
GetObjectAttributes不再绕过密钥认证;null-version 改写与原地换钥不会再留下不可读密文或不一致的 checksum metadata。 - 发布前主动减法。 删除死代码与过期 lint 例外;兼容快照只追踪真实对外服务面;依赖锁定到已审 revision;timeout 测试改用私有随机源。
- 客户端版本归入同一基线。 mcli 20260903 新增只读 checksum verify,完成 fail-closed 凭据脱敏,修复 Metrics JSON 与 S3 Select,严格校验策略写入,并提供签名软件包、溯源证明与验证过的 amd64/arm64 镜像。
安全修复
仓库安全台账为五个没有 CVE 的发现分配了稳定的本地编号。SN- 不是 CVE。完整威胁模型、兼容影响与运维动作集中记录在 SILO 20260903 安全说明。
| 编号 | 影响面 | 修复后的不变量 | 运维影响 |
|---|---|---|---|
SN-2026-006 |
SSE-C 零字节对象的读取与复制 | 即使没有 payload block 可解密,也必须认证客户密钥 | 错误密钥返回 403 AccessDenied;正确密钥不变 |
SN-2026-007 |
SSE-C 对象的 GetObjectAttributes |
获取属性需要客户密钥,除非请求确实来自已授权复制对端 | 裸 replication marker 不能跳过密钥认证 |
SN-2026-008 |
读、写、multipart、删除、Snowball、事件中的内部复制字段 | 复制语义需要鉴权与对应复制权限 | 普通客户端携带内部样式 header 时仍按普通 S3 语义处理 |
SN-2026-009 |
管理 API 的用户与组状态变更 | 启用与禁用分别校验不同 action | 过去只授予一个 action 却依赖双向操作的自定义管理策略需要拆分 |
SN-2026-010 |
带 versionId 的 DeleteObject / DeleteObjects |
显式删除版本需要 s3:DeleteObjectVersion |
检查 grant;原本用 Deny s3:DeleteObject 阻止永久删除的策略还需显式 Deny 新 action |
五个缺陷都继承自已归档的上游服务端代码,影响此前所有 SILO 版本。SN-2026-008 是对 CVE-2026-34204 接收端全链路修复的补全。
内嵌 Console v2.3.0 线还包含可信代理边界、TLS 校验修复、响应与日志脱敏、WebSocket 转发上限。
S3 正确性与互操作
Multipart 校验和
本版本关闭了 #46、#47、#48、#50 报告的 multipart 互操作问题组:
- 客户端在创建上传时选择算法、但没有为每个 part 提供 checksum header 时,
UploadPart由服务端计算所选校验和; - 旧版联邦后端的
UploadPartCopy不再丢掉远端返回的 checksum; CompleteMultipartUpload返回ChecksumType,缺失、畸形、矛盾或不支持的 checksum 使用 AWS 兼容错误;- 未知算法以及无效的
CRC64NVME+COMPOSITE组合直接拒绝,不再静默 canonicalize。
实现刻意分层:wire parser 拒绝非法声明,multipart state 记录所选算法和类型,完成阶段再将最终断言与状态比对。没有显式启用额外 checksum 的普通客户端不受影响。
独立证据记录见服务端计算 UploadPart 校验和、multipart completion 错误语义与 ChecksumType 传递。
CopyObject 与 SSE-C
CopyObject 现在会:
- 在可选压缩前对逻辑对象计算 checksum;
- 在响应中返回 checksum 字段;
- metadata-only copy 保留 transform state;
- 用源密钥解密源 checksum metadata,并为目标密钥重写;
- null-version 在任意 pool 方向复制时都不会丢失 current-version metadata;
- 对象层必须改写数据时,正确完成原地 SSE-C 换钥与重加密。
零字节对象与 attributes 修复消除了两个彼此独立的错误假设:“没有数据被解密”曾被等同于“密钥已经认证”。新规则是显式的:访问 SSE-C metadata 或 ciphertext 必须成功解封对象密钥,除非请求已经跨过授权 replica 边界。
CopyObject 状态矩阵与真实互操作证据记录在 CopyObject 的 SSE-C、checksum 与响应正确性。
列举与联邦
- ListObjects 快捷路径在 bucket 不存在且带 prefix 时也返回
NoSuchBucket(#32、PR #37)。 - 联邦
UploadPartCopy在公开响应中保留后端 checksum(PR #72)。 - 站点复制状态按站点计算 metadata 操作,不再把组级结果重复相乘,并将 CORS 作为独立配置字段校验。
missing-bucket shortcut 的完整记录见 ListObjects 必须先证明桶存在,再做优化。
桶级 CORS 与请求信任
桶级 CORS 端到端实现:
- S3 XML 类型与 rule matcher 校验协议 grammar 和多规则 preflight;
- bucket metadata 保存原始配置,
GET ?cors原样返回; - 外层 middleware 优先使用桶策略,不存在时才回落全局 origin 配置;
- 站点复制用带 tombstone 的 LWW register 单独承载 CORS;
- 预鉴权查找不加载 metadata、不创建 cache entry;
- 启动、已知桶元数据加载失败、删除、refresh 与按需重载都会显式维护 fail-closed 状态。
load-failure 状态是必要复杂度。预签名 URL 用自身签名完成鉴权,不需要 bucket policy;如果服务器忘记“真实桶的 CORS 文档加载失败”这一位信息,再回落宽松全局策略,就会丢掉这个已授权浏览器请求唯一的 origin 边界。
复制请求加固遵循同一原则。服务端先认证原始请求——内部 header 可能属于 SigV4 签名内容——再根据身份与权限得到一个私有 trust decision,最后清洗不可信的 request clone。clone 与原请求共享 trailer map,streaming checksum 不会丢失。Snowball worker 按 entry 推导信任,不继承 request-wide privilege bit。
完整设计与被否决方案记录在 鉴权前不做 I/O,Header 不产生权限。
桶元数据与 Object Lock
桶配置存放在同一个 .metadata.bin 记录中,但此前 policy、lifecycle、encryption、tags、quota、replication、Object Lock 与 CORS 使用彼此独立的锁更新。两个正确的 read-modify-write 因此可能都返回成功,后保存的一方却静默覆盖先前字段。
本版本用一个有界的 metadata.lock transaction 串行化所有整记录 mutation,同一把锁覆盖:
- 常规配置保存与删除;
- 旧元数据 migration 与 import;
- bucket 创建、
ForceCreate与站点 adoption; - healing 与 inconsistent-version repair;
- 只合并变化字段的 replication receive path。
实现没有把元数据系统改造成通用事务框架:既有 record 与 parser 保持不变,只集中 serialization boundary,并将复制导入限制为真正发生变化的字段。
Object Lock 还有第二条不变量:有效 lock 配置解析为 enabled 后,versioning 一律归一化为纯 Enabled;prefix exclusion 与 suspended 状态不能保留。判定依据是解析后的配置,不是与最小 XML 文档做字节比较,因此带 Default Retention Rule 的合法文档也无法绕过。测试覆盖 Update、读回、磁盘重载,以及两种畸形旧 versioning 状态。
IAM、策略与配置行为
- 显式版本删除按 AWS 的
s3:DeleteObjectVersion授权;复制删除保留s3:ReplicateDelete合同。 SetUserStatus与SetGroupStatus按目标状态授权,不再统一检查 enable action。- 新建或更新 named policy 与 service-account policy 时,拒绝空的 S3、S3 Tables、KMS ARN namespace、它们的历史序列化形式,以及同时含
Resource与NotResource的 statement;已存 policy 仍可加载。 - 已启用的旧 PostgreSQL/MySQL notification target 必须给出 connection string;启动现在以不含凭据的诊断失败,而不是静默丢掉所有 target。
MINIO_CONFIG_ENV_FILEparser 保留 named target、引号值与受支持的 shell-like assignment,但不会把文件当 shell 执行。
这些属于兼容性收紧,不是格式迁移。部署前必须阅读升级检查。
详细记录分别覆盖按目标状态授权用户/组操作、安全解析配置环境文件与旧数据库通知迁移的 fail-fast 设计。
组件
20260903 的依赖与配套发布线固定如下:
- Go 1.27.1
minio-go/v7: 上游兼容 pseudo-version,revision 结尾为0e78d3f18efe;临时silo-go分叉从服务端依赖图退役madmin-go/v3:v3.0.110silo-pkg/v3:v3.13.2,使用自有github.com/pgsty/silo-pkg/v3模块路径- 内嵌 Console:
v2.3.0,包含可信代理、TLS、脱敏与 WebSocket 安全修复 - 捆绑 mcli:
RELEASE.2026-09-03T07-13-05Z,保留mc兼容 alias - Helm Chart:
7.0.2;请使用正式发布的 Server 镜像pgsty/silo:RELEASE.2026-09-03T13-18-01Z与 mcli 镜像pgsty/mc:RELEASE.2026-09-03T07-13-05Z
tag 源码中的 Helm 提示: 签名 Server tag 的
helm/silo仍保留临时值RELEASE.2026-09-03T00-00-00Z。发布后提交4c164907f已把当前main上的 Chart 修正到正式 tag。若直接从 tag 源码归档渲染 Chart 7.0.2,请显式设置image.tag=RELEASE.2026-09-03T13-18-01Z;二进制与软件包不受影响。
服务端有意保留 MinIO 兼容的 Go module 与 wire identifier。替换 import target 不会改名公开 S3/Admin API、MINIO_* 配置、x-minio-* header、/minio/* route 或磁盘 metadata。
工程清理
最终发布审查删除了不保护兼容性或正确性边界的复杂度:
- 删除废弃 encryption helper、旧 handler path 与未使用 event-target function;
- 用更小的真实服务 route 兼容基线替代源码级 exported-symbol inventory;
- 删除
wait_pipelint exclusion,迁移到gomodguard_v2; - 用两个 helper 收口 CORS load-failure 生命周期,不在六条路径里分别 open-code set 操作;
- 站点复制 import 只更新发生变化的 metadata 字段;
- dynamic-timeout 测试使用私有随机源,不再修改 package-global seed;
- 保留共享 metadata lock、CORS tombstone、两级 replication trust 与覆盖各项已复现故障的回归测试。
发布前验证记录(包含测试期间发现并修复的问题)见 SILO Server 20260903 发布前复审。
升级检查
生产切换前请阅读从 RELEASE.2026-08-06 升级。至少完成:
- 给确实需要删除显式版本的身份授予
s3:DeleteObjectVersion;凡是用Deny s3:DeleteObject防止永久删除的策略,都要同时 Deny 新 action; - 拆分自定义管理策略中的 enable 与 disable grant;
- 重新提交 policy 前修正裸 ARN prefix;
- 给启用的旧 PostgreSQL/MySQL notification target 增加 connection string;
- 核对显式选择 checksum algorithm/type 的应用;
- 站点复制组所有成员全部升级后,再创建或修改桶级 CORS;
- 回滚前导出 CORS 配置,因为 20260806 不理解该字段;
- 分布式集群在一个维护窗口切换所有节点,混合 Silo 二进制无法成组。
对象与纠删码磁盘格式不变;分布式集群要求所有节点运行同一版本。
已知问题与明确延期
以下事项不在本版本修复范围内:
- 条件删除(#10、PR #12)。
DeleteObject忽略 HTTPIf-Match,DeleteObjects忽略每个<Object><ETag>,都会执行无条件删除,详见设计分析(已在后续 SILO 20261015 中修复)。 - 非 CORS 的多站点配置删除(#77)。 一个站点删除 policy、SSE、tag 或 quota 后,仍持有配置的对端可能把它恢复。单站点不受影响;CORS 使用 tombstone-aware register,也不受此缺陷影响。依赖这些复制删除的部署必须逐站核对(已在后续 SILO 20260916 中修复)。
ListMultipartUploads过滤(#79)。prefix类似精确 key 匹配,max-uploads、key-marker与delimiter尚未完整生效,详见兼容性分析与 2026-10-18 决策(提交4733e1423)。- 旧联邦后端
CopyObject(#99、#100)。 旧联邦后端可能忽略请求的 checksum algorithm,并拒绝 inline source object;不使用该后端的部署不受影响(已在后续 SILO 20261015 中修复)。 - 混合版本站点复制。 20260806 对端会接受但忽略桶级 CORS,并持续报告 mismatch;所有站点升级前不要配置 CORS。
- 回滚桶级 CORS。 20260806 重写 bucket record 时会丢掉未知 CORS 字段;先导出,重新升级后再恢复。
ILM relocation(PR #60)、更广泛的 SSE 支持(#61)、operator 发现(#30)、NATS target 热重载(#40)、Renovate(#20)等开放 enhancement 不在本版本范围内。
验证记录
完整本地验收运行在 ebac0ca73bbf251b070bb6df4d8005015841f901:
- 完整
cmd与internal测试套件; go test -race ./cmd,365.448 秒通过;- lint 0 issue、rebrand/compatibility guard、生成文件检查与
govulncheck; make verify覆盖 FS、erasure、分布式 erasure、erasure sets、多池、IPv6 多池:174 PASS / 0 FAIL。
之后只有一行代码修复 84e1580a4:GetConfig 按需重载成功后清除 CORS load-failure bit。后续依赖工作把工具链推进到 Go 1.27.1、把 x/crypto 推进到 0.56.0。在精确发布源码 9b11dc9469e650815b775cb47b039610644f5da4 上,git diff --check、完整 cmd 与 internal 测试、完整 cmd race、rebrand 与生成文件 guard、lint、govulncheck,以及远端 Go CI、VulnCheck、Test Release、Release workflow 全部通过。
最终功能验收使用四台 Ubuntu 24.04 arm64 虚拟机,每台四块 XFS 数据盘。测试覆盖 20260806 → 20260903 协调升级、回滚与再次升级;TLS 上的完整 mcli 功能脚本;1,004 个对象上传/下载 checksum 对比;单节点与 quorum loss;单盘重建;以及对象、版本、CORS、tag、quota、ILM、Object Lock、IAM、delete marker 与离线补传的双站点复制。结束时 16 块盘全部在线,没有未恢复的数据不一致。
binary/package Release 公开后,容器发布发现一处只影响交付的 pin 错误:首次 Docker workflow 在推送任何镜像前停止,因为 tag 中的 Dockerfile 已选择 mcli 20260903,却仍保留两条 20260901 归档摘要。提交 ce2326c94 与 9936a69d8 修正 pin,并新增一个收窄的恢复模式,从不可变且带 attestation 的 mcli checksum manifest 解析摘要。恢复运行 33847571968 随后使用原始 Server release 归档构建、运行、生成 SBOM attestation,并发布两个架构。classic 的 release/latest manifest 为 sha256:b616a0cf8cb281e7e6bb3c9b1fb53875b4016a2878223925541c18f82d6c5ca3;distroless 为 sha256:2711cdc5bce0ada353a4871dcda60f48f46414fdd69a45c36c71f6b7ebe1aead。
正式发布的服务端 GitHub Release 标识精确标签源码,以及经过验证的归档、软件包、checksum、SBOM 与 provenance;classic 与 distroless 镜像均发布一致的 amd64/arm64 manifest。独立发布的 mcli Release 不可变,包含 19 个已验证资产,并发布一致的 amd64/arm64 版本镜像与 latest;共享包 v3.13.2 与 Console v2.3.0 各自保留独立发布证据。
已解决事项台账
发布准备期间已经完成 tracker 收口:核心问题关闭,刻意排除的工作留在独立 open issue 中:
| 跟踪项 | 解决方案 |
|---|---|
| #102 | 已由合并的 PR #103 关闭:共享 metadata.lock,完整覆盖 writer、migration、adoption;需要先复现的残余审计拆到 #105 |
| #58 | 已由合并的 PR #104 关闭:显式版本授权、multi-delete auth/audit context 与 least-privilege replication;PR #59 作为被替代实现关闭 |
| #46、#47、#48、#50 | Multipart checksum 选择、计算、响应、校验与错误 |
| #32、PR #37 | missing-bucket listing shortcut 返回 NoSuchBucket |
| PR #57 | 所选 client/server stack 已提供 CompleteMultipartUploadResult.ChecksumType |
代表性变更
1c9a2431f至13e6458d9:实现桶级 CORS 与站点复制938603458至04b097fd9及 Snowball follow-up:认证复制语义,消除预鉴权 metadata I/Of9f9fa6c9至32a1b81e4:复现并串行化跨类型桶元数据更新3b5de82f5、21646eebd:保证 Object Lock 的 versioning 不变量,覆盖 retention-rule 文档f8b598f1d至d2d47a41f:对齐显式版本删除授权b73581b05、474cd5801、74c97d005:认证 SSE-C 零字节与 attributes 读取7fea6d5a5、5d152416d、7e079ff05、d28885d0e:对齐 multipart checksum 行为c0e715977、e73436c99、ffb70eb37:修复 CopyObject transform、checksum 与 SSE-C rotation84e1580a4:metadata 按需重载成功后解除 load-failure guard
致谢
本版本吸收了社区贡献者提交的问题与修复方案,也包含分叉自身的安全与兼容性审计。checksum、missing-bucket listing 与显式版本授权等工作都从公开 issue/PR 开始。
完整作者记录见 CONTRIBUTORS.md。
2.25 - 显式版本删除现在要求 DeleteObjectVersion
自 Server 20260903 起(pgsty/silo#104,对应 Issue #58),SILO 按照删除请求实际执行的操作类型选择所需权限:
| 请求 | 所需权限 |
|---|---|
未携带 versionId |
s3:DeleteObject |
| 版本 UUID | s3:DeleteObjectVersion |
显式 versionId=null |
s3:DeleteObjectVersion |
DeleteObjects |
对每个 XML 条目独立应用映射 |
此前所有情况均要求 s3:DeleteObject,而 s3:DeleteObjectVersion 仅用于检查显式 Deny。这导致仅持有 DeleteObject 的主体可以永久移除指定的历史版本,而仅持有 DeleteObjectVersion 的最小权限清理主体却无法执行版本清理。
修复后,仅持有 DeleteObject 的主体仍可执行不带版本号的删除或创建删除标记(Delete Marker),但在尝试删除指定 UUID 或 null 版本时会收到 AccessDenied。仅持有 DeleteObjectVersion 的主体可以永久删除指定版本,但不能创建删除标记。显式 Deny 与 s3:versionid 策略条件保持原有求值优先级。批量删除(DeleteObjects)按每个条目自身的 VersionId 求值,外层 URL 查询参数不会污染其他条目的条件值。
复制兼容性
存储桶与站点复制的目标策略无需新增 s3:DeleteObjectVersion。已认证请求通过精确的内部复制标记与 s3:ReplicateDelete 获得复制删除信任,接收端继续沿用已部署的 s3:DeleteObject + s3:ReplicateDelete 策略契约;而针对 s3:DeleteObjectVersion 的显式 Deny 仍会阻止复制版本清理。
这种区分既避免了升级后的目标站点静默拒绝永久删除复制,也不会给仅持有 ReplicateDelete 的凭据赋予额外的普通删除能力。双站点回归测试验证了使用最小权限策略(不含 DeleteObjectVersion)的目标端用户:永久版本删除与删除标记均能正常收敛。
升级影响
- 请检查当前仅授予
s3:DeleteObject,但需要执行mc rm --version-id、mc rm --versions、Console“删除全部版本”或 SDK 指定versionId删除的用户、服务账号、OPA 及外部授权策略,为其补充s3:DeleteObjectVersion。 - 对于普通的指定版本删除,外部授权插件现在只会收到一次
s3:DeleteObjectVersion判定请求,不再像修复前那样先收到一次仅检查 Deny 的s3:DeleteObjectVersion调用、再收到一次s3:DeleteObject调用。 X-Minio-Force-Delete前缀强制删除仍由s3:DeleteObject控制。- 本次变更不涉及传输协议或磁盘存储格式迁移。
完整的操作权限矩阵参见对象删除。
2.26 - mcli 20260903 正式发布
发布日期: 2026-09-03 · 版本: RELEASE.2026-09-03T07-13-05Z · 源码: a2ef95c0
距离 mcli 20260806 二十八天,本次发布汇总了客户端此后积累的全部变更。工具链升级到 Go 1.27.1,共享依赖栈与 SILO 家族对齐,silo-pkg 以自有模块路径导入,go.mod 如实描述二进制的构建来源。fork 以来第一个真正的新命令 —— mcli checksum verify —— 到来了,它对端点实际返回的字节只读地核验存储的 S3 附加校验和。让 --json admin prometheus metrics 无法使用的 panic 已修复,mcli sql 打印有效结果行后可能因重复关闭响应而 panic 的问题也已修复。每一条可能打印凭据的路径 —— --debug trace、admin trace、错误消息、JSON 错误文档、checksum 报告 —— 都按 fail-closed 原则重写。发布流程本身的变化不亚于代码:产物只从 main 精确顶端、且该提交 CI 全绿的 GPG 签名标签构建,每种格式都带有 LICENSE、NOTICE 与生成的 CREDITS,携带 Sigstore 构建溯源证明,并落入不可变的 GitHub Release。
本次发布构建自 a2ef95c0,即 pgsty/mc#27 的合并提交:自 RELEASE.2026-08-06T00-00-00Z 以来 58 个可达提交,其中 50 个非 merge 提交,涉及 262 个文件(+12,742 / −6,566 行)。中间的 20260901 构建仍可从 GitHub 获取,但本站以 20260806 为公开文档基线,只记录完整的 20260903 结果。
行为变更
- macOS 最低版本升至 13。 Go 1.27 链接 darwin 二进制时的最低系统版本为 13.0,darwin 归档包在 macOS 12 及更早版本上无法运行。
mcli --json admin prometheus metrics重新可用,并按名称确定顺序输出 metric family。纯文本 metrics 输出不变。pipe的标准输入为空时,现在执行普通的零字节 PUT,而非单分片的 multipart 上传。生成的 ETag 是d41d8cd98f00b204e9800998ecf8427e,而非<md5>-1形式。非空输入不受影响。- 策略写入在客户端严格校验 ——
admin policy create、admin user svcacct add/set、idp ldap accesskey create/edit。裸 ARN(如arn:aws:s3:::)被拒绝(表示该前缀下的全部资源请写arn:aws:s3:::*);命名策略缺少Version被拒绝;空策略被拒绝;同一 statement 内混用 S3 与 admin action 被拒绝。后三种情形服务端本来就会拒绝,因此只有裸 ARN 改变了"什么能被存入"。读取既有策略仍然宽松;服务账号的会话策略仍可省略Version,admin user svcacct set --policy也仍接受空文档 —— 这是清除内联策略、让账号回到继承策略的方式。 - 写在命令名之前的全局参数格式错误现在会报错。
mcli -H "bad" ls,以及在 app 层无法解析的--resolve、--limit-*取值,此前会被静默忽略、命令照常运行;现在与命令层形式一样以同样的消息失败,且只报告一次、输出到 stderr(--json下为 JSON 错误文档);此前 CLI 库会先把该文本回显到 stdout。 - 错误消息不再回显密钥。
alias set对错误的 secret key 只报告长度要求;MC_CONFIG_ENV_FILE的解析错误给出行号而不重复该行;SSE-C 密钥错误不再回显密钥;admin config set对携带密钥的key=value打码;远端复制目标打印时不含凭据;格式错误的--custom-header按位置报告,从不按值。 - 支持产物改为私有。
support diag、support profile、support perf、support inspect、admin cluster bucket export与admin cluster iam export写出的文件以0600创建,包括对上一个文件的轮转备份。此前是0644或0666。 --debug显示得更少。Authorization头只保留 scheme(AWS4-HMAC-SHA256 **REDACTED**);token、cookie、API key、SSE-C 密钥以及每一个--custom-header的值都被打码;2xx 响应的 body 不再 dump。admin trace的输出在默认与详细两种渲染下以同样方式脱敏:按名字打码的 header 值、查询串、body、错误文本与注记。- User-Agent 报告
minio-go/v7.3.0(SDK 自身的版本常量),而非minio-go/v7.0.98。
主要变更
- Go 1.27.1:模块、容器构建阶段、Linux/macOS/Windows CI、交叉编译、tidy 检查与漏洞分析全部使用 1.27.1。Go 官方维护的模块族同步更新:
x/net0.58.0、x/sys0.47.0、x/term0.45.0、x/text0.41.0、x/crypto0.56.0、x/mod0.40.0、x/sync0.22.0 与x/tools0.49.0。golangci-lint继续留在产品模块图之外,按钉死的 v2.13.1 独立安装。 - 以自有模块路径导入 SILO 包。
go.mod现在直接 requiregithub.com/pgsty/silo-pkg/v3v3.13.2。客户端的策略校验依赖只有那里才有的辅助函数 ——policy.ParseConfigStrict与policy.Resource.IsBareARN—— 而对上游minio/pkg做replace不会被任何把本模块当作依赖引用的项目继承(pgsty/mc#12),因此改为如实声明依赖。上游minio/pkg仅作为colorjson的间接依赖存在,由依赖下限检查钉在 v3.6.1,gomodguard则阻止上游 import 路径回流。minio-go从 v7.0.99 升到上游minio/minio-go的最新 master 提交,以伪版本v7.3.1-0.20260828014306-0e78d3f18efe表示;过渡期的pgsty/silo-gofork 已退役。最终依赖刷新包括 etcd 3.7.1、go-pretty6.8.3、klauspost/compress1.20.0、Prometheus client/common/procfs 1.24.1/0.71.0/0.22.0、grpc-gateway2.30.0、gRPC 1.83.2、Protobuf 1.36.12 与 JWX 3.2.0。go-systemd通过 SILO 的可移植性钉子有意停留在 22.6.0 —— 22.7.0 在 NetBSD 上无法编译。 - 运行时版本下限受到保护。 第一轮依赖调整曾因移除开发工具而悄然拉低了十余个运行时模块的选定版本 —— 其中包括
shoenig/go-m1cpu,其旧版本在启用 CGO 的新款 Apple Silicon 上会在初始化阶段崩溃 —— 该候选版本被否决。buildscripts/check-dependency-floors现在把go.mod中的每一条依赖与上一个RELEASE.*标签比较,出现回退即构建失败;唯一允许的例外是go-systemd钉子。 mcli checksum verify(设计文档)。将存储的 S3 附加校验和 —— CRC32、CRC32C、CRC64NVME、SHA1、SHA256,类型为FULL_OBJECT—— 与对象 API 返回的逻辑字节逐一核验,支持单个对象、--recursive前缀、--versions全部版本或精确的--version-id,以及 JSON Lines 候选清单。对未版本化对象以If-Match读取并在读后重新 stat,与核验并发的写入会被报告为UNKNOWN_OBJECT_CHANGED而非误判为不匹配。每个对象得到MATCH、MISMATCH、NO_CHECKSUM、WOULD_VERIFY(dry run)、十种UNKNOWN_*分类之一,或三种SKIPPED_*之一;汇总包含objects、verified计数(MATCH+MISMATCH,即真正重算过校验和的结果)、各状态计数与incomplete。--fail-on接受mismatch、unknown、no-checksum(任一对象缺少校验和、或根本没有核验任何对象时失败)、any(默认:不匹配、未知,以及被--max-size跳过的对象)或none。--report把同样的记录写入新建的0600JSON Lines 文件。无论 stdout 是否为终端,结果都写到 stdout;--json在非终端下输出紧凑的 JSON Lines —— pgsty/mc#5 报告的非 TTY 静默问题已修复。支持 dry run、--max-workers、--max-size、时间过滤,以及通过--enc-c的 SSE-C。该命令在 CI 中针对真实 SILO 服务器验证。- 凭据脱敏,fail-closed。 早期的模式匹配脱敏实现只识别预期形态、会放行非常规格式的凭据:含斜杠的 access key、首值为空以掩盖第二个值的
Authorization、非十六进制签名、藏进 credential scope 或SignedHeaders的密钥、Proxy-Authorization、cookie、X-Api-Key、响应 trailer、带 userinfo 的跳转Location、把请求头反射进错误 body 的服务端、--json错误输出中被 JSON 转义的密钥、重复出现两次的短 token。最终设计只有三条规则、没有例外:Authorization类的值只保留 scheme;名字看起来像密钥、或由调用者通过--custom-header提供的头一律打码、不问内容;客户端自己没有生成的文本 —— 响应 body、服务端错误消息、trace 注记 —— 先按凭据形态扫描,再按进程学到的每一个密钥字面值擦除。最后这一层由进程级的密钥登记表支撑:别名 secret key 与会话令牌、MC_HOST_*取值、各种编码形态的 SSE-C 密钥、tier 与 service principal 密钥、GCS 凭据文件、LDAP 密码及其返回的 STS 凭据、SUBNET 令牌、--secret-key/--account-key/--api-key参数,以及携带密钥的key=value参数,都在读入时、校验或任何网络调用之前登记。出现位置先收集为区间并合并再替换;JSON 错误文档在解码后的值树上只清洗字符串叶子,因此 key 与非终端下的紧凑格式都不受影响。四十余条边界测试用例、一个让真实客户端对着"反射所有收到内容"的端点运行的黑盒测试,以及对 SILO 服务器的实时抓包,共同钉住了结果。 admin trace脱敏。 服务端为其他客户端请求提供的 header map 走的是 S3 tracer 从未触及的路径。人类可读与 JSON 两种渲染、默认与详细两种形式,现在都基于脱敏后的副本 —— 事件对象在两者间共享、永不被修改。查询串(预签名 URL 的签名、位于第一个参数的会话令牌)、body、错误文本与注记都会按事件自身的 header 值擦除,因此其他客户端发送的 token 不可能进入特权 trace 输出。header 按名字打码:其他客户端以不像密钥的名字发送的--custom-header,会按服务端看到的样子显示。
缺陷修复
- Metrics JSON 不再 panic。 20260806 客户端的
mcli --json admin prometheus metrics在 v2 与 v3 两种 metrics API 下都会以panic: Invalid name validation scheme requested: unset失败。madmin-go构造了 Prometheus 文本解析器的零值,而prometheus/common0.66 起拒绝这种用法。客户端现在以 UTF-8 校验模式构造解析器,遇到问题返回普通解析错误而非 panic,并按名称顺序输出 family。 pipe空输入改为普通 PUT;此前对零字节发起的单分片 multipart 上传既无必要,又会生成 multipart 形式的 ETag。- RPM 载荷校验与签名脚本对齐:内容与发布元数据不符的 RPM 会在签名之前被拒绝。
sql打印完结果行后不再 panic。 SDK 的 select 读取器会在每个终止事件上自行关闭 HTTP 响应,而客户端又关闭了一次;在 zstd 压缩的响应上两次排空相互竞争,对 SILO 服务器的查询约五分之二会在结果行已经写出之后以panic: invalid memory address、退出码2收场(pgsty/mc#23)。客户端现在改为取消请求并排空结果管道,而不是再次关闭响应;在真实服务器上连续 820 次查询零 panic,而旧客户端 50 次中失败 21 次。
安全加固
- 运维人员产出的文件不再对全局可读。
support diag一边警告归档可能包含敏感环境信息,一边以0666写出;共用的moveFile帮助函数在把每个0600临时文件搬到最终名字的途中放宽为0644,而即便保留源文件权限,早期客户端留下的0644仍会被带进轮转备份。现在每个支持产物都以0600创建,并在写入第一个字节之前 chmod。 Test_FullSuite遇 panic 即失败。 功能套件此前会从 panic 中 recover 以执行清理、然后报告成功;套件中任何位置的 panic(包括二进制构建)在 CI 里都不可见。- 新命令帮助中残留的
play/别名 —— 仓库里唯一仍在宣传 MinIO 运营演示别名的文件 —— 已替换,品牌门禁现在除主机名外也拒绝命令示例中的裸别名。 - 补齐 AGPL 声明:六个 fork 自有文件此前只带许可证文本的前半段。
工程与交付
- 每种产物都带第三方声明。
CREDITS由buildscripts/gen-credits.sh从链接进六个 GoReleaser 目标的运行时模块并集生成,并在 CI 中比对,依赖变更不可能让它悄然过期。LICENSE、NOTICE与CREDITS随 GoReleaser 归档包、DEB/RPM/APK 软件包以及每一个容器镜像(/licenses/目录)一同交付。 - CI 在
main上运行。Go与Crosscompile此前只在 pull request 上运行,merge commit —— 真正被打标签的提交 —— 从未被测试;现在每次推送到main都会运行。它们的并发组以github.head_ref为键,而它在 pull request 之外为空,于是每次手动触发都会取消其他所有运行;现在改以 pull request 编号为键、ref 作为回退。VulnCheck也改为每周运行,因为归档上游的 fork 可能数周没有提交。CI 中的功能套件现在针对 SILORELEASE.2026-08-06T00-00-00Z运行。 - 只从签名标签发布。 产物工作流没有手动触发;产物来自推送的
RELEASE.*标签,事件 SHA、标签指向的提交与检出三者必须一致。verify-release-tag.sh要求 annotated 标签带有来自预期密钥的有效 OpenPGP 签名 —— 指纹与公钥位置来自仓库变量,而非标签指向的树本身,且导入的密钥必须与预期指纹一致。check-release-commit.sh要求被打标签的提交是origin/main的精确顶端,并且四个工作流对该 SHA 都有成功的 push-to-main 运行、按工作流文件路径匹配;pull request 与手动触发的运行不算证据。check-release-state.sh(pgsty/mc#6)按标签串行运行、从头替换恰好一个既有 Draft、拒绝已发布的 release,并在上传软件包前再次确认只有一个 Draft。每一个判定都住在带 fixture 覆盖的 shell 脚本里;测试中的假gh既钉住判决,也逐字核对传入参数。 - 溯源与不可变。 归档包、checksums、DEB 与 APK 软件包携带 Sigstore 构建溯源证明(
gh attestation verify <file> --repo pgsty/mc)。RPM 有意排除:sign-release-rpms.sh会在 release 仍为 Draft 时用密钥9592A7BC7A682E7333376E09E7935D8DB9BD8B20的分离式 GPG 签名改写其字节,早先做的证明会指向没有任何已发布 RPM 拥有的摘要。GitHub release 一经发布即不可变;修复资产意味着新标签。pgsty/mc容器镜像只在gh attestation verify把每个归档绑定到release.yml@refs/tags/<tag>、发布提交摘要、GitHub 托管 runner、其自身文件名与 SHA-256 之后才从经证明的归档构建;解包出的二进制的 ELF 机器类型必须与归档架构一致;每个架构的镜像先拉取、运行并检查标签,然后才合并 manifest,latest只从双架构 manifest 写入。UBI 基础镜像按摘要钉住,由 Dependabot 保持更新。 - 仓库治理适配单维护者项目。
main允许维护者直接推送;本次发布完成后,陈旧的 pull request 规则与依赖精确检查名称的耦合已删除。发布完整性不依赖这条分支规则:制品门禁仍要求被打标签的精确提交拥有成功的 push-to-main Go、Crosscompile、VulnCheck 与 Test Release 运行。RELEASE.*标签仍仅允许管理员创建且不可变;secret scanning、push protection、Dependabot 告警与安全更新保持启用。 - 打标签前后均完成验证。 在精确源码
a2ef95c035d9ae7cc01469a63926900f1786f9e2上,tidy/verify、依赖下限、单元测试、完整 race 套件、lint、actionlint、源码感知的govulncheck、九个标准交叉编译目标、干净缓存的 Go proxy/sumdb 构建、针对临时 SILO 服务端的 172 项功能检查、与已发布 20260901 二进制的 47 条命令黑盒对比,以及七个额外交叉目标全部通过。四个精确 SHA 的 main 工作流和两个发布工作流全部通过。不可变的公开 Release 包含 19 个资产;归档包与 sidecar 均通过校验和核验,11 个带证明的 subject 全部绑定到该标签与源码提交,两种 RPM 均通过 GPG 验证,pgsty/mc的版本标签与latest也被独立确认指向同一份 amd64/arm64 manifest。源码扫描没有可达漏洞。
兼容性
本版本的完整 --help 树与 20260806 二进制做了 diff:唯一差异是新增的 checksum 命令。在同一服务器上并排对比,head、cat、get、cp、mirror、find、share、sql、stat 与 ls 的输出与 JSON 字段集完全一致;二十份真实形态的策略除裸 ARN 外接受/拒绝结果一致;admin policy info --json 逐字节相同。mc 命令名与 mcli 软件包名;~/.mc / ~/.mcli 配置目录;github.com/minio/mc 模块路径及全部 import 路径;MC_* 环境变量;协议头(x-minio-*)与 minio-go User-Agent 前缀;minio tier 类型;.part.minio 传输后缀;minio-job 抓取任务名;以及软件包格式、产物命名与 YYYYMMDDHHMMSS.0.0 版本方案均未改变。客户端继续兼容 MinIO 服务器及其他 S3 兼容端点。把本模块当作 Go 依赖引用的项目现在直接解析到 github.com/pgsty/silo-pkg/v3,不再需要自己为它写 replace。
已知问题与边界
- 当服务端自身的完整性校验拒绝读取时,
checksum verify报告的是UNKNOWN_READ_ERROR而非MISMATCH—— SILO 的 bitrot 保护会在对象 API 返回之前拒绝损坏的分片。MISMATCH意味着存储的校验和描述的字节与对象 API 返回的不同,这正是该命令要找的历史写入路径缺陷;UNKNOWN永远不会被报告为MATCH。 - Access key ID 只要出现在凭据结构内就会被打码,但把裸 access key 反射进错误消息的服务端仍会让它被打印:它在合法消息中同时充当用户标识。短于三个字符的密钥不登记。当 secret key 与 access key 相同时 —— 默认的
minioadmin:minioadmin—— 错误文本中的这个标识也会被打码,因为这个字符串就是密钥。 --json错误文档中cause.error的 key 按字母序而非结构体顺序排列;key 与值本身不变。mcli sql在 stderr 报告查询失败后仍以0退出(pgsty/mc#25):逐对象的错误只报告而不中止运行,以便递归查询继续。与上游一致。govulncheck追踪到间接依赖x/crypto/openpgp中的 GO-2026-5932;不存在修复版本,客户端也不调用它。- JWX 3.2 按标准更严格地执行 JWT
crit处理。终端宽度依赖刷新也可能改变 Indic 或大量 ZWJ 文本的对齐;两者均未影响已测试的命令面。 - minio/mc#5139 继续适用:
mirror --remove --watch在源端删除对象的 非当前版本 时,可能误删目标端的现存对象。 - 不阻断发布的质量债 —— 归档可复现性、测试工具链可移植性、KMS / SSE-S3 / 多站点复制的延后覆盖 —— 记录在 pgsty/mc#21。
关联提交
- 98a8b0f:build: prepare the 20260903 dependency release
- 6686b64: chore(deps): align the SILO Go dependency stack
- 423490d: fix: stop JSON metrics from panicking
- 14b05e6: build: generate and package third-party notices
- e938cea: fix: align RPM payload signing validation
- 8158605: release: adopt Silo Go SDK v7.3.1
- d6ba236: ci: allow manual release workflow runs
- b6b2655: ci: release an existing tag on dispatch
- 70a2950: fix: preserve embeddable pkg compatibility
- aa07b1c: feat: add read-only object checksum verification
- 1b39423: fix: preserve checksum output in pipelines
- a31a42b: docs: record checksum verify output contract
- 2121081: fix: validate policy writes strictly
- 712bf3a: fix: use regular PUT for empty pipe input
- 9a3805d: ci: make release retries tag-idempotent
- 7bc9843: test: make report permissions portable
- 2d5abc2: chore: complete the AGPL notice on fork-authored files
- f5c5dc9: build: import the SILO package under its own module path
- a8e7eb4: ci: test main on push and stop dispatch runs cancelling each other
- b75d28b: fix: stop checksum verify help from advertising the play alias
- c05da7b: chore: drop configuration for bots that are not installed
- a29b455: fix: stop –debug from printing credentials
- 08244df: fix: keep secrets out of error messages and traces
- 30a7ea0: fix: stop writing support artifacts world-readable
- 14bd724: test: fail the full suite when it panics
- 4abbb3a: feat: expose how much checksum verify actually verified
- f484d0d: ci: make a release prove its commit, and pin what it builds from
- 2c33a4e: chore: ignore the local consumer-module review scratch directory
- bdc742a: fix: close the remaining –debug credential leaks
- 3998392: fix: withhold SSE-C keys and credential URLs the parser rejects
- c1358c4: fix: force 0600 on rotated support artifacts too
- 45e5144: fix: fail –fail-on no-checksum when nothing was verified
- 3674933: ci: make the release gate provable and its claims true
- 8b7b2e9: test: exercise checksum verify against a real server
- 4b36b0a: fix: make credential redaction fail-closed and scrub final output
- a6c14da: ci: verify build provenance before repackaging archives into images
- 915cfd9: fix: reject a malformed app-level global flag instead of ignoring it
- 81e2767: fix: rebuild SigV4 traces from known fields and widen secret scrubbing
- 625713c: fix: keep only the scheme of a signed header and scrub JSON structurally
- ec12b64: fix: redact admin trace output and register every credential ingress
- 1445e88: ci: release only from a signed tag at the tip of main, verify exact provenance
- f188939: fix: close final credential and release verification gaps
- f5b88dd: fix: redact the default admin trace and stop the shape sweep mangling prose
- 59989f8: fix: let svcacct set clear a policy again, report bad global flags once
- d38412a: fix: classify an SSE-C refusal with a key as a read error, keep policy and size messages clear
- b291b53: fix: register a URL password as written, keep listing parameters visible in traces
- 9dfca8f: test: drive the SSE-C refusal classification through the verify path
- 76b2e89: fix: keep an unquoted DSN password whole and match auth schemes case-insensitively
- 328efee: fix: register the password before the first separator of an unquoted DSN payload too
- 7057705: fix: avoid double-closing S3 Select responses
2.27 - mcli 20260806 发布
发布日期: 2026-08-06 · 版本: RELEASE.2026-08-06T00-00-00Z
距离 mcli 20260804 仅两天,本次发布完成了客户端向 Silo 身份的整体切换。这是一次刻意保持「纯品牌与封闭」性质的发布:--version 与 --help 现在以 Silo 客户端的身份呈现,所有残余的 MinIO SUBNET 外联路径在构建期被关闭,诊断工具链中内置的厂商加密公钥被移除,贡献政策改为无 CLA + 强制 DCO 签署。本周期 零依赖变更、零协议变更 —— go.mod 与 20260804 逐字节一致 —— 因此回归面被严格限制在文案、命令门禁与 CI 三个层面。
行为变更
所有原先会连接 MinIO SUBNET 的路径均已在构建期关闭,且无法在运行时重新开启:
mcli license register、mcli support upload、mcli support proxy set、mcli support callhome enable,以及mcli license update ALIAS的在线续期形态,均打印稳定的提示信息 —— “MinIO SUBNET services (registration, licensing, uploads) are disabled in this Silo build of mc; diagnostics remain available locally.” —— 并恒定以退出码1结束。脚本中如有这些调用应予移除。基于本地文件的mcli license update ALIAS license.key仍然可用,许可证使用内置公钥离线验签。mcli support diag/perf/profile/inspect恒定以本地(airgap)模式运行:诊断报告、性能剖析与 inspect 归档全部写入本地文件,不向任何地方上传。--airgap参数保留以维持兼容(实际上始终生效),SUBNET 注册不再是任何诊断命令的前置条件。mcli support callhome disable|status、mcli support proxy show|remove、mcli license info与mcli license unregister照常工作 —— 它们只读取或清除本地与服务端配置。- 全新生成的配置不再自动写入指向 MinIO 公共演示集群的
play别名,默认别名为local、s3、gcs。已有配置文件永远不会被修改,旧版配置迁移逻辑仍识别历史条目。 mcli --version增加一行身份说明(“Silo object storage client, based on MinIO technology”)与第二行版权信息。首行的机器可读格式保持不变,解析首行的脚本不受影响。
主要变更
- CLI 全面切换 Silo 身份:客户端自我介绍改为 “Silo client for object storage and filesystems”。约 220 处帮助文本重写:涉及被管服务器的用法说明改用 “Silo/MinIO server” 措辞,示例别名从
myminio/play换成mysilo,LDAP 示例 DN 换成dc=example,dc=com,示例分层名称换成SILOTIER-*。事实性表述保持事实:minio分层 类型、协议头以及第三方互操作提及均未触碰。 - SUBNET 构建期关闭:连接能力在编译期被单一开关移除,所有 SUBNET 请求汇聚的唯一 HTTP 出口以上述稳定错误拒绝。命令入口提前拦截,诊断命令强制本地模式,
mcli license info显示的 AGPL 许可说明不再附带商业订阅推销。专门的回归测试(cmd/subnet-disabled_test.go)钉住了全部行为,上游合并无法悄悄恢复任何外联。 - 治理 —— 无 CLA,强制 DCO:贡献按 inbound=outbound 原则以 AGPL-3.0-or-later 接收;贡献者保留自己的版权,维护者不获取项目许可证之外的任何权利。每个提交必须携带
Signed-off-by尾注,由新增的 CI 工作流强制校验(尾注须与提交作者邮箱匹配,仅豁免 GitHub 官方 bot 地址)。CONTRIBUTING.md、PR 模板与中英文 README 均已写明政策,行为准则的联系方式改为本分支维护者。 - 双版权署名:运行时输出与帮助信息同时致谢两条脉络 ——
Copyright (c) 2015-2025 MinIO, Inc.与Copyright (c) 2025-2026 PGSTY,源码构建动态计算截止年份。NOTICE明确声明分支关系,以及与 MinIO, Inc. 无隶属、无背书关系。 - 发布线更名为
main:工作流分支过滤器、文档与贡献指引全部指向main,旧的master引用已清除。
安全加固
- 移除厂商加密公钥:此前
mcli support inspect在未提供密钥时,会回退到用内置的 MinIO RSA 公钥加密输出 —— 产生只有厂商才能解密的归档。内置公钥现已移除:inspect 改用服务端为每次请求生成并回传给调用方的随机密钥(或运维人员自备的密钥),而任何未配置接收方的加密上传路径都会直接失败,绝不静默借用第三方密钥。运维人员产出的诊断数据,现在始终由其本人掌握解密能力。 - 品牌政策门禁:
buildscripts/check-branding.sh接入make verifiers与 CI。一旦命令树中重新出现 MinIO 运营的端点、商业推销 URL、上游产品身份、或任何内嵌的MII…公钥,构建即失败 —— 同时显式豁免有意保留的兼容标识(环境变量、协议头、模块路径、旧配置迁移默认值与原始版权头)。
工程与交付
- CI 迁移到 Node 24 Actions 运行时:
actions/checkoutv7、actions/setup-gov7、goreleaser-actionv7 及 Docker 系列 Actions —— 全部继续钉在 commit SHA 上,由 dependabot 保持更新。 - 功能测试只针对受控服务器:测试套件默认连接本地服务器(
localhost:9000)而非 MinIO 公共演示集群;CI 从pgsty/silo下载钉死的 SILO 服务端版本RELEASE.2026-08-04T00-00-00Z(经 SHA-256 校验)后再运行套件。 - 零依赖变更:本周期没有任何模块升级;20260804 建立的安全基线(Go 1.26.5、零个可达已知漏洞)原样延续。
- 发布前验证:覆盖 245 个文件的全量 diff 核对、品牌/兼容分类的 grep 扫描、调用图外联可达性检查(确认任何命令与参数组合均无法触达
subnet.min.io/play.min.io/dl.min.io),以及验证每条禁用路径均返回稳定错误与退出码1的冒烟测试。
兼容性
脚本与集成所依赖的一切均有意保持不变:mc 命令名与 mcli 包名/二进制名;~/.mc / ~/.mcli 配置目录(由调用名派生);github.com/minio/mc 模块路径及全部 import 路径;MC_* 环境变量;协议头(x-minio-*)与 minio-go SDK 的 User-Agent 前缀;minio 分层类型;.part.minio 断点传输后缀;Prometheus 抓取任务名 minio-job;以及软件包格式、发布资产命名与 YYYYMMDDHHMMSS.0.0 版本方案。客户端与 MinIO 服务器及其他 S3 兼容端点保持完全兼容。
已知问题
20260804 发布说明中标记的 mcli watch 回归已在服务端解决:修复随 SILO 20260804 交付,本客户端的 CI 现在正是针对该版本运行包括 watch 在内的功能测试套件。将 mcli 与 SILO 服务端 20260804 或更新版本搭配即可正常接收桶事件;更早的已发布服务端版本仍受影响。
上游未修复的历史缺陷继续适用,其中最严重的是 minio/mc#5139:mirror --remove --watch 在源端删除对象的 非当前版本 时,可能误删目标端的现存对象。在版本化桶上组合使用 --remove --watch 时请务必谨慎。
关联提交
- 8a883ca:ci: move the branch filters to main and fetch the server from pgsty/silo
- 8c304dd:ci: move the pinned actions onto the Node 24 runtime
- 02b1c11:docs: name the release line main, not master
- 810bbd2:ci: pin the functional-test server to a release whose watch API works
- d145647:fix: disable SUBNET connectivity and licensing upsell paths
- 5061c4f:rebrand: adopt Silo identity in CLI help and examples
- c7f7706:docs: align governance files and package metadata with the fork
- 65c71b2:test: default functional tests to a local server and add brand gate
- c62a64d:fix: credit both MinIO and PGSTY in copyright notices
- d205f88:docs: adopt no-CLA plus DCO contribution policy
- 95326ce:docs: add related-projects table and polish contribution wording
- d2c0db7:fix: remove the vendor encryption key and close the proxy-set path
- 0c6704d:fix: repair a link and help text damaged by the brand sweep
2.28 - Silo 20260806 发布
版本: RELEASE.2026-08-06T00-00-00Z · 提交: 3be10fcc1a44f6620ded0bd303461f9d688cca23
SILO 20260806 是 首个以 Silo 之名发布的版本。上一个版本 20260804 是以 pgsty/minio 名义交付的最后一版;本版本完成了向 github.com/pgsty/silo 的切换,重命名了每一个交付表面——二进制、软件包、容器镜像、systemd 服务、Helm chart——同时刻意原样保留了 MinIO 部署所依赖的每一个线上协议与配置表面。在改名之外,本版本新增了原生健康检查(silo healthcheck)、单二进制的 Distroless 容器镜像试点、装入每个交付物的完整许可合规材料,以及以兼容性快照与构建溯源为门禁的发布管线。
本版本覆盖 RELEASE.2026-08-04T00-00-00Z 之后的 28 个提交,变更 396 个文件,新增 27,188 行、删除 19,561 行。发布前通过了六阶段验收,包括一次真实的四节点 TLS 集群从 MinIO 到 Silo 的迁移——数据逐字节校验、维护闸门滚动重启、故障注入,以及完整的回滚彩排。
亮点
- 品牌切换完成,兼容性即契约。 仓库、二进制(
/usr/bin/silo)、软件包(silorpm/deb/apk)、镜像(docker.io/pgsty/silo)、服务(silo.service)全部改名;S3 与管理 API、/minio/*路由、MINIO_*环境变量、x-minio-*响应头、磁盘上的.minio.sys格式、Go 模块路径全部保留,并由 CI 兼容性守卫冻结。 - 原生健康检查:
silo healthcheck [live|ready|cluster|cluster-read]探测服务器自己的健康 API——正确的退出码、解码后的 quorum 诊断、TLS 自动探测、--maintenance下线前闸门——容器内无需 shell、curl或mc。 - Distroless 镜像试点:
pgsty/silo:<RELEASE>-distroless基于gcr.io/distroless/static,恰好装载一个程序——silo二进制——内置 exec 形式HEALTHCHECK,/data在镜像层内以可写方式创建。 - 经典镜像行为不变: 同样的入口脚本、同样的捆绑工具,
mc ready local继续可用,也没有给它添加HEALTHCHECK。捆绑客户端升级为mcli20260806。 - 合规补齐: LICENSE 与 NOTICE 装入每个软件包与镜像,CREDITS 按实际链接的模块集合(291 个模块)重新生成并纳入 CI 守卫,项目采用无 CLA、基于 DCO 的贡献政策。
- 组件刷新: 内嵌 SILO Console 2.1.1、
silo-pkg3.11.0、mcli20260806、Go 1.26.5。 - 以溯源为门禁的发布: 容器镜像只从已发布、校验和与 attestation 双重验证过的发布产物构建;镜像 SBOM 与溯源 attestation 现在同样覆盖 Distroless 变体。
改名
改了什么,与刻意没改什么:
| 已重命名(交付表面) | 已保留(兼容表面) |
|---|---|
仓库:github.com/pgsty/silo(main 分支) |
S3 API、管理 API 与请求签名行为 |
二进制:/usr/bin/silo |
/minio/* 路由,含 /minio/health/* 与指标端点 |
软件包:silo-*.rpm、silo_*.deb、silo_*.apk |
MINIO_* 环境变量与 x-minio-* 响应头 |
镜像:docker.io/pgsty/silo(另有 -distroless) |
磁盘格式(.minio.sys)、纠删码、版本控制 |
服务:silo.service(与 minio.service 冲突并取而代之) |
Go 模块与导入路径(github.com/minio/...) |
默认配置目录:~/.silo(存在旧 ~/.minio 时回退兼容) |
捆绑 mcli 的 mc 兼容别名 |
服务器呈现自己的身份——silo --version 输出 AGPL-3.0 许可、MinIO 2015-2025 版权、PGSTY 修改版权与 “based on MinIO technology” 归属——所有继承自上游的 MinIO 运营服务连接(更新源及其验签密钥、SUBNET、遥测)被切断而非重定向。容器入口脚本翻译 legacy minio argv 词元,因此 docker run pgsty/silo minio server /data 继续可用。
CI 中运行快照式 rebrand 守卫:对 334 个路由字面量、437 个环境变量词元、84 个响应头与 9,014 个导出符号的任意双向漂移直接判失败。
原生健康检查
服务器二进制现在可以探测自己的健康端点,使容器健康检查不再需要第二个二进制——这也是 Distroless 镜像的依赖所在:
- 检查词汇与
/minio/health/<path>一一对应;live(默认)回答"这个进程是否在服务",ready在配置了 KMS/etcd 时追加其可达性,cluster一对评估每个纠删集的写/读 quorum。 - 退出码只有
0(健康)与1(其余一切)——绝不使用 Docker 保留的2。单行诊断解码服务端的x-minio-server-status与 quorum 响应头供docker inspect采集;--json输出机器可读判定。 - 探测目标按服务器推导自身监听地址的方式推导:
--address/MINIO_ADDRESS,HTTPS 由 certs 目录中public.crt+private.key的存在自动判定,或用--url/MINIO_HEALTHCHECK_URL整体覆盖。环境变量形式的存在是因为探针进程看不到服务器的命令行——当服务器地址或 TLS 来自 CLI 参数时,一个环境变量即可矫正内置探针。 silo healthcheck --maintenance cluster回答下线前的问题:退出0表示此节点可安全下线而不失去高可用;HTTP 412(退出1)表示不可以。- 跳过证书校验,与 kubelet 对 HTTPS 探针的文档行为一致;传输层忽略
HTTP_PROXY,环回探测永不进代理。
Kubernetes 完全不需要这些——kubelet 的 httpGet 探针从容器外打 /minio/health/live 与 /minio/health/ready——且 cluster 检查不应进入单容器探针:它反映的是全集群 quorum,不是单个进程。完整设计依据(含源码核实的端点语义)记录于健康检查设计笔记。
Distroless 镜像试点
在经典镜像之外,本版本发布 Distroless 变体:pgsty/silo:RELEASE.2026-08-06T00-00-00Z-distroless,另有滚动 distroless 标签。
- 基底为
gcr.io/distroless/static-debian12:CA 证书、tzdata、/tmp、含nonroot(65532)条目的/etc/passwd——没有 shell、没有包管理器、没有 libc。之上恰好一个程序:/usr/bin/silo(外加/licenses/下的许可三件套)。镜像 128 MB,对比经典镜像 199 MB。 - 二进制即
ENTRYPOINT;内置 exec 形式HEALTHCHECK运行silo healthcheck ready(interval 30s、timeout 10s、start-period 2m、retries 3),Compose 用户零配置即获得可用的depends_on: condition: service_healthy。 /data在镜像层内以全员可写方式创建——运行时已没有入口脚本可以修补卷属主,而这正是让所有权限模式(含--user)都能工作的原因。这在 Distroless 变体中修复了 #55 记录的非 root 失败。- 此变体不支持:已弃用的
MINIO_USERNAME/MINIO_GROUPNAME降权路径(改用--user或 KubernetesrunAsUser)、docker exec <c> sh式调试(改用临时容器工具)、镜像内mc(改用已发布的mcli或客户端镜像)。 - TLS:把证书挂到
/tmp/.silo/certs(容器内默认 certs 目录),服务器与内置探针会从同一位置各自推导出 HTTPS;CLI 参数配置的服务器则设置MINIO_HEALTHCHECK_URL。
经典镜像仍是默认且保持不变。试点验证充分后,Distroless 变体将成为推荐镜像;决策记录见上述设计笔记。
容器镜像
经典镜像与 pgsty/minio:RELEASE.2026-08-04T00-00-00Z 做了逐字段对照:入口、暴露端口、卷、工作目录、用户与(不存在的)健康检查配置完全一致。差异恰好三处且均为刻意变更:Cmd 由 ["minio"] 改为 ["silo"];移除上游更新验签密钥变量 MINIO_UPDATE_MINISIGN_PUBKEY(上游渠道更新永久关闭);声明 HOME=/tmp 以与入口脚本的可写 home 保证一致。
捆绑客户端升级为 mcli RELEASE.2026-08-06T00-00-00Z(保留 mc 别名),按架构以 SHA-256 摘要钉版并在构建期对照发布校验和验证。已发布的 mcli 20260806 与本服务器的互操作性——分段上传、版本控制、预签名 URL、metadata/tags、用户与策略管理——已在发布验收中验证。
Helm chart
Chart 以 silo 7.0.1 发布,经迁移守卫验证在模拟升级中与旧 chart 的 7 个渲染资源身份保持稳定。默认镜像标签现在指向本版本——docker.io/pgsty/silo 是全新仓库,继承的旧默认值本不可能拉取成功。Chart 仍未附带 liveness/readiness/startup 探针;按设计笔记的后续阶段规划补齐。
打包与迁移
RPM、DEB、APK 软件包恰好安装六个文件:/usr/bin/silo、silo.service、sysusers 定义(创建 silo 系统用户)、/etc/default/silo(config/noreplace)、LICENSE 与 NOTICE。RPM 以 PGSTY 维护者密钥 GPG 签名(9592A7BC 7A682E73 33376E09 E7935D8D B9BD8B20)。RPM 与 DEB 现在统一携带 PGDG 风格的 1PGSTY release 段——silo-<版本>-1PGSTY.<架构>.rpm 与 silo_<版本>-1PGSTY_<架构>.deb——取代此前 RPM 继承的裸 -1 与 DEB 缺失的 revision;APK 命名保持不变,因为 Alpine pkgrel 只接受 -r<整数>。
silo.service 为接管而设计:Type=notify(就绪由服务器自己发信号)、Conflicts=minio.service + After=minio.service(启动 Silo 会停掉运行中的 MinIO 服务)、以及 两个 环境文件——先读 /etc/default/minio、后以 /etc/default/silo 覆盖——现有 MinIO 配置无需编辑即被继承。对数据属主为 minio 用户的既有部署,用文档规定的 drop-in 保持属主不动:
分布式迁移必须全体节点一起切换。
集群引导会(按校验和)验证每个节点运行相同的二进制。混合集群——部分节点 Silo、部分仍是 MinIO——无法组网:新节点停在 activating,无限期记录 Expected Silo binary checksum ... seen: ... 与 Waiting for at least 1 remote servers with valid configuration。请在 所有 节点上停止 MinIO,然后(近同时地)在所有节点上启动 Silo。全部节点运行 Silo 之后,滚动重启一切正常——每台重启前用 silo healthcheck --maintenance cluster 做闸门。
来自验收实测的迁移排障提示:若 Silo 以软件包默认的 silo 用户启动,而部署的 TLS 证书位于 minio 用户家目录之下,会以 HTTPS specified in endpoints, but no TLS certificate is found 失败并连续重启直至 systemd 启动限制生效——上面的 legacy-user drop-in 即是解法。迁移窗口期间保留 MinIO 软件包与服务(处于 disabled):回滚路径——停 Silo、起 MinIO——已经彩排,且能读取 Silo 窗口期写入的全部数据,因为迁移既不动数据属主也不动数据格式。
组件与依赖
- SILO Console 2.1.1 —— 内嵌控制台,取自
pgsty/silo-console,保留github.com/minio/console导入路径。 silo-pkg3.11.0 —— 保留策略/LDAP/证书修复,含 #15 跟踪的 LDAP-over-TLS 修复。mcli20260806 —— 镜像内捆绑并已单独发布;见其发布说明。- Go 1.26.5 —— 工具链与 20260804 一致。
构建、CI 与发布管线
- 兼容性即 CI 门禁: rebrand 守卫对路由、环境变量词元、响应头、指标、存储/策略标识符与导出符号做快照,任何未经评审的漂移直接失败;配套脚本断言交付表面(二进制路径、unit 内容、镜像布局),并确保运行时代码中不再存在任何上游在线端点。
- 发布镜像门禁: 发布管线的每次运行都构建两个容器镜像并断言(除其他外):Distroless 的
HEALTHCHECK存活于镜像配置(它是 OCI 规范之外的 Docker 扩展)、/data以全员可写交付、不存在 shell 与/usr/bin/minio、Docker 健康状态仅凭内置探针即转 healthy、SIGTERM 在 root 与--user 1001:1001双路径下均优雅停机。 - 溯源链: 镜像从已发布的发布产物构建,先经校验和验证与对确切 tag 的
gh attestation verify;经典与 Distroless 镜像均推送分架构 SBOM 与溯源 attestation;Distroless 健康检查门禁在多架构 manifest 提升之前运行。 - 工作流运行时 全面迁移到 Node 24。
兼容性与升级注意
- 软件包升级是接管而非原地更新。 安装
silo,保持/etc/default/minio原样(会被继承),启用silo.service;启动它会经冲突关系停掉minio.service。数据不动。 - 用上述 legacy-user drop-in 保持数据属主稳定;迁移期间不要 chown 存储、不要移动证书。
- 分布式集群:只能全停切换。 见上方警告——Silo/MinIO 混合节点无法组网。
- 容器用户: 镜像现为
docker.io/pgsty/silo;docker.io/pgsty/minio冻结在 20260804 作为存档。经典镜像行为不变——包括mc ready local健康检查——Distroless 变体严格自愿选用。 - Distroless 的差异是刻意的: 无 shell、无镜像内
mc、无MINIO_USERNAME路径;健康检查原生;CLI 参数配置的服务器需为内置探针设置MINIO_HEALTHCHECK_URL。 - Helm 用户: chart 7.0.1 的默认值现在能拉取本版本;若钉版本请显式覆盖
image.tag。 - 已知且未变: 经典镜像仍不在层内创建
/data,因此完全非 root 的docker run配 Docker 管理卷会一如既往失败(#55,Distroless 变体已修复);20260804 说明中继承的 Postgres/MySQL 遗留通知迁移限制仍然适用(#53)。 - 客户端配套使用
mcli20260806;旧客户端在未变的线上协议下继续可用。
验证
本版本分阶段验证,每一步留有证据记录:
- 健康检查命令的单元与端到端矩阵:目标推导与优先级链(flag/env/派生)、真实 TLS 自动探测、退出码契约、JSON schema、超时有界性、用法错误;
- 四节点集群上的语义验证:停掉 4 节点中的 2 个后,
cluster报告 503 且write-quorum=5,而cluster-read与live保持 200——写/读 quorum 分野现场观测,与纠删码推演一致; - 镜像验收:经典镜像与 20260804 基线逐字段对照;Distroless 镜像断言至文件清单、精确健康检查配置,及 root/非 root/TLS/环境变量覆盖四个运行场景;
- 以真实迁移收尾的六阶段发布前验收:Pigsty 部署的四节点 TLS MinIO 20260804 集群(16 盘、EC:4)经软件包接管路径迁移至 Silo——参考数据(分段、多版本、带标签对象)逐字节读回一致、四轮维护闸门滚动重启、
kill -9故障注入下负载均衡持续 IO 24 轮成功 23 轮(唯一失败恰在击杀当秒)、Prometheus 指标连续,以及完整的回滚至 MinIO 再切回——证明迁移可逆。
验证边界
本轮发布验证未连接外部在线 LDAP/OIDC/KMS/etcd 服务;amd64 软件包完成了交叉构建与载荷校验;Windows 与 Intel macOS 目标仅做交叉编译。
交付物
pgsty/silo的 GitHub ReleaseRELEASE.2026-08-06T00-00-00Z:带校验和的平台归档、溯源 attestation、RPM/DEB/APK 软件包(RPM 带 GPG 签名);docker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Z与latest;docker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Z-distroless与distroless——从完成的 Release 按需发布;- 配套发布:
mcli20260806、silo-pkg3.11.0、内嵌 SILO Console 2.1.1; - 设计记录:原生健康检查与 Distroless 镜像。
选定变更
15def34dc、77bdc4c0c:清除上游交付残留;呈现 Silo 身份并关闭继承的上游服务15ab10833:交付物改名为 silo 并补全软件包载荷30749911b:镜像装载 silo 二进制并翻译 legacy argv 命令e071bb77e:以保持身份的 silo chart 替换 minio chartbd8df5166:以兼容性、打包与溯源证据为 rebrand 设门禁6613c2a3c:钉住外部测试固件并针对 silo 二进制运行套件fd2ca1c6d、c46b16ec6、c47733abc、f1c77d5a2:切换到 pgsty/silo 与 main;记录归档分支6740e6978:工作流迁移到 Node 24 运行时b57275be3:采用无 CLA + DCO 政策并修正版权条款62717d7bf、a6d6d9b02:内嵌 Console 升级到 2.1.0、再到 2.1.16bd9cf77e:按实际链接模块集合重新生成 CREDITS 并纳入 CI 守卫219670d31:LICENSE 与 NOTICE 装入每个软件包与镜像2ff594f4b:新增原生silo healthcheck子命令4c34d2309:新增 Distroless 镜像变体试点b6d47b739、9462cce16:加固健康检查与 Distroless 镜像;lint 清理16b78eb4e:捆绑 mcli 20260806 并将 Helm 默认值指向本版本062a91bee:将 CREDITS 模块闭包钉定到实际发布的 linux 目标467931455:统一 rpm 与 deb 的 release 段为1PGSTYb14ea22aa:镜像发布通道精确匹配校验和清单条目3be10fcc1:新增手动 finalize 通道,为已签名的 Draft 软件包刷新 SBOM 与校验和
致谢
已有四位贡献者的代码合入本分支,Git 历史中保留着他们的署名:@ZouhairCharef 修复了 go-jose 中的 CVE-2026-34986(#18),@mfredenhagen 修复了 OpenTelemetry 中的 CVE-2026-39883(#19),@pinginfo 为 trackingResponseWriter 实现了 Flush,修好了桶通知的流式输出(#34),@waterkip 将文档链接指向了 Silo 门户(#41)。
以新名字发布的第一个版本,也正是感谢所有为这个分支提交过 issue 的人的时刻——缺陷报告、兼容性发现与提案,无论已解决还是仍在处理:
@mosesdd(#1)、@Xavier-777(#2、#17)、@jiadzh(#3)、@TLINDEN(#4)、@AntonOfTheWoods(#5)、@zylpsrs(#6)、@nsanitate(#7)、@makinikm(#9)、@magicxor(#10)、@spaceg00se-r(#11、#14)、@heroes1412(#13)、@vampywiz17(#15)、@davinkevin(#20)、@chalukyaj(#30)、@cbornet(#31、#32)、@jvasile(#33)、@Kesavaambati(#35)、@redfoxfox(#38)、@kuldeep-link11(#39、#40)、@meesudzu(#42)、@pmezhuev(#43)、@kh0mka(#51)。
本版本的多项核心内容可以直接追溯到这些报告:内置客户端的保证来自 #4 与 #9,LDAP-over-TLS 修复来自 #15,软件包载荷补全来自 #33,RPM GPG 签名来自 #43,迁移指南来自 #42,distroless 的 /data 修复来自 #55。
仍在处理中的 PR 同样值得一提。@davinkevin 的 distroless 镜像 PR(#21)比本版本的试点早了数月——最终交付的变体以内置原生健康检查取代了该 PR,但方向是在那里首先提出的。@magicxor(#12)与 @ycjlin(#37)的一致性修复 PR 将在本版本发布后立即进入评审。
所有为本分支做出贡献的人都记录在 CONTRIBUTORS.md 中。GitHub 不为 fork 仓库生成贡献者图表,该文件即本项目的署名记录。
2.29 - Silo 20260804 发布
版本: RELEASE.2026-08-04T00-00-00Z · 提交: d88f46ccee345a9c2fabe2d221d9a9e56bc11aec
SILO 20260804 是 pgsty/minio 社区分支的一次安全、正确性与发布工程更新。该版本完成 CVE-2026-42600 所开启的节点间存储边界治理,阻止客户端输入冒充服务端计算出的 S3/IAM 策略条件值,恢复流式响应 flush 语义,修复多项 multipart 与版本控制边界问题,加固通知配置迁移,将构建基线升级到 Go 1.26.5,并接入由 SILO 维护的 Console、共享包与 mcli 版本。发布流水线经过重建,产出可复现的二进制与 GPG 签名的软件包。
本次发布覆盖 2026-06-18 前基线之后的 50 个提交,共修改 155 个文件,新增 9,241 行、删除 981 行。每一处改动都针对已打标签的提交进行审查,并在 macOS ARM64 与 Linux AMD64 上验证,GitHub CI 在已发布 HEAD 上全部通过。
版本亮点
- 节点间边界收尾: 在存储边界校验 storage-REST 消息体、storage Grid 帧与 peer-S3 Grid 请求,关闭移除
ReadMultiple后残留的路径、卷、纠删元数据、panic 与无界分配缺陷。 - S3/IAM 决策使用真实有效值: 客户端输入不再能遮蔽内部条件值;请求标签与既有对象标签被分离;
s3:signatureAge被限制在经过验证的预签名请求内;s3:versionid跟随服务端实际操作的版本。 - Bucket 与 Object 资源分离: 十二项敏感的 bucket 级写操作不再能通过仅对象的
bucket/*资源模式被授权。提供一个有文档记录的兼容开关用于迁移。 - Multipart 兼容性与正确性改进: 协议允许时,完整对象校验和补全无需逐分片校验和即可工作;零长度 multipart 对象的校验和被保留;重复的分片编号被拒绝,而不是拼装出重复数据。
- 流式可靠性恢复:
trackingResponseWriter现在正确实现Flush并记录隐式 HTTP 200 响应,修复了 20260618 发布中记录的继承回归所影响的mcli watch、bucket 通知监听器与 S3 Select keep-alive。 - 通知配置加固: 解析器与旧迁移读取的 NATS、AMQP 键被注册并正确往返;libpq 连接参数被安全引用;非法键错误不再回显 secret 值。
- 可复现、已签名的发布流水线: 二进制不再嵌入构建机路径,软件包安装到规范的 systemd 路径,RPM 经过 GPG 签名。容器入口现在在每条权限路径上都能优雅退出。
- 发布基线刷新: Go 1.26.5、
klauspost/compress1.18.7、Apache Thrift 0.24.0、SILO Console 2.0.0、silo-pkg3.11.0 与mcli20260804。
安全加固
节点间 storage 与 Grid 边界 — SN-2026-002
20260618 移除过时的 ReadMultiple 端点关闭了一条可达路径,但并未关闭底层缺陷类别。storage-REST 请求体与 Grid RPC 帧不经过 HTTP 查询校验中间件,而 peer-S3 RPC 可以完全绕过 storage-REST 包装层。
本次发布把边界收敛到存储层,在使用前校验每一个调用方可控的路径、卷、纠删参数、分片大小、shard 长度与分配长度。修复包括:
- 在路径与卷两个维度拒绝穿越,包括 Windows 卷根别名;
- 覆盖那些不经 storage-REST 包装层就触达磁盘的 peer-S3 bucket RPC;
- 在 shard 运算之前拒绝为零或不可用的 data/parity/block-size 组合;
- 拒绝负的分片大小与被截断的 shard,而不是把它们报告为健康;
- 将 storage-REST
ReadFile的分配上限设为 5 GiB; - 约束其他源自节点间声明的分配;
- 在不阻塞调用方的前提下遏制 deadline-bounded 存储任务中的 panic;
- 在 keep-alive 响应间保留
ReadParts错误,并避免空分片列表的 trace panic。
这些路由需要集群 root 或节点间凭据,且仅在分布式纠删部署中注册。单节点 S3 行为不变。协议面分析见 节点间路径边界审计。
策略条件绑定真实有效值 — SN-2026-003
策略条件映射历史上把服务端计算的值与原始请求条目混在一起。因此一个客户端可控的拼写可以遮蔽或合成一个内部条件值。SILO 20260804 将 silo-pkg 3.11.0 的精确键查找规则与服务端来源归一化配对:
- 内部条件名不能作为任意客户端值提供;
s3:prefix、s3:delimiter、s3:max-keys取自各自的有效查询输入;- 基于 Header 的
x-amz-*条件不接受无关的查询替代值; - 当存储类别或上传标签同时支持两种形式时,显式存在的 Header 胜出,包括空 Header;
s3:ExistingObjectTag/*仅来自已存储的对象元数据;s3:RequestObjectTag/*绑定到相关操作实际消费的标签输入;s3:signatureAge只在经过验证的 SigV4 预签名认证计算出它之后才暴露;s3:versionid在未指定版本时缺席,并在DeleteObjects中按条目重新绑定到实际解析出的有效版本。
版本 ID 行为关闭了一个"简单省略空值"式修复本会为 Multi-Delete 制造的 fail-open 陷阱。见 缺席不等于为空。
Bucket/Object 资源边界 — SN-2026-004
IAM 匹配器过去会给 bucket 级请求追加一个斜杠,使得像 arn:aws:s3:::bucket/* 这样仅对象的资源能授权部分 bucket 级操作。本次发布在 Allow 语句上,将这十二项敏感写操作从该模式中排除:
PutBucketPolicy、DeleteBucketPolicy、PutBucketObjectLockConfiguration、PutBucketVersioning、PutReplicationConfiguration、PutBucketLifecycle、DeleteBucket、ForceDeleteBucket、PutBucketCors、DeleteBucketCors、PutBucketQOS 与 PutInventoryConfiguration。
Deny 与 NotResource 行为不变。读取/列举操作、CreateBucket、bucket 标签、默认加密与通知配置保持兼容。内置策略使用 Resource: "*",不受影响。
自定义 bucket 授权需要迁移策略
如果某个自定义策略仅用 arn:aws:s3:::bucket/*(常常通过 s3:*)授予这十二项操作之一,请补上裸 bucket ARN:
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 在策略迁移期间恢复历史匹配器。它同时也恢复了历史上的过度授权,因此只应作为临时回滚控制使用。
可信客户端地址边界
MINIO_API_TRUSTED_PROXIES 为 aws:SourceIp、审计 remotehost、事件通知 Host 以及 mcli admin trace 显示的客户端地址提供了一个可强制执行、按需启用的边界:
- 设为地址/CIDR 列表,则仅信任来自这些对端的转发头,并从右向左遍历转发链;
- 设为
none,则忽略所有转发头; - 保持未设置,则完全维持历史行为。
旧的 _MINIO_API_XFF_HEADER=off 开关仍然只抑制 X-Forwarded-For,它不防护 X-Real-IP 或 RFC 7239 Forwarded。如果基于 IP 的策略是你安全边界的一部分,请显式配置可信代理,并阻止对 S3 API 端口的直接访问。多节点部署应当允许自己的节点地址。见 客户端源地址信任。
S3 与存储正确性
Multipart Upload
- 当补全请求不提供逐分片校验和、且上传元数据不要求它们时,CompleteMultipartUpload 接受 S3 完整对象校验和模式。
- 零长度 multipart 对象的校验和被保留,而不是作为空元数据丢弃。
- 分片编号必须严格递增。像
[1,1]这样的重复条目现在返回InvalidPartOrder,而不是消费掉上传并把同一分片拼装两次。带间隙或非 1 起始的合法分片列表仍被接受。见 重复分片编号。
对象读取与 Buffer 所有权
- 纠删读取仅在所有权允许复用时才池化 buffer;
- 更新下载返回调用方拥有的 buffer,而不是暴露返回后可能被覆盖的数据;
- 移除
ReadMultiple后遗留的旧 HTTP 流式辅助函数,在引用与平台 tag 检查后被删除。
HTTP Response Tracking 与 S3 Select
trackingResponseWriter.Flush()委托给底层 flusher 并正确提交响应状态;- 第一次隐式写入记录 HTTP 200,保持审计与指标准确;
- S3 Select 测试不再让客户端解析器与响应体所有权竞争;
- CSV、JSON、Parquet 选择路径保持覆盖,包括 range/错误与 keep-alive 行为。
因此 SILO 20260618 中指出的继承性静默 flush 回归在本次发布中得到修复。
IAM、版本控制与审计行为
- DeleteObject 以及 DeleteObjects 中的每个条目,都针对服务端选定的有效版本评估
s3:versionid。 - 请求标签在策略评估期间不再能冒充既有对象标签。
- 在发出悬垂对象删除记录时恢复
merrs标签,保持预期的审计分类。 - Bucket-policy 与 IAM 路径共享加固后的条件来源规则,同时保留各自既定的 S3 路由与错误行为。
通知配置
- 注册解析器读取的 NATS
user_credentials、nkey_seed、tls_handshake_first键; - 将旧 NATS 环境变量拼写与存储的配置键分离;
- 修复 NATS 迁移往返与 AMQP
immediate/internal映射; - 增加一项机械审计,把通知代码读写的键与各子系统注册的 schema 做比对;
- 引用 libpq 连接串参数,使空白、引号与反斜杠保持其预期取值;
- 阻止非法键诊断回显 secret 值。
见 通知键空间注册。
已知的旧迁移限制
继承的 Postgres 与 MySQL 旧迁移函数仍然写入未注册的 host、port、username、password、database 字段。因此迁移后的配置可能在下次加载时校验失败,而通知目标加载是跨子系统 fail-fast 的。此问题早于 20260804,但从连接串之前的数据库通知配置升级时,必须在重启前审查并转换。存储的 password 字段可能包含明文数据库密码。
组件与依赖
- Go 1.26.5: 包含
crypto/tls与os的安全修复,以及编译器、运行时、网络与 syscall 的修正。 klauspost/compress1.18.7: 刷新对象与归档路径使用的压缩栈。- Apache Thrift 0.24.0: 更新经由 Parquet 支持编译进来的依赖。
- go-systemd 22.6.0: 有意保留而非升级到 22.7.0,因为后者引入了与受支持交叉构建矩阵不兼容的 NetBSD 时钟依赖。
- SILO Console 2.0.0: 嵌入式 console 选自
pgsty/silo-console,同时保留兼容的github.com/minio/console导入路径。 silo-pkg3.11.0: 提供配套的策略、LDAP、证书、RNG 与时间格式修复,同时保留github.com/minio/pkg/v3模块路径。mcli20260804: 嵌入式客户端来自pgsty/mc;发布镜像将其暴露为mcli并保留mc兼容别名。
配套发布说明见 silo-pkg 3.11.0、mcli 20260804 与 SILO Console 2.0.0。
构建、CI 与软件包
本次发布为可复现性与供应链完整性重建了发布流水线:
- 每条路径都优雅退出。 入口脚本的自定义 UID/GID 分支现在
exec进入服务端,使其以 PID 1 运行并直接接收SIGTERM;此前这些分支把一个中间 shell 留作 PID 1,服务端会在容器停止超时后被强杀。一个 CI 烟测会构建发布运行时镜像,并在默认路径与降权路径上断言优雅退出。 - 可复现二进制。 发布二进制不再嵌入构建机的
GOPATH/GOROOT,因此-trimpath生效,第三方从标签重建可得到一致的字节。已发布的 Linux 二进制不含任何构建机路径。 - 加固的发布工作流。 发布标签通过环境传入并做白名单校验,而不是拼接进 shell;构建检出的是正在发布的那个标签;一个可能越过流程发布或移动
latest的、未跟踪的影子 GoReleaser 配置被移除。 - 诚实的门禁。 CI 对构建、vet、单元测试、lint、生成漂移、race 测试与交叉编译把关;交叉编译矩阵对齐到实际发布目标的精确集合;lint 与依赖安装步骤现在会在真实错误上失败,而不是掩盖它们。
- 签名的规范软件包。 RPM、DEB、APK 软件包以 PGSTY 身份用 nFPM 产出,systemd unit 安装到
/usr/lib/systemd/system/minio.service并使用Type=notify,RPM 由 PGSTY 维护者密钥离线 GPG 签名(指纹9592A7BC 7A682E73 33376E09 E7935D8D B9BD8B20)。 - 归档与容器镜像分步发布。 GoReleaser 产出平台归档、校验和与软件包;多架构容器镜像基于已完成的发布产物构建并发布。
兼容性与升级说明
- 集群滚动升级期间保持所有节点在同一发布版本。 节点间校验在 storage-REST 与 Grid 面上发生了改变;混合二进制未经生产测试。
- 审查自定义 IAM 策略。 为这十二项受保护的 bucket 写操作补上裸 bucket ARN。仅将
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on作为临时迁移控制。 - 有意识地配置客户端地址信任。 如果
aws:SourceIp或审计归属重要,请设置MINIO_API_TRUSTED_PROXIES并封闭代理周围的直连网络路径。 - 审查旧的数据库通知设置。 在重启前把 Postgres/MySQL 的 host/user/password 字段转换为受支持的连接串格式。
- 预期重复的 multipart 补全条目会失败。 多次发送同一分片编号的客户端现在会收到
InvalidPartOrder,而不是一个损坏但成功的对象。 - 使用匹配的
mcli。 20260804 客户端禁用了自更新,必须通过软件包或 GitHub Releases 升级;mcli update保留为兼容命令但以非零码退出。 - RPM 用户可启用签名校验。 软件包用上述维护者密钥签名;在为 SILO 软件包启用
gpgcheck之前先导入该密钥。
已执行验证
针对已打标签提交执行的验证包括:
git diff --check、gofmt、模块校验以及 YAML/shell 语法;go build ./...、go vet ./...、项目 lint 与govulncheck ./...;- 完整的
go test ./...、完整 race 套件,以及针对存储、策略、通知、HTTP tracking 与 S3 Select 改动的重复 race 测试; - 生成器幂等性,加上刻意构造的陈旧源与未跟踪产物反例;
- 对每个发布目标的交叉编译;
- Linux AMD64 原生全量测试、定向 race 测试、真实 S3/
mcli冒烟测试(创建/上传/下载/复制、range、版本控制、删除标记、健康检查、优雅退出、重启持久化)与 systemd notify 行为; - 发布产物验证:GitHub CI 在已发布 HEAD 上全绿、可复现二进制不含构建机路径、systemd unit 安装到
/usr/lib/systemd/system、RPM 签名以rpmkeys --checksig校验通过。
govulncheck 未发现可从 Server 或 mcli 代码到达的漏洞。对无人维护的 golang.org/x/crypto/openpgp 包仍有一条模块级提示(GO-2026-5932);该包未被编译进这些二进制。
验证边界
原生执行测试在 Linux AMD64 与 macOS ARM64 上完成;Windows、Intel macOS 与 Linux ARM64 目标完成了交叉编译。本轮测试未连接外部在线 LDAP、OIDC、KMS、STS、Postgres、MySQL、NATS 与 AMQP 端点。
发布产物
- GitHub release
RELEASE.2026-08-04T00-00-00Z,含 Linux、Darwin、Windows 在 amd64 与 arm64 上带校验和的平台归档; - PGSTY 身份下的 RPM、DEB、APK 软件包,其中 RPM 经 GPG 签名;
docker.io/pgsty/minio:RELEASE.2026-08-04T00-00-00Z以及发布选定的latest标签,从发布按需产出;- 配套的 SILO Console 2.0.0、
silo-pkg3.11.0 与mcli20260804 引用。
重点提交
ca7baa670、80e8eaa42、b6f70ab08:校验节点间路径、纠删元数据与分配大小a36fd8fff:遏制 deadline-bounded 存储任务中的 panic2f55347f7:将 S3/IAM 策略条件绑定到真实请求值744a9dcd7:将s3:versionid绑定到有效对象版本97b7d2804:强制 bucket/object 资源边界fe6dc4780:新增可信代理客户端地址边界22c1e41fd:拒绝重复的 multipart 分片编号c8590413f、3e14733f1:恢复完整对象与零长度 multipart 校验和行为8069a32ac、65795ee1f:恢复响应提交与流式 flush 语义162ded343、0c14d8151:修复通知键注册与 libpq 引用924717926、89d346bf5:恢复安全的 buffer 池化与返回 buffer 所有权3b8a55dee:exec 进入降权进程使信号抵达服务端2ca4971d9:停止把构建机路径烧入二进制4c185d5a6、e064b5555:加固发布工作流并移除影子配置aa5139369:将 systemd unit 安装到/usr/lib11d79fddc、ca674a696、021110b45、d88f46cce:对构建、vet、测试、lint、生成、race 与交叉编译把关,并对发布镜像做烟测
2.30 - mcli 20260804 发布
发布日期: 2026-08-04 · 版本: RELEASE.2026-08-04T00-00-00Z
这是 pgsty/mc 社区分支自 20260417 以来的首次发布。本次更新修复了一处调试日志泄密问题,彻底切断了客户端与上游发布渠道之间的所有连接,将容器与软件包完全改由本分支自建,并把打包体系从 MinIO 的 pkger 迁移到标准 nFPM,同时首次为 RPM 包提供 GPG 签名。
上游 minio/mc 仓库已于 2026 年 7 月归档。其最后一个提交 77f82e18 正是本分支的基线,而上游从未发布过包含该提交的版本 —— 因此本次构建比历史上任何官方 mc 二进制都要新。
行为变更
mcli update 自更新功能在本分支中 已被禁用。命令本身保留(接受原有参数以维持脚本兼容),但不再联网检查、也不会替换自身二进制,执行时会打印明确的提示信息,并恒定以退出码 1 结束。上游 mc update 在已是最新版时返回 0,脚本中如有依赖其退出码的调用应予移除。请通过 Pigsty 软件仓库或 GitHub Releases 升级。
同时,每次执行命令时向上游发布源发起的 自动版本检查已完全移除,MC_UPDATE / MINIO_UPDATE 环境变量不再被读取。
主要变更
- 禁用自更新,切断上游发布渠道:移除
minio/selfupdate与aead.dev/minisign依赖及全部二进制替换逻辑,删除更新通知器与 FIPS/非 FIPS 更新分支。update命令保留为兼容壳,运行时辅助函数(Docker / DCOS / Kubernetes / 源码构建检测)迁移至独立的cmd/runtime-info.go。此前该客户端会在每次执行时静默访问上游发布源并提示升级,现已不再有任何出站发布探测。 - 容器与制品全部本地化:默认镜像改为从检出的分支源码构建,hotfix 二进制从本地构建上下文拷贝,不再下载任何上游预编译产物。移除上游发布用的
Dockerfile.release、Dockerfile.release.old_cpu与docker-buildx.sh,并禁用已失效的 MinIO hotfix 上传目标。 - 打包体系迁移至 nFPM:从 MinIO 的
pkger迁移到标准 nFPM。产物结构与安装路径保持完全一致(/usr/local/bin/mcli、包名mcli、版本方案YYYYMMDDHHMMSS.0.0),但供应商信息改为 PGSTY,许可证改用 SPDX 标识AGPL-3.0-or-later,DebianSection由空值改为utils。 - RPM 包首次提供 GPG 签名:RPM 由维护者主密钥在本地离线签名(密钥指纹
9592A7BC7A682E7333376E09E7935D8DB9BD8B20),签名前校验全部包元数据,签名后复验并重新生成校验和。DEB 与 APK 不做包级签名,其信任锚位于软件仓库层。 - 构建溯源加固:此前所有已发布二进制都被 Go 工具链标记为「来自被修改的工作树」(
vcs.modified=true),导致产物无法与其 Git 标签对应。本次修复该问题,并在发布与测试流水线中加入强制校验,确保每个二进制都能追溯到确切的提交。
安全修复
- SUBNET 调试日志凭据脱敏:启用
--debug时,SUBNET 相关的 HTTP 请求会打印完整报文。此前api-key/api_key查询参数、认证头以及响应体均以明文进入日志,而 SUBNET 的认证与注册端点会在响应中返回 API Key、License 与 Token。本次修复对两种参数拼写、重复参数值统一脱敏,屏蔽敏感响应头,并将 SUBNET 响应体整体排除在调试转储之外。该泄漏继承自上游,存在于包括上游mc在内的所有历史版本中:如果你曾对外分享过 SUBNET 相关命令(mcli license .../mcli support ...)的--debug输出,请将其中的 API Key 与 License 视为已泄露并及时轮换。 - 调试脱敏与调用方状态隔离:调试追踪改为转储请求与响应的 副本,确保脱敏动作不会污染调用方持有的对象;覆盖零长度、定长与未知长度三种响应体形态,且不影响调用方正常读取响应内容。
依赖更新
除 term / mod / sync / tools 四项常规刷新外,以下每一项升级都关闭了 Go 漏洞数据库中已发布的安全公告;govulncheck 对本次发布的代码报告 零个可达的已知漏洞。minio/mc、minio-go、madmin-go、minio/pkg 四个包本身无已公开的安全公告。
- Go 构建基线由
1.26.2升级至1.26.5(截至发布时 1.26 线最新版),纳入 1.26.3–1.26.5 各批安全修复,包括 GO-2026-4970(os包符号链接根逃逸)与 GO-2026-5856(crypto/tlsECH 隐私泄漏)—— 对一个通过 TLS 下载对象并写入本地文件的 S3 客户端而言最直接相关的两项。 github.com/klauspost/compress由v1.18.5升级至v1.18.7(关闭 GO-2026-5841)。github.com/prometheus/prometheus由v0.310.0升级至v0.311.3(关闭 GO-2026-5264、GO-2026-5381、GO-2026-5710)。google.golang.org/grpc由v1.79.3升级至v1.82.1(关闭 GO-2026-6061),同步刷新genproto系列。golang.org/x/*系列整体刷新:cryptov0.49.0→v0.53.0(GO-2026-5005…5033 共 14 项公告)、netv0.52.0→v0.56.0(GO-2026-5025…5030 及 GO-2026-5942)、sysv0.42.0→v0.46.0(GO-2026-5024)、textv0.35.0→v0.39.0(GO-2026-5970),term、mod、sync、tools同步升级。- 移除
aead.dev/minisign与github.com/minio/selfupdate,并同步更新第三方致谢清单。
工程与交付
- 集成测试依赖钉死:CI 不再从上游可变地址下载 MinIO 服务端,改用带 SHA-256 校验的
pgsty/minio版本化发布归档,Go 版本钉在1.26.5。 - 发布流水线校验:新增打包验证工作流,逐字节比对三种包格式内的二进制与构建产物是否一致,并校验包名、校验和、架构字段与全部元数据。RPM 元数据的期望值以签名脚本为唯一事实源,避免配置漂移导致发布中途签名失败。
- CI 供应链加固:全部 GitHub Actions 钉到 commit SHA 并接入 dependabot,工作流权限收敛为只读,移除指向上游组织项目板的失效工作流。
- 文档更新:README 中英文版本明确本分支的分发渠道与自更新策略,移除会导致误装上游
mc的安装指引。
已知问题
mcli watch(桶事件监听)在对接 任何已发布的 pgsty/minio 服务端版本时都无法收到事件。根因是服务端侧一处继承自上游的静默流式 flush 回归,与客户端无关,上一个 mcli 版本受影响的程度完全相同;修复已于 2026-07-29 合入服务端 master,但尚未随公开服务端版本交付。详见 SILO 20260618 发布说明 与 PR #34。
另外,本分支继承了上游未修复的历史缺陷;上游仓库已归档,这些问题今后只可能在本分支修复。其中最严重的是 minio/mc#5139:mirror --remove --watch 在源端删除对象的 非当前版本 时,可能误删目标端的现存对象。在版本化桶上组合使用 --remove --watch 时请务必谨慎。
关联提交
- 9603ee3:fix: redact SUBNET secrets in HTTP debug logs
- f6ae2b0:fix: disable self-update in Pigsty builds
- c05a6e4:build: update Go deps and toolchain to 1.26.5
- 1f105aa:build: use local fork artifacts for containers
- 1182da5:ci: pin fork integration test dependencies
- 9ee207f:docs: clarify Pigsty fork distribution channels
- 0686cd8:fix: isolate SUBNET debug redaction
- ad10a2a:build: complete local Docker context isolation
- 5f54221:docs: update mc README and cn version
- 02c0305:build: migrate release packaging to nFPM
- 4c4dcc4:build: harden release provenance and package metadata
2.31 - Silo 20260618 发布
发布日期: 2026-06-18 · 版本: RELEASE.2026-06-18T00-00-00Z
这是 pgsty/minio 社区分支的一次例行安全与依赖维护更新。该版本加固 LDAP STS 限流,补齐 S3 Select 超大记录限制,移除废弃的 ReadMultiple 节点间 storage-REST API,将 Go 构建基线升级到 1.26.4,并刷新 Go 模块依赖以吸收第三方安全修复。
说明
已知问题:本版本与自 RELEASE.2025-12-03T12-00-00Z 以来的所有早期社区版本一样,带有一个继承自上游的静默流式 flush 回归,会导致 mc watch / 桶事件通知监听以及 S3 Select keepalive 失效。修复(PR #34)已随 SILO 20260804 发布。
主要变更
- 移除废弃的
ReadMultiplestorage-REST API:旧的节点间/rmpl端点不再保留兼容路径,而是连同 route、handler、client wrapper、storage interface、xlStorage 方法、生成数据类型和相关 metric 一并移除。上游 multipart 处理迁移到ReadParts之后不应再有生产调用者,但滚动升级时仍应让集群运行一致版本。 - 补齐 S3 Select 超大记录限制:JSON Lines 输入现在走有界 reader 路径,因此在支持 SIMD 的 CPU 上也会一致拒绝超大记录,不再绕过限制。S3 Select stream error 现在会保留预期错误码,并将 JSON parser 错误包装为
JSONParsingError。 - 加固 LDAP STS 限流源地址分桶:限流现在仅按源 IP 分桶,避免按用户名共享的 bucket 被单一客户端耗尽并锁定合法用户。受信代理处理现在从右到左解析
X-Forwarded-For,拒绝全网段 trusted-proxy CIDR,忽略 RFC 7239Forwarded,并明确记录X-Real-IP的部署约定。 - 刷新 Go 运行时与模块基线:release、hotfix、goreleaser 与 old-CPU Docker 构建现在都使用
golang:1.26.4-alpine;go.mod更新到 Go1.26.4;依赖刷新覆盖 NATS、Prometheus、Azure SDK、Apache Thrift、gRPC、OpenTelemetry、Google API/auth、Gox/*以及相关间接依赖。
直接安全修复
- CVE-2026-42600:移除废弃的
ReadMultiplestorage-REST API,关闭/rmpl暴露的旧节点间文件读取路径。 - CVE-2026-39414:补齐 JSON Lines 输入的 S3 Select 超大记录限制,并保留正确的 S3 Select 错误语义。
- CVE-2026-33419:进一步强化 LDAP STS 限流记账与 trusted-proxy 源 IP 处理。
依赖安全更新
- 将
github.com/Azure/go-ntlmssp从v0.1.0升级到v0.1.1,修复 CVE-2026-32952,避免畸形 NTLM challenge 触发 Go 进程 panic。 - 将
github.com/apache/thrift从v0.22.0升级到v0.23.0,修复 GoTFramedTransport实现中的 CVE-2026-41602。 - 将
github.com/nats-io/nats-server/v2从v2.11.1升级到v2.11.15,吸收 NATS 2.11.x 安全补丁线。重点覆盖 pre-auth WebSocket 与 leafnode 拒绝服务、MQTT 授权、JetStream 管理 API 授权、凭据暴露和请求身份伪造等问题,包括 CVE-2026-27889、CVE-2026-29785、CVE-2026-33217、CVE-2026-33218、CVE-2026-33222 与 CVE-2026-33247。 - 将
github.com/prometheus/prometheus从v0.310.0升级到v0.311.3,吸收 Prometheus 关于 remote-read 拒绝服务、Web UI 存储型 XSS、remote-write 配置 secret 暴露等安全修复,包括 CVE-2026-42154、CVE-2026-44903、CVE-2026-42151 与 CVE-2026-40179。 - 将发布构建基线升级到 Go
1.26.4,并刷新golang.org/x/crypto、golang.org/x/net、golang.org/x/sys、golang.org/x/text、google.golang.org/grpc与 OpenTelemetry 等模块族。即便此前锁定版本已经越过特定公开 advisory 的修复范围,这些更新仍让社区分支继续贴近上游已修复依赖基线。
关联提交
2.32 - Silo 20260417 发布
发布日期: 2026-04-17 · 版本: RELEASE.2026-04-17T00-00-00Z
本次版本聚焦安全加固与兼容性收敛,集中修复了 OIDC、LDAP STS、S3 Select、对象复制元数据、unsigned-trailer、Snowball 上传链路,以及依赖与 Go 工具链相关的多项安全问题,并同步完成 LDAP TLS 回归修复与社区分叉文档整理。
主要变更
- 身份认证链路收紧:OIDC / WebIdentity 现在只接受来自 IdP
JWKS的非对称签名ID Token,HS256等对称签名 token 不再被接受;LDAP STS 统一隐藏“未知用户”和“密码错误”的区别,降低用户名枚举风险。 - LDAP STS 限流行为更新:限流现在同时按源 IP 与归一化用户名生效,成功请求不再错误消耗额度;默认仅使用 socket peer address 作为源地址,不再信任
X-Forwarded-For、X-Real-IP、Forwarded,如需按真实客户端 IP 限流,需显式配置MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES。 - 上传与写入路径更严格:presigned query 参数不能再与
unsigned-trailer的PUT/ multipart 上传组合使用;Snowball auto-extract 在unsigned-trailer路径下也会执行完整签名校验,匿名或伪造签名请求将被拒绝。 - 复制元数据不再可伪造:普通
PUT/COPY请求夹带的X-Minio-Replication-*内部复制头现在会被拒绝或忽略,只有可信复制链路才能写入相关内部元数据。 - S3 Select 错误语义更明确:CSV 和 line-delimited JSON 遇到超大记录时会直接返回
OverMaxRecordSize,不再笼统返回InternalError;依赖旧错误码的客户端或告警规则需要同步调整。 - 运行时与依赖基线升级:修复
ldaps://未正确应用 TLS 配置的回归问题,替换minio/pkg/v3为pgsty/minio-pkg/v3,并锁定若干易引入 breaking changes 的关键依赖;同时升级go-jose、go.opentelemetry.io与 Go1.26.2,统一构建与发布基线。 - 文档与安全说明同步更新:刷新
SECURITY.md、VULNERABILITY_REPORT.md、docs/sts/ldap.md等文档,新增安全通告索引,并将安全说明中的上游minio/minio引用统一切换为pgsty/minio。
修复的 CVE
- CVE-2026-34986:升级
go-jose到v4.1.4,修复 JWT / JOSE 依赖中的已知安全问题。 - CVE-2026-39883:升级
go.opentelemetry.io依赖栈,修复 PATH hijacking 风险。 - CVE-2026-33322:恢复严格的 JWKS-only OIDC JWT 验证路径,阻断 keyring 注入与算法混淆风险。
- CVE-2026-33419:系统性强化 LDAP STS 认证、限流、源地址识别与记账逻辑,涉及 4 个后续修复提交。
- CVE-2026-34204:拒绝不可信请求注入
X-Minio-Replication-*元数据,防止对象被写入异常复制状态。 - CVE-2026-39414:提前拒绝超大 S3 Select 记录,避免异常数据持续缓冲和解析。
- GHSA-hv4r-mvr4-25vw:封堵 unsigned-trailer query auth 绕过。
- GHSA-9c4q-hq6p-c237:加固 Snowball auto-extract 场景中的 unsigned-trailer 认证与签名校验。
- CVE-2026-32280、CVE-2026-32281、CVE-2026-32283:升级 Go 到
1.26.2,吸收上游工具链与标准库安全修复。
关联提交
- ce1c537:fix: pin deps with breaking changes and fix LDAP TLS regression (#15)
- 68e0ba9:fix: upgrade go-jose to v4.1.4 to patch CVE-2026-34986
- 1869bd3:fix: CVE-2026-39883 upgrade go.opentelemetry.io
- ff58df9:merge: PR #18 upgrade go-jose to v4.1.4 for CVE-2026-34986
- e4fa063:merge: PR #19 upgrade go.opentelemetry.io for CVE-2026-39883
- d24f449:fix: CVE-2026-33322 restore JWKS-only OIDC JWT verification
- 3b950f8:fix: CVE-2026-33419 harden LDAP STS auth
- 56fa63b:fix: CVE-2026-34204 reject untrusted replication metadata
- 3252d5b:fix: CVE-2026-39414 reject oversized S3 Select records
- f444b6f37:fix: fake CVE-2026-40027 block unsigned-trailer query auth bypass
- efb6e5b00:fix: fake CVE-2026-40028 harden snowball unsigned-trailer auth
- db4c0fd:fix: CVE-2026-32280/CVE-2026-32281/CVE-2026-32283 upgrade Go to 1.26.2
- 18b712d:fix: CVE-2026-33419 preserve LDAP STS rate limits on success
- 9e10f6d:fix: CVE-2026-33419 harden LDAP STS rate-limit source IP
- f441108:fix: CVE-2026-33419 tighten LDAP STS rate-limit accounting
- f48dbe7:docs: refresh security guidance and fork references
2.33 - Silo 20260325 发布
发布日期: 2026-03-25 · 版本: RELEASE.2026-03-25T00-00-00Z
本版本重点改进容器镜像打包、修复 ldaps:// 连接的 TLS 配置透传回归,并同步锁定多项第三方依赖的安全更新版本。
主要变更
- 将
mcli(及兼容别名mc)内置到 Docker 镜像中并增加二进制校验,开箱即可直接执行管理命令。 - 修复
ldaps://场景下的 LDAP TLS 配置回归,确保自定义 CA 证书与MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY正确透传。 - 清理从上游继承但社区分支不再使用的历史 CI/CD 工作流。
- 锁定多项关键依赖版本,隔离上游非兼容变更并提供以下安全修复。
修复的 CVE
- CVE-2026-24051:将
go.opentelemetry.io/otel/sdk锁定至安全版本v1.42.0,消除 macOS 上因PATH劫持触发任意代码执行的风险。 - CVE-2025-10543:升级至
github.com/eclipse/paho.mqtt.golang v1.5.1,修复超长 UTF-8 字符串编码错误导致的 MQTT 数据包内容异常。 - CVE-2025-58181:升级至
golang.org/x/crypto v0.49.0,修复sshGSSAPI 认证请求可触发的无界内存消耗问题。
关联提交
2.34 - Silo 20260321 发布
发布日期: 2026-03-21 · 版本: RELEASE.2026-03-21T00-00-00Z
这是一次围绕 Go 1.26.1 与依赖收敛展开的维护版。除了适配更严格的编译与 lint 检查外,这个版本也完成了本轮最关键的一次安全依赖刷新。
主要变更
- 将构建环境从 Go
1.26.0升级到 Go1.26.1。 - 全面刷新直接与间接依赖,收敛新工具链下的兼容性问题。
- 修正 Go 1.26.1 更严格检查暴露出来的一批 lint 与测试问题。
修复的 CVE
- CVE-2026-27137:Go stdlib 从
1.26.0升级到1.26.1,修复crypto/x509对邮箱约束校验不完整的问题。 - CVE-2026-27138:Go stdlib 从
1.26.0升级到1.26.1,修复畸形证书触发crypto/x509panic 的问题。 - CVE-2026-25679:Go stdlib 从
1.26.0升级到1.26.1,修复net/url对 IPv6 host literal 解析不严格的问题。 - CVE-2026-27139:Go stdlib 从
1.26.0升级到1.26.1,修复os中FileInfo可逃逸Root边界的问题。 - CVE-2026-27142:Go stdlib 从
1.26.0升级到1.26.1,修复html/template在meta refresh场景下 URL 未正确转义的 XSS 风险。 - CVE-2026-26958:
filippo.io/edwards25519从v1.1.0升级到v1.2.0,修复MultiScalarMult可能产生错误结果或未定义行为的问题。 - CVE-2025-10543:
github.com/eclipse/paho.mqtt.golang从v1.5.0升级到v1.5.1,修复超长 UTF-8 字符串编码错误导致的 MQTT 报文异常。 - CVE-2026-24051:
go.opentelemetry.io/otel/sdk从v1.38.0升级到v1.42.0,修复 macOS 上通过 PATH hijacking 触发任意代码执行的风险。 - CVE-2026-33186:
google.golang.org/grpc从v1.77.0升级到v1.79.3,修复 HTTP/2:path缺少前导斜杠时的授权绕过问题。
关联提交
2.35 - Silo 20260314 发布
发布日期: 2026-03-14 · 版本: RELEASE.2026-03-14T12-00-00Z
这个版本完成了向社区维护 Console 分支的切换,并在切换过程中做了一轮较大幅度的依赖更新,为后续 Go 1.26.x 维护版打下基础。
主要变更
- 切换到社区维护的
georgmangold/console v1.9.1,替换不可持续维护的上游 Console 依赖。 - 大幅更新
go.mod中的直接与间接依赖,使 Console 与新工具链组合能够稳定构建。 - 修复
grid_test.go的go vet格式化指令问题,并调整部分测试以适配 Go 1.26 的 HTTP 行为变化。
修复的 CVE
- CVE-2025-47913:
golang.org/x/crypto从v0.37.0升级到v0.46.0,修复ssh/agent在处理异常响应时可能导致客户端 panic 的问题。 - CVE-2025-58181:
golang.org/x/crypto从v0.37.0升级到v0.46.0,修复sshGSSAPI 认证请求可触发无界内存消耗的问题。 - CVE-2025-47914:
golang.org/x/crypto从v0.37.0升级到v0.46.0,修复ssh/agent对畸形身份消息缺乏边界校验导致的 panic 问题。 - CVE-2025-47911:
golang.org/x/net从v0.39.0升级到v0.48.0,修复html.Parse在特制输入下的二次复杂度解析问题。 - CVE-2025-58190:
golang.org/x/net从v0.39.0升级到v0.48.0,修复golang.org/x/net/html可能陷入无限解析循环的问题。
关联提交
2.36 - Silo 20260214 发布
发布日期: 2026-02-14 · 版本: RELEASE.2026-02-14T12-00-00Z
这是社区分支早期的一个基础设施版本,除了恢复内嵌 Console 与建立 GitHub CI/CD 之外,也把 Go 运行时基线提升到 1.26.0,因此一并消化了上一代工具链中的一批安全问题。
主要变更
- 恢复内嵌 Console,并更新 README 以明确社区维护分支的定位。
- 新增 GitHub CI/CD 流水线,为后续自动构建和多平台分发建立基础。
- 补充文档、Docker、GitHub 仓库与
pig包管理器安装入口,完善发布入口。
修复的 CVE
这些问题随着 Go 从 1.25.5 升级到 1.26.0 一并被修复,包括:
- CVE-2025-68121:
crypto/tls在会话恢复时可能错误接受已变更 CA 配置。 - CVE-2025-61730:TLS 1.3 在跨加密级别 record 中可能错误处理握手消息。
- CVE-2025-61726:
net/url查询参数解析可导致内存耗尽。 - CVE-2025-61728:
archive/zip构建索引时可能触发过高 CPU 消耗。 - CVE-2025-68119:
cmd/go在调用外部 VCS 工具链时可能触发意外代码执行。 - CVE-2025-61731:
#cgo pkg-config:指令可导致任意文件写入。 - CVE-2025-61732:
cmd/cgo文档注释解析差异可能导致代码走私。
关联提交
- 8630937:Restore embedded console and update README for community fork
- 68521b3:add github ci/cd pipeline
历史说明:CI/CD 流水线证据链接维护主线的 68521b37f,保留原流水线文件并包含 README 调整。
2.37 - Silo 20251203 发布
发布日期: 2025-12-15 · 版本: RELEASE.2025-12-03T12-00-00Z
这是目前可追溯到的首个社区版发布,确立了社区维护分支的打包与分发基线。
主要变更
- 基于
minio/pkger建立社区版打包流程。 - 选择 MinIO 进入 maintenance mode 后的一版上游基线作为社区分支起点。
- 首次产出
apk、deb、rpm等多种平台包,为后续持续发布建立基础。
修复的 CVE
- 首个社区版发布基线,无相对前序社区版本的增量 CVE 列表。
关联提交
- d4cd4b4:RELEASE.2025-12-03T12-00-00Z with go 1.25.5
3 - SILO 安全编年史
本栏按时间从新到旧收录 PGSTY SILO 的安全技术公告。每篇公告列出受影响版本、触发条件、漏洞根因、代码修复与升级要求。
全部 CVE-* 与 SN-2026-* 编号、修复提交及依赖安全更新的单页汇总,见安全公告台账。
3.1 - 十月安全更新:Object Lock、管理凭据与 JWK 解析
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。
运维操作建议
- 使用生产环境中的真实普通用户、服务账号、STS 会话与外部插件身份验证管理操作。若自动化脚本此前依赖受限子凭据继续派生下级服务账号,或使用非 root 管理员管理 root 派生密钥,请改用具备相应权限的父身份或 root 身份。
- 审查受影响版本期间创建的子服务账号与策略变更,撤销异常密钥;多站点 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-josev4.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 修复设计笔记及组件版本矩阵。
3.2 - SN-2026-015 与 SN-2026-016:S3 Object Lock 治理模式保留锁绕过与指定版本号原地覆盖漏洞分析
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):
- 在
PutObject与CopyObject中由客户端指定?versionId=:非复制流量的普通客户端可在PutObject或CopyObject的查询参数中传入?versionId=<existing-uuid>(或在 SnowballPutObjectExtract的 tar PAX 头中传入minio.versionId),直接原地覆盖已有历史版本,而未对目标版本执行 WORM 保留期与法律冻结(Legal Hold)拦截。 - 目录对象(
key/)覆盖null版本:上传以/结尾的目录对象时,即使在开启了版本控制与 Object Lock 的桶中也会固定写入nullVersionID,从而在未检查null版本现有保留锁的情况下原地覆盖数据。 CompleteMultipartUpload原地覆盖null版本:当桶挂起版本控制(Versioning Suspended)且存在受保护的null版本对象时,通过CompleteMultipartUpload完成分段上传会原地覆盖该null版本而未检查其 WORM 锁状态。- 内部复制请求头伪造:未持有
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:
而在 enforceRetentionBypassForPut 中:
- 当目标对象当前没有有效保留锁(
opts.Mode == "")时,代码未正确要求retSet必须为真,只要请求头带了x-amz-bypass-governance-retention: true就会放行(触发 F1); - 当目标对象处于
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(
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(削弱有效
GOVERNANCE锁必须叠加s3:BypassGovernanceRetention):若目标版本当前处于未过期的GOVERNANCE锁定期,且请求试图清空保留锁(Mode == "")、缩短保留截止时间(!RetainUntilDate.After(existing.RetainUntilDate))或跨模式变更,则请求必须同时携带x-amz-bypass-governance-retention: true并通过s3:BypassGovernanceRetention权限校验。 - 不变量 3(
COMPLIANCE绝对不可削弱):处于未过期COMPLIANCE锁定期的对象版本,任何身份均不可清空、缩短保留期或降级为GOVERNANCE。 - 不变量 4(禁止非复制请求指定
?versionId=覆盖写):非复制 S3 客户端不得在PutObject、CopyObject或PutObjectExtract中指定已有或自定义的versionId原地覆写对象。 - 不变量 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 |
五个被否决的替代方案
在设计复核阶段,我们评估并否决了五个看似更简单的改法:
- 仅把
isPutRetentionAllowed中的byPass || retSet改成byPass && retSet:这样虽能修掉 F2 与 F3,但未加锁分支上的 F1 依然存在;而且如果一个仅有s3:PutObjectRetention的用户在正常延长保留期时顺手带上了x-amz-bypass-governance-retention: true,byPass && retSet会错误地拒绝该合法延长请求。 - 直接在
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 策略条件全部失效! - 只要请求头带了
x-amz-bypass-governance-retention: true就一律要求s3:BypassGovernanceRetention:不符合 S3 规范——只有当操作确实需要突破有效GOVERNANCE锁时才需要检查s3:BypassGovernanceRetention。 - 直接禁止传入空
<Retention/>请求体:通过<Retention/>清空治理锁是mcli retention clear长期依赖的公开扩展能力;只要调用方同时具备s3:PutObjectRetention与s3:BypassGovernanceRetention且显式传入x-amz-bypass-governance-retention: true,就应当继续支持。 - 仅在开启了 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.Duration290 年上限而发生饱和截断。 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.Duration290 年上限的远期保留时间(如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.3 - 九月安全修复:载荷完整性、IAM 撤销与 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。
3.4 - SN-2026-011:修复与发布状态
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 日版本加固公告及组件版本矩阵。
3.5 - CVE-2025-62506:Session Policy 权限提升
状态: 已继承并发布
首个 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 父账号场景:
TestServiceAccountPrivilegeEscalationBug2_2025_10_15(cmd/admin-handlers-users_test.go):验证受限服务账号无法通过创建未受限子服务账号实现提权。TestSTSPrivilegeEscalationBug2_2025_10_15(cmd/sts-handlers_test.go):验证受限 STS 临时凭据的同一边界。
两组测试均接在标准 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 通告。
3.6 - SILO 20260903 安全说明:SN-2026-006 至 010
本公告汇总随 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 归档解压时跨条目传递复制信任状态。
修复方案在接收端建立了统一的复制信任判定流程:
- 先按原始签名请求完成 SigV4 认证;
- 要求携带唯一且精确小写的
X-Minio-Source-Replication-Request: true标记头; - 在目标资源上校验
s3:ReplicateObject或s3:ReplicateDelete权限; - 仅在复制状态同时为
REPLICA时派生更严格的replicaTrusted状态; - 将判定结论存入私有的
req.Context()键中; - 验签完成后在请求克隆上剥离未受信任的内部复制头,同时保留底层
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。升级后:
- 仅持有
s3:DeleteObject的主体无法再删除显式版本; - 通过
Allow s3:*配合Deny s3:DeleteObject阻止永久删除的策略,需同步将s3:DeleteObjectVersion加入Deny列表。
运维升级建议
升级前:
- 梳理需要删除显式对象版本的业务策略,显式授予或拒绝
s3:DeleteObjectVersion; - 核查涉及用户/组启停的自定义管理策略;
- 确认复制专用账号具备所需的
s3:ReplicateObject/s3:ReplicateDelete权限,且普通业务账号未误配这两项权限; - 确认 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 与镜像发布门禁。
相邻问题跟踪
- 条件删除(
DeleteObject的If-Match与DeleteObjects的逐对象 ETag,#10)以及站点复制间桶级元数据删除收敛(#77)另行跟踪,详见 Server 20260903 发布说明与随后的 Server 20260916 发布说明。
3.7 - CVE-2026-32285:最终无需补丁的 jsonparser 通告
状态: 无需代码改动,问题已关闭
GitHub Issue: pgsty/silo#26
关联上游提交: c1a49490(CVE-2025-62506)
对 CVE-2026-32285 的初始排查假设仓库可能仍携带未修复的 github.com/buger/jsonparser,团队甚至一度评估过替换依赖或维护独立分支。但在逐一核对通告引用的上游提交、Go 模块实际解析版本以及符号可达性后,结论完全改变:
- 外部漏洞库将
CVE-2026-32285的摘要写成了jsonparser漏洞,但其绑定的修复提交c1a49490实际上是 CVE-2025-62506(Session Policy 权限提升)的上游修复,自RELEASE.2025-12-03T12-00-00Z起已包含在所有 SILO 版本中。 - 针对
github.com/buger/jsonparser本身,当前代码树已解析至包含修复的v1.1.2,且govulncheck未发现任何可达的漏洞符号。
因此,我们没有制造一次形式上的依赖升级,而是完整记录核查证据并关闭了 pgsty/silo#26。
初始假设为什么站不住脚
问题最初被表述为“该依赖尚无可用的修复版本”。如果未核对模块图与提交溯源就直接按该前提改动代码,会带来多重副作用:
- 在没有任何安全收益的情况下变动依赖图;
- 为一个并不存在的漏洞冒着引入 JSON 解析兼容性回归的风险;
- 背上长期维护额外第三方库分支的负担;
- 误导运维人员以为既有的 SILO 发行版曾暴露于该漏洞之下。
当证据表明代码已经安全时,保持代码不变本身就是明确的安全决策。
四层核查过程
本次研判按四层证据逐步收敛:
- 核对提交溯源:检查通告链接的上游提交
c1a49490,确认其为已原样继承的 Session Policy 修复(CVE-2025-62506)。 - 核对模块解析版本:检查
go.mod与go.sum中实际选中的版本,确认github.com/buger/jsonparser已处于包含上游补丁的v1.1.2。 - 符号可达性分析:运行
govulncheck,确认编译后的调用图中不存在可达的漏洞符号。 - 归因外部通告偏差:确认告警源于外部漏洞数据库的条目错配与信息滞后,而非当前源码树遗留漏洞。
评估依赖类安全通告时,必须严格区分四件事:某个历史版本曾被标记受影响、某个包被项目导入、漏洞符号在程序调用图中可达、以及外部不可信输入能通过部署入口真正触发该路径。
为什么不做“预防性盲目升级”
既然当前选中的版本已经包含修复,再随意滚动到其他提交或替换解析库并不会提升安全性。在庞大的 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 最终解析到哪个版本、漏洞符号是否进入编译调用图、以及运行时入口是否可利用。只有当这些核查证明源码确实存在未闭合路径时,才应当产出代码变更。
3.8 - CVE-2026-33322:OIDC JWT 算法混淆
状态: 已发布
首个包含版本: 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 密钥环后,单次客户端密钥泄露就会直接升级为任意身份伪造:
- 攻击者通过配置泄露等途径获取 OIDC
client_secret。 - 攻击者构造包含任意用户或角色声明的 JWT,并以
client_secret作为 HMAC 密钥签发HS256、HS384或HS512令牌。 - 验证器在密钥环中找到
client_secret并通过 HMAC 验签。 - 伪造令牌进入
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 发布。
兼容性代价
这是一项明确的不兼容变更(Breaking Change)。仍在签发 HS256、HS384 或 HS512 ID Token 的身份提供商必须先迁移至基于 JWKS 的 RSA 或 ECDSA 非对称密钥,再升级 SILO。若怀疑升级前 client_secret 曾发生泄露,请同步在 IdP 与 SILO 中轮换该密钥。
3.9 - CVE-2026-33419: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 InvalidParameterValueSTS 认证错误; - LDAP 基础设施故障继续返回 HTTP
500,并在服务端日志中保留底层详细原因; - 新增单节点内存限流器,同时按客户端源 IP 与归一化后的
LDAPUsername两个维度分桶限流。
这一版关闭了响应内容侧信道并抬高了暴力破解成本,但对限流器状态机与客户端 IP 提取逻辑的深入复核很快暴露出新的缺陷。
第二轮:修正成功退额、代理溯源与令牌核算
4 月 16 日的连续补丁(18b712d、9e10f6d、f441108)修复了第一版限流器的三类缺陷:
- 显式的预留/提交/取消/退还生命周期(reserve/commit/cancel/refund):认证成功以及 LDAP 基础设施故障必须退还预留令牌,避免正常的高频登录耗尽失败配额。
- 默认不信任代理转发头:默认仅使用 TCP 套接字对端地址(
RemoteAddr),绝不因为请求携带了X-Forwarded-For、X-Real-IP或Forwarded就无条件采信,除非直连对端在显式配置的可信代理白名单中。 - 退额与容量硬边界:取消与退还逻辑必须严格受限于桶容量上限,防止高并发取消时凭空多退令牌。
第三轮:为什么删掉了 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头,限流器将回退至按代理对端地址分桶。 - 统一响应才是防枚举的核心:限流的作用是压低单一来源的尝试速率,而真正隐藏“用户名是否存在”的是统一的外部认证失败响应。
3.10 - CVE-2026-34204:复制元数据注入
状态: 已发布
首个包含版本: 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 复制流程保持正常。
设计取舍与后续接收端全局加固
- 默认静默剥离:普通客户端夹带的内部复制请求头会被直接剥离并忽略。
- 安全的失效默认方向:由于复制专用元数据必须在授权分支内显式恢复,未来若新增复制入口时漏掉恢复调用,只会表现为功能测试失败,而不会静默放开未授权注入。
- 后续接收端全局加固:
- 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-chunkedContent-Encoding并剔除已废弃的敏感元数据键,防止原始请求头被重新注入落盘对象。
- SN-2026-008(
3.11 - CVE-2026-39414:S3 Select 超大记录与 SIMD 快路径绕过
状态: 已发布,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 分发链路继续下钻时,我们发现了下面这条分支:
当 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 能力的行为一致性验证:
初始修复随 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 的分隔符语义作为独立兼容性问题另行跟踪。
3.12 - CVE-2026-40344:Snowball 自动解包认证绕过
状态: 已发布
首个包含版本: 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 归档可正常流式解包。
兼容性与运维边界
- 拦截未验签请求组合:此前依赖未实际验签的 unsigned-trailer Snowball 请求客户端,在升级后会在解包开始前收到
AccessDenied。 - 共享 reader 边界:Snowball 自动解包与普通
PutObject的 unsigned-trailer 路径现在共用newUnsignedV4ChunkedReader(),后续对该分块读取器的修改必须同时回归两条路径。 - 解包器内部边界独立:将认证前移至
untar()之前解决了未授权进入解包器的问题,而已授权归档内部的条目路径规范化、归档体积与对象数量控制仍属于独立的防御面。
3.13 - CVE-2026-41145:Unsigned-Trailer 查询认证绕过
状态: 已发布
首个包含版本: 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 请求头中时,旧代码的两处假设发生了碰撞:
- 处理器用“
Authorization请求头是否非空”来决定是否调用reqSignatureV4Verify,误以为请求头为空就代表纯匿名请求; - 身份提取与
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 上传以及桶策略允许的纯匿名写入继续正常工作。
兼容性与运维边界
- 明确拒绝预签名 unsigned-trailer:预签名/查询字符串 SigV4 凭据与
STREAMING-UNSIGNED-PAYLOAD-TRAILER的组合现已明确不受支持,属于有意的不兼容收紧(Breaking Change)。 - 下沉至 Reader 的结构性收益:当多个处理器共享同一种认证数据流时,将认证门禁放进
newUnsignedV4ChunkedReader()能够从结构上防止PutObject、PutObjectPart或PutObjectExtract(CVE-2026-40344)再次因外层分支不一致而漏检。
3.14 - CVE-2026-42600:ReadMultiple Storage-REST 路径穿越
状态: 已发布
首个包含版本: 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 端点。
3.15 - SN-2026-002:内部节点路径包含审计——补完 CVE-2026-42600 欠下的那笔账
发布状态: 已随 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。
三层历史设计叠加出的结构性盲区
三个各自看似合理的设计事实叠加在一起,构成了整类漏洞的根因:
- 路径校验只发生在 HTTP 表层:全局中间件只检查
r.Form,而r.Form仅由url.ParseQuery(RawQuery)填充,永不解析请求体(2017 年定型)。 - Grid RPC 绕过了 HTTP 中间件:
/minio/grid/v1在建立连接时完成一次 WebSocket 升级,之后的 msgpack 帧直接分发给 handler,不再经过 HTTP 中间件链(2023 年引入)。 - 存储层默认调用方已完成校验:
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 桶,攻击者随后发一次普通 S3GetObject就能读走集群 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 反射护栏测试,以小得多的风险换取了同等的抗漂移能力。
施工日志:我们自己制造并拦截的四次回归
在开发与审查过程中,我们自己的初版代码曾先后制造出四次回归:
- 误杀空格对象键(
" "与" "):第一版路径守卫把空白字符当成了非法路径分隔符,直接拒绝了" "与" "——而它们是完全合法的 S3 对象键。由于PutObject最终通过RenameData提交数据,上传名为" "的对象会在所有远程磁盘上同时失败,直接打崩写入 quorum。 - 误杀 Unix 下的纯反斜杠键(
"\\"):同一函数还拒绝了"\\"。在 Unix 上,path.Clean从不把\当作目录分隔符,因此"\\"只是一个普通文件名。在节点间装饰器中拒绝它,会导致分布式集群拒绝单机部署接受的合法 S3 写入。 - Windows 下的空格与句点卷根别名(
" "与"..."):Win32 路径规范化层会自动剥离路径组件末尾的空格与句点,因此完全由空格或句点组成的路径组件会凭空消失并退化为父目录。这意味着在 Windows 上" "与"..."都是卷根别名——而在审查指出这一问题前,"..."正好躺在我们的“合法对象名”正向测试白名单里。 - 负数分片尺寸(
Size < 0):初版guardErasureParams只拦截了“正数尺寸 + 不可用纠删码参数”,把“非零”写成了Size > 0,漏掉了负数尺寸通过另一条计算路径同样会让ShardFileSize归零。
两次“假绿”测试与一处 Fuzz 盲区
编写复现与验收测试时,我们还踩中了三个测试陷阱:
- REST 客户端静默覆盖
Content-Length:最初通过storageRESTClient发送伪造的Content-Length: 64 GiB(配合空*bytes.Reader)测试AppendFile时,客户端库对*bytes.Reader做了类型特判并根据其实际长度重写了Content-Length: 0,导致测试拿到了毫无意义的绿灯。 - Go 标准
http.Client拒绝发送截断请求体:换成不透明io.Reader后,Go 自己的 HTTP 客户端传输层又因为请求体实际长度短于声明的Content-Length而拒绝发包。最终改用httptest直接驱动服务端 handler,才真实复现出 64 GiB 分配。 - 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接口的所有方法;一旦未来新增了接收路径参数却未在装饰器守卫表中登记的方法,测试直接按方法名报错失败。
独立代码审查记录
本次修复在合入前经过五轮独立代码审查,依次定位出五处遗漏缺陷:空白键误杀 -> 纯反斜杠键误杀 -> 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一致,不解析磁盘根目录内部指向外部的操作系统符号链接。
3.16 - SN-2026-003:s3:versionid 条件求值缺陷与请求条件隔离加固
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 策略求值加固:
SN-2026-003(80c2c9d03):当请求未携带versionId时,getConditionValues依然无条件写入了args.ConditionValues["versionid"] = []string{""},导致针对s3:versionid的 IAMNull条件恒判定为“键已存在”;而如果只修改getConditionValues,又会导致批量删除DeleteObjects请求体中的 XML<VersionId>绕过版本条件策略,必须配合authorizeRequestWithTags中的逐条重绑定一并修复。- 条件来源隔离(
2f55347f7):严格隔离 URL 查询参数、HTTP 请求头、服务端内部派生键以及s3:ExistingObjectTag/<key>与s3:RequestObjectTag/<key>,防止不可信查询串或请求头伪造内部条件键。 SN-2026-005(2b087a1/eee05a17c):新增ParseConfigStrict,在策略写入路径上拒绝退化的裸 ARN 前缀("arn:aws:s3:::"与"*arn:aws:s3:::")。
结论先行
- 无条件写入空字符串切片导致
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。 - 仅修改
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"]。 - 空白字符归一化消除
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 条件算子按如下逻辑检查请求上下文中是否缺失某个条件键:
然而在 cmd/signature-v4-utils.go 中,getConditionValues 在函数末尾未判断 vid 是否为空便直接写入了 "versionid":
当调用方发起不带 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 条件严禁永久删除任何历史版本:
由于 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 将两端协同闭环:
- 在
getConditionValues中仅当vid != ""时写入"versionid": - 在
authorizeRequestWithTags(cmd/auth-handler.go)中针对DeleteObjectAction从ReqInfo.VersionID重绑定"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 等)均通过以下方式提取版本号:
然而 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。
边界说明与关联修复
DeleteObjects的 Object Lock 治理模式绕过检查(75a6734e4/#104):在审查DeleteMultipleObjectsHandler的过程中,我们同步梳理了批量删除中请求头与逐条保留锁的交互;随后在 Server 20260903(75a6734e4,SN-2026-008)中收紧了x-amz-bypass-governance-retention的逐条鉴权边界。- Snowball
PutObjectExtract的minio.versionIdPAX 扩展头:在本次versionId审计中记录归档,并已在 Server 20261018(SN-2026-016,14042b5aa)中彻底封堵。 - 其余身份条件键(
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 前缀。
3.17 - 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 等高危桶级变更操作。
结论先行
- 根本原因:当
args.ObjectName为空时,Statement.IsAllowed无条件向桶名追加了'/'(得到"bucket/"),从而被"arn:aws:s3:::bucket/*"的末尾通配符*匹配命中。 - 多租户提权风险:在多租户场景下,被授予
Allow s3:* on arn:aws:s3:::tenant/*(常见的“允许操作该桶内所有对象”简写写法)的租户凭据,可以调用PutBucketPolicy为该桶写入Principal: "*"公开策略,或调用DeleteBucket、PutReplicationConfiguration、PutBucketLifecycle、PutBucketVersioning与PutBucketObjectLockConfiguration。 - 为何不能一刀切去掉末尾斜杠:直接对所有空对象名请求去掉
'/'会立刻破坏 11 个现有的 STSListBucket兼容性测试,并且会导致Deny s3:* on arn:aws:s3:::bucket/*拒绝策略失效。 - 精准收敛边界:针对
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 比对的资源路径:
由于 else 分支无条件追加了 "/",每一个不带对象名的桶级请求(args.ObjectName == "")都会被构造为 resource = "mybucket/"。当它与资源模式 "arn:aws:s3:::mybucket/*" 进行通配比对时,* 会匹配 "/" 之后的空后缀并返回 true。
多租户环境下的越权影响
在 AWS IAM 与 S3 桶策略语义中,arn:aws:s3:::mybucket 代表桶本身,而 arn:aws:s3:::mybucket/* 代表桶内的对象。管理员在为租户或业务团队分配“桶内对象全权读写”权限时,经常将动作简写为 s3:* 并将资源限定在对象 ARN 上:
由于 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" 去匹配。但运行测试集与代码审查后,我们发现这种一刀切改法会引发两个严重回归:
- 破坏存量
ListBucket兼容性:cmd/sts-handlers_test.go中有 11 个继承自上游的 STS 与 IAM 测试用例专门在arn:aws:s3:::mybucket/*上授予s3:ListBucket(或s3:*),而并未同时列出裸桶 ARNarn:aws:s3:::mybucket。如果对读取/列举操作也去掉"/",许多仅配置了arn:aws:s3:::mybucket/*的存量业务将无法列举桶。 - 削弱
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 中接入):
对于 Allow 语句上的这些高危桶级变更动作,资源集合必须同时匹配裸桶名 "mybucket"(从而拒绝仅包含 arn:aws:s3:::mybucket/* 的策略,同时放行包含 arn:aws:s3:::mybucket 或 arn:aws:s3:::* 的策略)以及 "mybucket/"(原因见下文为什么单调性要求同时匹配两者)。
第二轮动作审计:哪些桶级写操作被纳入保护
初版草稿仅拦截了 PutBucketPolicy 与 DeleteBucketPolicy,遗漏了 DeleteBucket 以及生命周期、复制等变更操作。在第二轮审查中,我们逐一盘点了所有桶级写动作(args.ObjectName == ""),并将它们明确划分为两组。
纳入 isSensitiveBucketMutation 保护的 12 项高危桶级变更动作包括:
s3:PutBucketPolicys3:DeleteBucketPolicys3:DeleteBuckets3:ForceDeleteBuckets3:PutBucketLifecycles3:PutReplicationConfigurations3:PutBucketVersionings3:PutBucketObjectLockConfigurations3:PutBucketQuotaConfigs3:PutBucketRetentionConfigs3:PutBucketCorss3: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 语句放行。
在代码审查中,这一单调性证明经历了两次迭代才最终严密:
- 第一次尝试(在
NotResource上翻车):最初我们只检查了statement.Resources.Match(args.BucketName, ...)而未同时检查"mybucket/"。此时如果策略使用了反向排除NotResource: ["arn:aws:s3:::mybucket/*"],在旧逻辑下"mybucket/"命中排除集因此被拒绝;而只检查"mybucket"时它不再命中排除集,结果反而从拒绝变成了放行! - 第二次尝试(在单字符通配符
?上翻车):即便不使用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)。
3.18 - SN-2026-018:客户端源地址信任边界、转发头欺骗与 aws:SourceIp 加固
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。
结论先行
_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策略限制。- “在前面挡一层反向代理”往往并不够:即便部署了反向代理,如果代理采用追加模式(如 nginx 默认的
proxy_add_x_forwarded_for)而服务端固定取X-Forwarded-For最左侧的第一个 IP,或者集群内 Pod 可以绕过 Ingress 直连ClusterIP,攻击者依然可以伪造aws:SourceIp。 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 原本按顺序依次检查三组请求头:
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 了。”事实上,在以下两种极其普遍的生产拓扑中,即便有反向代理,旧逻辑依然会被轻易击穿:
- 取最左侧
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 依然直接胜出! - Ingress 入口与集群内直连并存(Kubernetes ClusterIP):在 Kubernetes 环境中,外部流量经过 Ingress 控制器进入,但集群内的业务 Pod 往往直接访问
silo的ClusterIPService。如果服务端无条件信任任何对端发来的转发头,集群内任意一个普通 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 个隐蔽的边界问题:
第二轮审查发现
init()与MINIO_CONFIG_ENV_FILE的启动加载时序倒置:初版代码仅在包级init()函数中读取环境变量;如果管理员将MINIO_API_TRUSTED_PROXIES或_MINIO_API_XFF_HEADER写在由MINIO_CONFIG_ENV_FILE指定的配置文件中,由于该文件在serverHandleEnvVars阶段才被解析注入进程环境,init()早已执行完毕!我们在serverHandleEnvVars中显式补齐了这两个开关的二次解析与生效。- 转发链内部的
127.0.0.1跳板欺骗(X-Forwarded-For: spoofed, 127.0.0.1):区分了“TCP 对端r.RemoteAddr是环回地址”与“请求头字符串里出现127.0.0.1”(详见权衡 4)。 - 带作用域标识的 IPv6 地址(
fe80::1%eth0):链路本地(Link-Local)IPv6 对端的r.RemoteAddr包含%zone后缀;在匹配 CIDR 前先剥离 Zone 标识再调用netip.ParseAddr,避免了合法 IPv6 链路本地地址解析失败。 - 多行同名
X-Forwarded-For请求头(r.Header.Values):HAProxy 或 Envoy 等代理在追加转发信息时,可能直接追加第二行X-Forwarded-For请求头,而非用逗号拼到第一行末尾。若调用r.Header.Get("X-Forwarded-For")只会读到第一行(恰恰是客户端伪造的那一行!)。我们改为通过r.Header.Values("X-Forwarded-For")从最后一行向第一行逆序遍历。 - 内部
forwarder.go请求头清理:当不可信对端发来的X-Real-IP或Forwarded被拒绝后,内部节点间转发不应将其原样透传给下游受信任节点。 ::ffff:0:0/96网段前缀的Unmap陷阱:若对 CIDR 的网络前缀地址无条件调用Unmap(),会把/96的 IPv6 映射网段变成0.0.0.0并错误匹配所有 IPv4 地址;我们在 CIDR 解析中专门保护了映射网段的前缀长度语义。
第三轮审查发现
- 空列表必须默认安全关闭(
MINIO_API_TRUSTED_PROXIES=","):如果管理员误配了MINIO_API_TRUSTED_PROXIES=","(或纯空格),解析出的 CIDR 切片为空;若将空切片当作“未设置(信任所有)”就会导致严重 Fail-Open!现在只要显式设置了该环境变量但解析不出任何有效规则(且不为off/none/all/*),服务端启动会直接报错退出。 - 区分“未设置”与“设为空串”:使用
env.Get(EnvAPITrustedProxies, "")无法区分环境变量未设置与被显式设为MINIO_API_TRUSTED_PROXIES="";我们改为显式检查环境变量是否存在。 - 零分配逆序扫描与 100 跳上限:如果在每个请求上直接调用
strings.Split(header, ","),攻击者只需发送一个包含上百万个逗号的 1 MBX-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、审计日志、事件通知与管理追踪中全面闭环源地址归属。
3.19 - SN-2026-019:通知子系统配置键注册缺失与校验报错脱敏
SN-2026-019 涵盖了 Server 20260804(提交 162ded343 与 672c07a0f,pgsty/silo#39)修复的一类通知子系统配置缺陷:通知解析器与旧版配置迁移代码读写了未在 Default*KVS 中注册的配置键,CheckValidKeys 在校验失败时将包含敏感凭据的原始 key=value 拼入错误消息,且 FetchEnabledTargets 在遇到首个校验错误时会直接中止全部通知目标的加载。随后在 Server 20260903(f1ba68358)中,由本次 AST 审计顺带揪出的 PostgreSQL 与 MySQL 离散连接字段被正式废弃,统一收敛为 connection_string 与 dsn_string。
结论先行
- 四处独立维护的代码面发生漂移:在通知配置子系统中,
Default*KVS(合法键集合)、Help*(帮助文档与敏感级别分类)、GetNotify*(配置解析)与SetNotify*(旧配置迁移)长期靠手工同步,缺乏一致性校验。 - 单个目标报错会拖垮全局通知目标:
FetchEnabledTargets按顺序校验所有通知子系统,一旦遇到首个错误便直接返回,导致全局通知目标列表保持为nil,使正常的通知目标一并失效。 - 配置校验报错回显了敏感值:当
CheckValidKeys拒绝未知或拼写错误的键时,旧代码使用kv.String()格式化错误,将完整的key=value(含密码或凭据内容)直接输出到日志和客户端终端。 - 通过 AST 审计测试锁死不变量:Server
20260804补齐了 NATS 与 AMQP 缺失的配置键、在CheckValidKeys报错中剥离键值、扩展了HasSensitiveKeys脱敏范围,并新增编译期 AST 审计测试,强制要求cmd/config/notify读写的所有配置键必须在Default*KVS中注册。
一条令人困惑的故障报告
本次排查始于社区用户 kuldeep-link11 报告的一个离奇现象:运维人员执行 mc admin config set us notify_nats:FITCHECK address="..." subject="..." 时,服务端却报错指向了另一个完全不相干的目标 notify_nats:ONE,并且报出了两个当前命令根本没有传入的键(nkey_seed= tls_handshake_first=off):
为什么修改 FITCHECK 会报出 ONE 上从未手敲过的两个键?排查发现这是两个机制叠加的结果:
mc admin config set的分词器吞字效应:internal/config/parser.go中的kvFields仅根据DefaultKVS中已注册的键名进行分词。当用户在命令行传入未注册的键时,kvFields不会将其识别为新键的起点,而是将"nkey_seed=..."静默吞入前一个合法键(如subject)的值中。- 旧配置迁移逻辑向
notify_nats:ONE写入了未注册键:在此前版本升级或配置持久化的过程中,旧版配置迁移函数SetNotifyNATS(cmd/config/notify/legacy.go)直接构造了 KVS 结构体(绕过了kvFields分词),将nkey_seed和tls_handshake_first作为独立键写入了notify_nats:ONE。当运维人员随后修改notify_nats:FITCHECK时,validateConfig重新校验集群中存储的所有目标,结果在notify_nats:ONE上撞上了CheckValidKeys报错。
同一个常量混用在了两个命名空间
检查 internal/config/notify/target/nats.go 时,我们在 nkey_seed 旁边立刻发现了第二个隐患:
2024 年 2 月 PR minio/minio#19139 为 NATS 引入用户凭据文件支持时,作者将常量 NATSUserCredentials 放在了 Env* 常量块末尾,赋值为 "MINIO_NOTIFY_NATS_USER_CREDENTIALS",随后将其同时用作环境变量名和 DefaultNATSKVS 中的 KVS 配置键名。结果,运维人员通过 mc admin config set notify_nats user_credentials=/path/to/creds 配置时会因键名未知而被拒绝;反而是字面量 MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds 成了 DefaultNATSKVS 在持久化配置中期待的键名。
四个缺乏协同的配置面
梳理 cmd/config/notify/ 下全部十个通知目标可以发现,每个目标都分散在四个平行的代码面上:
Default*KVS(cmd/config/notify/config.go):由CheckValidKeys强制校验的合法键清单;Help*(cmd/config/notify/help.go):面向用户的帮助说明及敏感类型标注(Sensitive/Secret);GetNotify*(cmd/config/notify/parse.go):读取 KVS 配置键与MINIO_NOTIFY_*环境变量;SetNotify*(cmd/config/notify/legacy.go):将旧版 JSON 配置结构体序列化为 KVS 键值对。
在 NATS 和 AMQP 中,共有四个配置键在这四个代码面之间发生了漂移:
| 目标子系统 | 配置键 | 引入来源 | Default*KVS |
Help* |
GetNotify* |
SetNotify* |
故障表现 |
|---|---|---|---|---|---|---|---|
notify_nats |
user_credentials |
#19139 (2024-02) |
误注册为 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
缺失 | 读取 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
写入 MINIO_NOTIFY_NATS_USER_CREDENTIALS |
执行 mc admin config set 传入 user_credentials 被拒 |
notify_nats |
nkey_seed |
#21231 (2025-05) |
缺失 | 缺失 | 读取 nkey_seed |
写入 nkey_seed |
持久化配置无法通过 CheckValidKeys |
notify_nats |
tls_handshake_first |
#21008 (2025-02) |
缺失 | 已包含 | 读取 tls_handshake_first |
写入 tls_handshake_first |
持久化配置无法通过 CheckValidKeys |
notify_amqp |
immediate |
初始 KVS 迁移 | 缺失 | 缺失 | 读取 immediate |
将 cfg.Immediate 误写入 AmqpInternal! |
immediate 被拒;且 SetNotifyAMQP 用 cfg.Immediate 覆盖了 internal |
特别注意表格最后一行的 AMQP 缺陷:cmd/config/notify/legacy.go 中的 SetNotifyAMQP 存在一处复制粘贴笔误,把 strconv.FormatBool(cfg.Immediate) 写到了 target.AmqpInternal 键下,而真正的 cfg.Internal 字段则完全丢失。
爆炸半径放大器:全有或全无的目标加载机制
为什么一个未启用的 notify_nats:ONE 目标报错会造成严重后果?在 cmd/config/notify/parse.go 中,FetchEnabledTargets 会依次加载全部十类通知子系统(amqp、elasticsearch、kafka、mqtt、mysql、nats、nsq、postgres、redis、webhook)。只要其中任何一个子系统在 CheckValidKeys 中返回错误,FetchEnabledTargets 就会立即中止并返回错误,不会填充全局活跃通知目标映射表。
这意味着,哪怕某个早已停用的 notify_nats 历史条目里残留了一个未注册键,也会导致实例重启或配置重载时全局通知目标列表置空,使正在运行的 Kafka、Webhook 等所有桶通知静默中断,直到管理员手工清理该配置段为止。
我们曾评估是否要将 FetchEnabledTargets 改为按子系统隔离错误(跳过损坏的子系统、保留其余目标运行),但最终否决了这一改法:在 SILO 中,配置校验在设计上就是全有或全无的原子操作,以便 mc admin config set 能够明确拦截错误配置而非部分生效。补齐合法键注册即可消除误报,无需削弱配置校验的原子性契约。
修复键注册与敏感级别分类
提交 162ded343 修复了 NATS 与 AMQP 的键空间缺陷:
- 拆分配置键常量与环境变量常量:
target.NATSUserCredentials修正为"user_credentials",同时新增target.EnvNATSUserCredentials = "MINIO_NOTIFY_NATS_USER_CREDENTIALS"。 - 在
DefaultNATSKVS与DefaultAMQPKVS中补齐四个键:将user_credentials、nkey_seed、tls_handshake_first注册到DefaultNATSKVS,将immediate注册到DefaultAMQPKVS。 - 补全
HelpNATS与HelpAMQP:为user_credentials、nkey_seed和immediate补齐帮助项。由于user_credentials和nkey_seed接收的都是服务端本地文件路径(与client_key一致),我们将二者标记为config.Sensitive而非config.Secret。 - 修复
SetNotifyAMQP:令SetNotifyAMQP将cfg.Internal写入target.AmqpInternal,将cfg.Immediate写入target.AmqpImmediate。
对存量 NATS 配置的窄化向后兼容
如果存量集群在 20260804 之前为了绕开 #19139,已经在持久化配置中写入了字面量键 MINIO_NOTIFY_NATS_USER_CREDENTIALS=/path/to/creds,该怎么办?
如果仅仅把 NATSUserCredentials 改名为 "user_credentials",这些存量集群在升级后首次加载配置时就会被 CheckValidKeys 判定为包含非法键——从而直接触发上一节所述的 FetchEnabledTargets 全局通知中断。
在设计向后兼容路径时,我们发现了 internal/config 中两个同名函数的细微陷阱:
- 包级自由函数
config.CheckValidKeys(subSys, kvs, defaultKVS, deprecatedKeys...)接受可选的废弃键变长参数(deprecatedKeys),并在加载校验时放行这些键; KVS的方法kvs.CheckValidKeys(subSys, defaultKVS)内部调用了CheckValidKeys(subSys, kvs, defaultKVS),没有透传任何废弃键参数。
因此,我们在 NATS 加载路径(GetNotifyNATS)中改用包级函数 config.CheckValidKeys 并将 "MINIO_NOTIFY_NATS_USER_CREDENTIALS" 列入废弃键白名单,同时在解析 UserCredentials 时规定了确定的优先级顺序:
MINIO_NOTIFY_NATS_USER_CREDENTIALS环境变量(最高优先级);user_credentialsKVS 配置键;- 历史遗留的
MINIO_NOTIFY_NATS_USER_CREDENTIALSKVS 配置键(仅当user_credentials为空时兜底)。
校验报错脱敏与敏感键加固(SN-2026-019)
在 20260804 之前,无论是 config.CheckValidKeys 还是 KVS.CheckValidKeys,在拼接非法键报错信息时都会直接调用 kv.String()(即输出完整的 key=value)。如果管理员在设置配置时不小心拼错了敏感键名(例如把 password 敲成 pasword=Hunter2,或把 auth_token 敲成 auth_tokn=eyJ...),又或者旧版迁移函数写入了未注册的凭据键,报错信息就会把明文密码原样回显到终端响应和服务端错误日志中。
提交 162ded343 修改了这两个 CheckValidKeys 函数,使其在报错时仅输出键名(kv.Key),不再输出 kv.Value。
随后在提交 672c07a0f 中,我们进一步审查了用于在 mc admin config get 导出和健康诊断包(health-diagnostics)中掩码敏感值的 config.HasSensitiveKeys(internal/config/config.go),将其匹配范围扩展至通知、身份认证与 KMS 子系统使用的全部凭据键名(password、auth_token、secret_key、client_secret、nkey_seed、user_credentials、sasl_password、connection_string 与 dsn_string)。
用 AST 审计测试锁死注册不变量
为了从根源上杜绝 Default*KVS、parse.go 与 legacy.go 再次出现手工同步遗漏,我们在 cmd/config/notify/config_audit_test.go 中编写了基于 go/ast 的静态审计测试。该测试会解析 parse.go 和 legacy.go 的抽象语法树,追踪每一处 kvs.Get(...) 和 kvs.Set(...) 调用所引用的 internal/config/notify/target 字符串常量,并断言这些键全部已在对应的 Default*KVS 中注册。
在对审计测试本身复核时,我们发现并堵上了初版测试的两个盲区:
- 反向覆盖与数量下界断言:如果只断言
parse.go ⊆ Default*KVS,万一未来语法树结构变化导致 AST 遍历器一个键都没提取出来,空集也是任何集合的子集,测试会假阳性通过!因此我们加上了反向检查,要求Default*KVS中注册的每一个键(除enable、comment等通用键外)都必须在parse.go中被读取,并为每个子系统设置了提取键数量下界。 - 无类型复合字面量(Untyped Composite Literals):在
legacy.go中,部分迁移函数并非调用kvs.Set(),而是直接用无类型复合字面量{Key, Value}构造config.KVS{...}切片。当我们把 AST 遍历器扩展到检查config.KVS复合字面量后,测试立刻在现有代码库中抓出了另一处潜伏缺陷。
AST 审计顺藤摸瓜抓出的问题:PostgreSQL 与 MySQL 离散连接键
一旦 AST 遍历器开始检查 legacy.go 中的 config.KVS 复合字面量,它当即报出 SetNotifyPostgres 与 SetNotifyMySQL 存在未注册键写入:这两个函数各自写入了 host、port、username、password、database 五个离散配置键,且 GetNotifyPostgres / GetNotifyMySQL(parse.go)在 connection_string / dsn_string 为空时也会读取这五个键,但它们完全没有在 DefaultPostgresKVS 和 DefaultMySQLKVS 中注册!
更糟糕的是,正因为 password 未在 DefaultPostgresKVS 或 DefaultMySQLKVS 中注册,在 162ded343 之前,任何传入 password=... 或触发旧版 PostgreSQL/MySQL 配置迁移的操作都会撞上 CheckValidKeys,并在错误消息中把数据库明文密码直接打印出来。
我们分两步在两个版本中彻底解决了这一历史包袱:
- Server 20260804(
672c07a0f)短期止血:在DefaultPostgresKVS和DefaultMySQLKVS中补注册host、port、username、password、database(并在HelpPostgres/HelpMySQL中将password标记为Secret),确保存量配置与新的 AST 审计不变量立即自洽。 - Server 20260903(
f1ba68358)架构清理:同时维护两套数据库连接配置方式(connection_string与离散的host/port/username/password/database字段)会导致SetNotifyPostgres和SetNotifyMySQL在配置读写往返时用空的离散字段覆盖掉合法的connection_string。因此 Server20260903从Default*KVS、Help*、parse.go与legacy.go中彻底退役了这五个离散字段,统一收敛为使用connection_string(PostgreSQL)与dsn_string(MySQL)。
明确不做项
- 不在本补丁中改动
kvFields分词边界规则:若将kvFields改为按任意key=value切分而非仅识别DefaultKVS中的已知键,会改变值内部包含未加引号空格(例如 SQL 语句或通知主题)时的解析行为。在通过 AST 审计保证Default*KVS完整注册后,合法配置键均可被kvFields正确识别。 - 不削弱
FetchEnabledTargets的快速失败校验:如爆炸半径放大器所述,配置校验在所有子系统间保持原子性。
审查记录
代码审查验证了 AST 审计测试的反向覆盖下界断言、无类型 config.KVS 复合字面量扫描、CheckValidKeys 报错脱敏(162ded343)以及 HasSensitiveKeys 敏感凭据键覆盖(672c07a0f)。
总结
随 Server 20260804(162ded343 与 672c07a0f)发布并在 Server 20260903(f1ba68358)完成离散键收敛后,所有通知子系统配置键均由 AST 静态测试锁死在 Default*KVS 中,且非法键报错不再回显配置值。
3.20 - CompleteMultipartUpload 重复分片号漏洞分析与修复
在 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)。
结论先行
- 缺陷真实存在且边界清晰:
CompleteMultipartUpload使用sort.SliceIsSorted配合<比较器,实际验证的是非递减序(a[0] <= a[1] <= ...)而非严格递增序(a[0] < a[1] < ...)。传入[1, 1]这类重复分片号会返回成功,并在最终对象中将对应分片重复拼接。 - 属于数据完整性缺陷,而非 CVE 安全漏洞:触发该行为的前提是调用方已经拥有目标对象的
s3:PutObject权限,并持有正在进行的分段上传的合法uploadId。它不跨越租户、桶或凭据边界。 - 修复收敛在 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> 分片列表:
在 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 替换为显式的相邻元素检查即可消除该盲区:
两次重构为何都保留了这一缺陷
通过 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
我们评估了恶意调用方能否利用重复分片号制造存储空间放大攻击:
- 无直接物理落盘放大:在纠删码存储层,
CompleteMultipartUpload只是在xl.meta的分片元数据数组中记录条目并指向磁盘上已有的part.1数据文件,不会在完成阶段按条目复制底层数据块。虽然读取该对象会流式读取两次part.1,且后续后台修复(healing)或复制会按逻辑大小物化数据,但拥有写权限的调用方本就可以通过正常上传两个分片或调用UploadPartCopy达到同样的效果。 - 仅限调用方自身权限范围:调用
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 实现中重复植入,原因有两点:
- 单一不可信入口:由客户端控制的完成清单只会从
CompleteMultipartUploadHandler进入。系统内部调用方(如batch-replicate、restore、decommission、rebalance等)均直接基于已持久化的ObjectInfo.Parts或按1..n顺序循环构造分片列表。 - 在获取命名空间锁之前快速失败:在 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 开始”的限制(要求
Parts[0].PartNumber == 1):立即被稀疏分片回归测试([3]与[5, 9])捕获。 - 删掉
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 对空分片切片执行安全早退保护。
4 - 设计归档
设计归档记录 PGSTY SILO 的核心工程决策:缺陷根因、S3 与 IAM 协议不变量、备选方案取舍、运维边界与回归测试索引。
2026-10-24 FULL_OBJECT 校验和复制
- FULL_OBJECT 校验和复制:在初始化上传时保留类型(
#254、#255):解释遗漏类型为何让有效的分片 CRC 对象复制失败,共享请求头的一处修复如何同时恢复已有 CopyObject 路径,以及发送端升级与验收边界。
2026-10-24 多池删除与放置决策
-
多池删除:失败时保住最新副本(
#256、#257):解释在线磁盘预检查为何挡不住执行中的 I/O 故障,分阶段清理如何保住每项的权威副本,以及已合并修复的重试、分层引用、性能和兼容性边界。 -
多池覆盖亲和:为何最终没有采纳 #245(
#245):记录原始放置问题、请求级 quorum 与健康判断改进、仅限普通无版本覆盖的收窄设计、两轮 m3 性能实测,以及小对象覆盖仍未达标后的停止决定。实验修改没有合并。
2026-10-23 IAM 与 KMS 设计记录
- LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界(
#250):解析NormalizeDN的\xx十六进制转义为何触发对象 IAM 后端的单路径分量 255 字节上限,以及为何仅将物理上无法落盘的纯 ASCII 超长分量归类为errConfigNotFound。 - IAM 策略重载:锁粒度、请求取消与超时预算隔离(
#251):在保留writeMu写串行锁的前提下将慢速策略加载与 KMS 解密移出内存鉴权缓存锁,将取消信号贯通至KES.Decrypt,并将etcd单步读取超时与整批解密预算解耦。
Server 20261018 核心设计记录
围绕 Server 20261018 正式版的核心架构改动,请参阅:
- Object Lock 治理保留期与覆写防护(
SN-2026-015、SN-2026-016) - IAM Admin 凭据授权边界(
SN-2026-017) - 持久化 IAM 撤销与按范围撤销 STS(
SN-2026-013) - LIST 恢复锁开销优化
- ListMultipartUploads 回退决策与容量边界
- 多池对象一致性与迁移保护
- KES 与 OpenBao 集成
4.1 - SILO、KES 与 OpenBao:密钥归属和恢复
SILO 通过 PGSTY KES(MINIO_KMS_KES_ENDPOINT)接入外部密钥管理系统。当前最新版本为 KES 20261026(使用 Go 1.27.3 构建并更新云服务依赖,保持本文的 API、配置与密钥格式不变);此前的 PGSTY KES 20261018(源码 2f7e741db905)完成了将 AWS Secrets Manager 后端迁移至 AWS SDK for Go v2 的主体改造。
三个服务的职责边界
| 服务 | 负责内容 | 调用对象 |
|---|---|---|
| SILO | 对象字节流与对象加密元数据;加解密对象内容 | 通过 HTTPS(mTLS)调用 KES 生成或解密数据加密密钥(DEK) |
| PGSTY KES | 应用身份认证、密钥访问策略与密钥 API;封装或解封装数据密钥 | 通过 OpenBao KV v2 保存或读取根主密钥 |
| OpenBao | KES 根主密钥的持久化记录、后端访问控制与自身恢复材料 | 所配置的持久化存储后端 |
MINIO_KMS_KES_ENDPOINT 必须指向 KES 服务端点,因为 OpenBao 本身不实现 KES 密钥封装 API。KES 通过内置的 keystore.vault 配置访问 OpenBao(针对 OpenBao KV v2 挂载点需设置 version: v2)。同一个 kes 可执行文件同时提供服务端与管理 CLI,多个无状态 KES 实例可共享同一个外部 OpenBao 密钥存储。
SILO 将加密后的数据密钥(DEK)随对象元数据保存在存储桶中。对象存储桶本身不包含外部根密钥备份;一旦丢失 OpenBao 中的根密钥,即便磁盘上的所有对象纠删分片完好无损,已加密对象也将无法解密恢复。
对接 OpenBao KV v2 的最小 KES 配置示例:
针对名为 secret、前缀为 kes 的 KV v2 挂载点,KES AppRole 所需的最小 OpenBao ACL 策略:
身份认证与故障行为
部署时应分别配置 SILO → KES 与 KES → OpenBao 两段链路的 TLS 信任。KES 依据客户端证书的 SHA-256 摘要(kes identity of <cert>)授权 SILO 访问指定密钥名;OpenBao 则通过 AppRole 授权 KES 后端身份。
| 层级 / 配置项 | 故障表现 | 运维处置 |
|---|---|---|
| OpenBao 处于封存(Sealed)或中断状态 | 内存热缓存可能允许部分操作短暂继续,但未命中缓存的密钥请求或重启后的新 SILO 进程会立即失败(Fail-Closed),绝不会降级为明文存储。 | 在启动或滚动重启依赖 KMS 的 SILO 节点前先解封 OpenBao;执行恢复验证时必须重启进程清空热缓存并实际回读对象。 |
| KV v1 与 KV v2 配置不匹配 | 对 KV v2 挂载点配置 version: v1(或 OpenBao ACL 策略未同时授权 data/ 与 metadata/ 路径)会在创建或列举密钥时返回 403 permission denied 或 404。 |
显式设置 engine: <mount>、version: v2,并在 OpenBao 策略中同时授权 <mount>/data/<prefix>/* 与 <mount>/metadata/<prefix>/*。 |
| 客户端证书轮换 | 轮换 MINIO_KMS_KES_CERT_FILE 会改变客户端证书的 SHA-256 Identity。若新 Identity 未提前加入 KES 策略,SILO 启动自检将失败。 |
轮换证书前先在 KES 策略中同时保留旧、新两个 Identity;待所有 SILO 节点切换至新证书后再移除旧 Identity。 |
请妥善保管持久化密钥库备份、KES 配置、TLS 信任根、身份与策略配置以及 OpenBao 解封恢复材料,并在演练中验证恢复后的密钥名称与密钥值完全一致。KES 开发模式(内存模式)仅将密钥保存在进程内存中,严禁用于持久化加密存储。
SDK 迁移与兼容性
PGSTY KES 20261018(2f7e741db905)将 keystore/aws/secretsmanager 从已归档的 github.com/aws/aws-sdk-go(v1)迁移至模块化的 AWS SDK for Go v2(github.com/aws/aws-sdk-go-v2、config、credentials、service/secretsmanager 与 github.com/aws/smithy-go)。
迁移保持以下兼容性不变量:
- 完整保留既有密钥存储格式与 YAML 配置字段。
- KES YAML 中显式配置的凭据继续优先;留空时回退至 SDK 标准凭据链。
- KES 不会隐式加载
~/.aws/config,且配置文件中的endpoint始终优先于 SDK 端点环境变量覆盖。 - AWS Secrets Manager 的
ResourceNotFoundException、ResourceExistsException与参数错误保持与原内部错误(errKeyNotFound、errKeyExists)相同的映射语义。
兼容性验收矩阵(testing/kms-compat.sh)覆盖了 OpenBao KV v2 与 Moto Secrets Manager 的 SDK-v1 -> SDK-v2 -> 回滚 全流程切换,每次切换均重启 SILO 以清空内存密钥缓存并验证加密对象回读。
升级与恢复检查
- 记录当前 SILO 与 KES 版本、主密钥名称、后端端点及客户端证书 Identity,并在确认恢复成功前保留既有密钥与后端备份。
- 变更前读取一个既有加密对象并记录其预期摘要(Digest);基于同一持久化后端将 KES 升级为 PGSTY KES 20261026 二进制。
- 重启 KES 与 SILO(清空热缓存),读取旧加密对象、写入新加密对象并校验两者摘要,验证生产环境实际使用的 TLS 身份与访问策略。
- 在隔离演练环境中,基于同一密钥存储恢复旧版 KES 二进制,重启两项服务并重复验证加密对象回读。
分步配置与运维命令详见配置 KES 与 OpenBao 服务端加密指南、KES 参考配置以及 KES 兼容性测试脚本。
4.2 - 写死 TLS 参数与握手兼容性
Go TLS 默认值修复 48e184652 随 Server 20260916 发布后,issue #154 的报告人在该版本复测,OIDC discovery 仍然报同一条 connection reset by peer。与此同时,上游 Go 社区的同类 issue 将完全一致的症状定位到检查 signature_algorithms_cert 扩展并发送重置的 TLS 中间设备(见上游的同款案例)。本文在《Go 1.27 TLS 默认值与 OIDC Discovery 故障形态》的基础上,补充发布后的复测与机制分析、20260916 修复的覆盖边界、受影响部署的可选解法,以及由此确立的发布与沟通门禁。
先把本质说清楚
TLS 连接的第一条消息是 ClientHello,客户端在其中列出自己支持的算法。许多企业网络会在链路中部署中间设备(WAF、TLS 检查防火墙、负载均衡器)解析这条消息。部分设备在遇到不认识的算法编号时会直接重置连接。
Go 1.27 在这条消息中新增了三个编号——ML-DSA 后量子签名方案——共计 12 个字节。使用 Go 1.27 编译后,SILO 发出的 TLS 1.3 ClientHello 比 Go 1.26 多出这 12 个字节。报告人 IdP 前方的中间设备拒绝了该报文并返回 TCP RST。同一容器内的 curl 之所以正常,是因为 curl 使用 OpenSSL,不会发送这些编号。
Go 针对混合密钥交换提供了兼容开关 GODEBUG=tlsmlkem=0,但该开关有一个生效前提:仅当应用未显式指定曲线清单(Config.CurvePreferences == nil)时生效。 SILO 此前保留了一份从上游继承的显式曲线清单,其中恰好包含一个后量子条目,导致 GODEBUG=tlsmlkem=0 失效。48e184652 修复移除了这些硬编码曲线清单,使该开关恢复生效。
需要注意两者的区别:GODEBUG=tlsmlkem=0 只能关闭后量子密钥交换(ML-KEM),无法关闭后量子签名算法声明(ML-DSA),而 Go 1.27 新增的 12 个字节正是 ML-DSA。Go 标准库未提供关闭 ML-DSA 的开关。如果中间设备是因为 ML-DSA 编号而重置连接,仅移除显式曲线清单无法消除这 12 个字节——这与报告人的复测结果完全吻合。
Go 到底在解决什么问题
后量子密钥交换需要提前迁移。 在“现在截获、未来解密”(harvest-now-decrypt-later)威胁模型下,攻击者可以先录制当下的加密流量,待未来出现具备密码学破解能力的量子计算机后再行解密。因此,密钥交换的机密性迁移必须在量子计算机实用化之前完成。NIST 于 2024 年正式发布 ML-KEM(FIPS 203)与 ML-DSA(FIPS 204),当前部署采用混合模式:X25519MLKEM768 同时运行经典 X25519 与 ML-KEM 并合并双方共享密钥,即便 ML-KEM 存在未知弱点,连接安全性也不低于单独的 X25519。主流浏览器、CDN 与 SSH 实现自 2024 年起已默认启用混合密钥交换,Go 在 1.24 中跟进。
握手签名的时间表则不同。 握手签名无法被事后追溯伪造——未来的量子计算机无法令 2026 年已完成的握手失效。因此目前 TLS 1.3 中的 ML-DSA 仅用于在握手中声明客户端支持验证 ML-DSA 证书。Go 1.27 将 MLDSA44、MLDSA65、MLDSA87 加入默认签名算法列表,新增的 12 个字节正来源于此:
这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到 TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。
这个开关本来就不管显式清单
暴露 SILO 写死曲线列表的那处变更,在 Go 1.27 发布说明中有明确记载:
Post-quantum hybrid key exchanges can now be explicitly enabled in
Config.CurvePreferenceseven if thetlsmlkem=0ortlssecpmlkem=0GODEBUG options are used. Those options were always meant to only apply to the default set used whenConfig.CurvePreferencesis nil.(后量子混合密钥交换现在可以在
Config.CurvePreferences中显式启用,即使设置了tlsmlkem=0或tlssecpmlkem=0。这些选项本来就只适用于Config.CurvePreferences为 nil 时所用的默认集合。)
标准库自 Go 1.24 起就把两条路径写成二选一:
代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。
其中的原则是显式配置应当压过环境变量——否则运维改一个环境变量,就能悄悄推翻开发者写在代码里的安全策略。Go 1.26 及更早版本会把显式清单也过滤一遍,因此 SILO 在 Go 1.26 上「开关有效」其实是依赖了一个 Go 认定的错误行为。
此外,Go 把 GODEBUG 的默认值绑定在主模块的 go 指令上,声明旧版本的模块会保持旧行为直到主动上调。SILO 的 go.mod 写的是 go 1.27.1,等于明确声明采用 1.27 的行为。
SILO 的技术债在哪,以及哪些还没还
出问题的清单是 {X25519MLKEM768, CurveP256, X25519, CurveP384, CurveP521},继承自上游,后来被加入了那个后量子条目。写死它带来的是两头不靠:既享受不到标准库默认值的演进,又用不了标准库提供的兼容开关。
48e184652 在全部 8 个 Server TLS 配置点移除该赋值——外部通用 HTTP transport、复制 transport、带客户端证书的云 transport、节点间 transport、两条 grid 链路、etcd,以及入站 S3/Console 监听器——退役 TLSCurveIDs helper,并补充了跨 5 个出站构造函数、2 个对端 TLS 版本、2 种 GODEBUG 设置的线上级回归测试。证书与主机名校验、密码套件策略、代理处理、HTTP/2 选择均未改变,也没有引入任何失败后降级或重试。此前只改 OIDC 的候选补丁被放弃,是因为同一个 transport 还服务身份插件、通知与 Lambda 可达性检查、审计 webhook 和 S3 云分层。
同一类债在下一层依然存在。 密码套件仍然写死:crypto.TLSCiphers() 与 crypto.TLSCiphersBackwardCompatible() 被赋值在出站 transport、LDAP、etcd、grid 链路和入站监听器上。监听器至少还有 MINIO_API_SECURE_CIPHERS 可以在两套之间切换,所有出站路径一个开关都没有。
本设计确立的通用准则:设 MinVersion,其余交给标准库。 如果确有不兼容的对端需要更窄的配置档位,把它做成产品配置——在 mcli admin config 与支持包里可见——而不是写死在源码里。协议僵化是个老问题:TLS 1.3 不得不在线上伪装成 TLS 1.2,GREASE 的存在正是为了逼中间设备容忍自己不认识的编号。
线上真正的差异
在同一个合成 IdP 夹具前测量,默认设置下:
| 构建 | ClientHello | ML-KEM (4588) | ML-DSA 0x0904-6 | User-Agent |
|---|---|---|---|---|
| 20260804,Go 1.26.5 —— 正常 | 1497 B | 有 | 无 | MinIO (…) |
| 20260903,Go 1.27.1 —— 故障 | 1509 B | 有 | 有 | Silo (…) |
| 20260916,Go 1.27.1 —— 已修复 | 1509 B | 有(默认) | 有 | Silo (…) |
20260804 + tlsmlkem=0 |
275 B | 无 | 无 | MinIO (…) |
20260903 + tlsmlkem=0 |
1509 B | 仍然有 | 有 | Silo (…) |
有两点容易被忽略,但至关重要:
- 能正常工作的版本本来就在发 ML-KEM。 除非运维在升级之前就设过
tlsmlkem=0,否则 ML-KEM 不是新旧版本之间的变量,恢复这个开关也就无法单独解释或修复他们的故障。 - 默认设置下,两个版本之间只有两处变化: 三个 ML-DSA 编号,以及改名带来的 HTTP User-Agent 变化(
MinIO (…)→Silo (…))。后者只有在 RST 发生于握手完成之后时才成立。
影响面,以及症状为什么具有误导性
写死的清单覆盖了所有 TLS 路径的两个方向,但后果差别很大:
| 路径 | 入口不兼容时的后果 | 可见度 |
|---|---|---|
| OIDC discovery / JWKS | 身份初始化阻塞,Console 不启动,节点不提供任何服务 | 立刻致命 |
| KMS/KES、LDAP(S) | 加密或目录认证不可用。这两处本来就用默认曲线,GODEBUG 一直有效 | 立刻可见 |
| 跨站复制目标 | 复制静默积压,只能从指标看出来 | 容易漏 |
| 审计 / 通知 webhook、Lambda 检查 | 审计记录丢失、事件不投递 | 容易漏 |
| S3 分层 / 云后端 | 转冷失败,远端对象读不回 | 容易漏 |
| etcd、节点间 grid | 集群内部流量,一般不经过这类设备 | 低 |
| 入站 S3/Console 监听器 | 客户端连不上;由对端的握手决定 | 取决于客户端 |
只有身份路径会阻塞启动,因此它最先被报告。其余几条会安静降级,所以一个部署完全可能已经受影响而无人提单。
哪些部署有暴露面:
| 情形 | 原因 |
|---|---|
| 出站链路上有 SSL 检查 NGFW、SASE、SWG 或 IDS/IPS | 本次报告的场景,也是已确认存在厂商缺陷的那一类 |
| 出口经过会检查 TLS 的企业 VPN | 同一机制,作用于整条出口 |
| 环境按 JA3/JA4 客户端指纹做白名单 | 握手一变指纹就变,每次工具链升级都可能触发,且同样表现为无提示的重置 |
| 端点前面是老式 TLS 终止器或负载均衡器 | 不容忍未知扩展,或对跨分片的 hello 处理有问题 |
| 从用 Go ≤ 1.22 构建的上游 MinIO 版本迁移过来 | 那份握手既没有后量子密钥交换也没有 ML-DSA——约 275 字节对约 1509 字节。这个群体跨度最大,暴露面也最大 |
| 政策上不允许后量子算法的合规环境 | 需要经典档位是出于政策,而非互操作原因 |
出站链路上没有检查设备的部署不受影响,也不应设置下文任何兼容选项。
四个特性叠加,使这个症状在现场几乎无法诊断:身份初始化以随机 0–3 秒间隔重试;discovery 与 JWKS 抓取没有总超时,启动可以无限期挂住;身份离线期间 /minio/health/live 与 /minio/health/ready 都保持 200,Kubernetes 就绪探针会报成功(只有 /minio/health/cluster 返回 503 并携带 X-Minio-Server-Status: iam-offline);而错误文本 read: connection reset by peer 不说明 TLS 握手是否完成。此时从同一镜像跑 curl 还会成功,因为 curl 用的是另一套 TLS 栈并协商了 HTTP/2。
20260916 自身带来的变化
移除写死清单不会恢复此前的线上格式,而是采用当前 Go 默认值,而它更大:
| 版本 | 实际提供的 supported_groups |
|---|---|
| 20260903 及更早(写死) | X25519MLKEM768, X25519, P256, P384, P521 |
| 20260916(Go 默认) | X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519, P256, P384, P521 |
因此对于什么都不设置的运维,20260916 会在每一个 TLS 端点(入站与出站)多提供两个后量子编号。对绝大多数部署这无害,但一个按 group 编号做白名单的入口,理论上可能在 20260903 正常而在 20260916 失败。GODEBUG=tlssecpmlkem=0 可以只关掉这两个并保留 X25519MLKEM768。而对于确实设置了 tlsmlkem=0 的运维,20260916 产生的握手比此前任何版本都更小更保守——这正是修复的目的。
三种机制,一个决定性事实
调查复现了三种都能产生所报错误文本的机制,而 20260916 修复覆盖其中一种。
| 分支 | 机制 | 20260916 是否覆盖 |
|---|---|---|
| A | 入口拒绝 ML-KEM,且运维在升级前就设过 tlsmlkem=0 |
是——修复恢复的正是这条 |
| B | 入口拒绝 ML-DSA 签名编号 | 否。只有 TLS 1.2 上限能抑制该 offer,而产品没有暴露这个设置 |
| C | HTTP 层规则在握手成功之后拒绝变更后的 Silo User-Agent |
否,任何 TLS 改动都与之无关 |
还有第四项观察解释了 curl 对照为什么具有误导性:discovery transport 禁用 HTTP/2 且不发 ALPN,而 curl 协商了 h2。这个差异在正常版本和故障版本里都存在。
一个事实能把 A/B 与 C 分开,夹具结果能把 B 与 A 分开: RST 是紧跟 ClientHello 到达,还是在 GET 写出之后才到达。在故障网络位置做一次抓包,或者拿到入口在同一秒的拒绝原因,就能定案。
上游的同款案例
分支 B 不是假设。上游 Go 有两条 issue 描述了它:
- golang/go#81199,标题即 “add a GODEBUG to disable advertising ML-DSA signature algorithms in the ClientHello (Go 1.27 regression against TLS-inspecting middleboxes)",2026-08-28 至今 open。所报症状与 #154 完全一致:经过 TLS 检查防火墙的所有 TLS 1.3 连接
read: connection reset by peer,而同一台主机上的curl、OpenSSL 3.6、Node.js 24 正常,同一程序用 Go 1.26 编译正常,设置MaxVersion: tls.VersionTLS12也正常。其中点名的产品是 Palo Alto Prisma Access,PAN-OS 10.2.10-h37;Go 团队的回复指出该产品已有修复版本。 - golang/go#79626 已关闭,但其中有完整诊断:一位网络管理员与本单位防火墙团队一起定位到,Palo Alto 的一条 IDS/IPS 威胁特征——针对 OpenSSL CVE-2020-1967,该 issue 中记为 PA 威胁 ID 58033,最后更新于 2022-07-12——会检查
signature_algorithms_cert扩展,并对其算法列表不符合预期的连接发送重置。Palo Alto 已于 2026-06-23 发布内容更新禁用该特征。
CVE-2020-1967 当年正是通过畸形的 signature_algorithms_cert 触发的空指针解引用;厂商为它写的检测规则对合法的现代握手触发了重置。这也精确解释了那 12 个字节:Go 1.27 把三个 ML-DSA 编号同时加进了 signature_algorithms 和 signature_algorithms_cert,即两个扩展各 3 × 2 字节。
Go 不会提供开关。 安全负责人在 #81199 的立场是:不可能为 ClientHello 的每一处底层变化都加 GODEBUG;以往加开关的是会改变协商结果的变更;而增加一个签名算法本不应该改变任何东西。提出的 tlsmldsa 设置(golang/go#81307)尚未合入,此外 Chrome 预计将开始对 signature_algorithms_cert 做 GREASE,这会让剩余的问题设备更快暴露。
对 SILO 有两个直接结论:
- SILO 不能依赖上游新增
GODEBUG开关:针对受分支 B 影响的部署,需要在 SILO 自身提供显式的出站 TLS 兼容配置档位。 - 排查出站链路上的 TLS 检查设备:更新防火墙厂商的威胁特征库即可在网络侧直接消除根因。
全部可选解法
按「谁来动手」分组,每一行只解决特定分支:
入口侧——唯一完整的解法。
| 做法 | 分支 | 代价 |
|---|---|---|
| 更新中间设备——威胁特征库与固件都算——使其容忍不认识的算法编号 | A、B、C | 需要设备归属方配合。对已确认的产品,内容更新已经存在,因此这是最短路径 |
绕开它:把 MINIO_IDENTITY_OPENID_CONFIG_URL 指向不经过该设备的端点,或使用 split-horizon DNS。discovery 文档中的 issuer 必须保持不变 |
A、B、C | 证书主机名与 issuer 一致性必须成立 |
| 用 sidecar(stunnel、Envoy)自行终止 TLS 并连接 IdP | A、B、C | 多一个组件及其信任链 |
走 HTTPS_PROXY |
无 | CONNECT 隧道原样转发同一个 ClientHello;只有自行终止 TLS 的代理才有意义 |
部署侧——今天就能用。
| 做法 | 分支 | 代价 |
|---|---|---|
GODEBUG=tlsmlkem=0 |
A | 进程级关闭混合密钥交换,含节点间链路与入站监听器;证书校验不受影响 |
GODEBUG=tlssecpmlkem=0 |
20260916 新增的两个 group | 更窄;保留 X25519MLKEM768 |
GODEBUG=fips140=on |
A 与 B | 不推荐。 FIPS 白名单里没有 ML-KEM 与 ML-DSA,但它同时替换密码套件与曲线,并对整个进程排除 Ed25519/X25519 |
| 停留在 20260804 | A、B、C | 放弃后续全部安全修复;仅作应急 |
在入口放行 Silo User-Agent |
C | 一条规则改动 |
目前无法通过 GODEBUG 关掉 ML-DSA。 Go 没有为签名算法提供 GODEBUG——internal/godebugs/table.go 中 crypto/tls 的条目只有 fips140ems、tlsmaxrsasize、tlsmlkem、tlssecpmlkem、tlssha1——tls.Config 也没有签名算法字段。唯一的杠杆是版本门:这些编号只在 TLS 1.3 下定义,把 MaxVersion 压到 TLS 1.2 即可抑制。
为什么不回退工具链
用 Go 1.26 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。但它仍然是错误的手段,有五条理由:
- 用整个产品的工具链,去替中间设备的缺陷买单。 而那台设备的厂商已有修复。
- 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布,1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
- 上游保持新默认值。 Go 已明确拒绝增加开关,Chrome 也计划对同一扩展启用 GREASE。
- 四个核心仓库均已要求 Go 1.27。 Server、Console、
mcli和silo-pkg均声明go 1.27.1,使用 Go 1.26.7 编译当前源码会被模块要求拒绝,且传递依赖也已要求 1.27。 - 只覆盖单一分支。 如果连接重置源自分支 C 的 HTTP 层规则,工具链版本根本无关。
保持用 Go 1.27 编译而仅下调 go.mod 中的 go 版本指令同样无效:该机制仅控制绑定了 GODEBUG 的行为,而 ML-DSA 声明没有对应的 GODEBUG 开关。
对于正在等待网络设备更新特征库的受影响环境,在过渡期内暂时停留在 20260804 镜像是合理的应急措施。
由此确立的需求
- 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过
httptrace记录连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节,并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为,且可以用一条日志代替抓包来区分 A、B、C 三条分支。 - 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。必须 opt-in、启动时打出醒目警告,并在
mcli admin config与支持包中可见。MINIO_API_SECURE_CIPHERS是既有先例。这是分支 B 唯一的进程内解法。 - 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消,并明确就绪检查是否应当反映身份状态。
- 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数,以及 classical / TLS 1.2 / User-Agent 对照,使运维可以直接定位这类故障。
- 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。
有两个选项被明确否决:
- 发布一个降低了的默认值——在
go.mod写godebug指令,或在镜像里塞ENV GODEBUG=…——会悄悄削弱所有连接的后量子保护,而且仍然解决不了分支 B。 - 握手被 RST 后自动降级重试等于把「注入一个 RST 就能把连接压到 TLS 1.2 加经典曲线」交给主动攻击者;浏览器正是因为这个原因移除了这类 fallback。
发布与沟通门槛
这次修复的兼容前提写在 Server README 的 TLS 小节、Go 1.27 TLS 与 OIDC,以及 20260916 发布说明 中。但在 issue 处置中有三件事没有做到位:
- 未在 issue 中直接说明该修复需要配合设置
GODEBUG=tlsmlkem=0才会生效,以及它针对的是哪条故障分支。 - 该配置要求仅作为变更记录写入说明,而未作为受影响环境升级时的显式操作项列入升级检查清单。
- issue 在补丁合并时即被关闭,未等待报告人复测确认。
当一项修复依赖运维人员显式配置环境变量才能生效时,必须按兼容变更流程执行以下三条门禁:
- 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
- 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭;补丁合并只改变状态标签,不改变 issue 状态。
- 修复无法覆盖所报症状的全部可能分支时,issue 中必须写明:处理的是哪条分支、还剩哪些分支、以及具体需要什么定位证据。
归属
本文在 #154 调查与九月 Go 1.27 栈评审的基础上,补充发布后的复测分析、上游 Go 社区案例、解法空间与流程门槛。Go 行为以 Go 1.27.1 标准库源码与官方发布说明为依据。
4.3 - 桶配置复制:来源时间、删除与确定性收敛
#77 是已在 Server 20260916(PR #180,提交 48ec10312)中修复的站点复制正确性缺陷。修复前,接收站点将对端事件的来源时间改写为本地到达时间(UTCNow),导致后续真正较新的删除请求因早于本地到达时间而被当作旧事件拒绝;同时部分配置在删除后不再导出时间戳,断连期间遗漏的删除也无法通过自愈(heal)恢复。仅补一个 DELETE 分支无法同时解决这两类问题,确定性收敛必须同时具备来源时间保留、持久化删除墓碑(tombstone)、规范化内容比较键以及整桶元数据锁。
既有工作与本轮范围
此前的发布说明、20260903 安全加固记录与 Server 兼容性说明已概述 #77 的删除收敛限制。本文完整记录其状态模型、方案取舍与验证证据(原始实验记录归档于 docs/investigations/issue-77.md)。
| 已有工作 | 已解决的问题 | #77 补齐的剩余缺口 |
|---|---|---|
| PR #91 | 每站点配置计数、Policy/Quota 统计与畸形字段隔离 | 计数一致不代表配置内容与来源时间已收敛 |
| PR #103 | 整个 .metadata.bin 记录的写入串行化 |
仅在写盘阶段加锁,无法保证锁外作出的新旧判断在拿到锁时仍然有效 |
| #76、#78 | Object Lock 复制载荷与已有桶接管保护 | 未建立覆盖六类配置的统一来源状态比较规则 |
| CORS 复制修复 | 桶级 CORS 的独立删除记录与复制信任边界 | 仅针对 CORS 设计,其语义不能直接套用到全部元数据类型 |
本修复处理 Policy、Tags、SSE、Quota、Versioning 与 Object Lock 六类配置。Lifecycle/expiry 保持其合并载荷时间语义;CORS 保留独立机制;notification、IAM、对象复制可靠性、MRF、resync 与公开统计各自独立维护。
复现确立的故障事实
原始缺陷在 ErasureSD 和 16 盘 Erasure 两种对象层上均稳定复现:源站在过去时刻 $T$ 写入配置,接收端落盘时却写入当前到达时间;随后收到源时间为 $T + 1\text{ 分钟}$ 的删除事件时,接收端因该时间早于刚才记录的到达时间而将其拒绝。因此单看 RPC 返回成功,并不能保证各站点最终状态一致。
| 配置类型 | 修复前 PUT 保留来源时间 | 空事件的既有语义 | 修复前无载荷时导出时间 |
|---|---|---|---|
| Policy | 否 | 删除 | 是 |
| Tags | 否 | 删除 | 否 |
| SSE | 否 | 删除 | 否 |
| Quota | 否 | 删除;零值 JSON 另有语义 | 否 |
| Versioning | 否 | 不操作 | 不作为删除使用 |
| Object Lock | 否 | 不操作 | 不作为删除使用 |
排查过程中还定位了三个必须在入口层修复的问题:
- 较旧的批量(bulk)事件可以覆盖较新的单字段配置。
- 请求在锁外完成新旧判断后再排队等待
metadata.lock,拿到锁时仍按已过期的判断覆盖新状态。 - Tag 的远端 heal 请求漏传了
UpdatedAt时间戳。
因此修复不能只改动 heal 的选源条件,必须覆盖所有真实写入与同步入口。
字段状态模型与确定性排序
状态模型复用 .metadata.bin 中既有的字段载荷、字段 UpdatedAt 和桶 Created,不新增磁盘格式、SDK 字段或持久化 deployment ID。
| 状态 | 判定条件 | 能否作为复制来源 |
|---|---|---|
| 未知或无效 | 创建时间未知、载荷非法,或字段时间早于 Created |
否;缺失或损坏的信息不能当成删除 |
| 空基线 | 空载荷,时间为零或等于 Created |
否 |
| 有值基线 | 合法非空载荷,时间为零或等于 Created |
是;用于历史配置初始化 |
| 真实更新 | 合法非空载荷,时间晚于 Created |
是 |
| 真实删除 | 可删除字段的空载荷,时间晚于 Created |
是;该带时间戳的空值即删除墓碑(tombstone) |
Versioning 和 Object Lock 的空事件继续保持无操作,不会因共用排序辅助函数而获得删除语义。零时间字段在比较时使用 Created 作为基线,不会把历史空值误判为一次新删除。
状态比较按以下四条规则依次裁决:
- 真实状态(
UpdatedAt > Created)优先于基线状态。 - 真实状态之间,来源时间
UpdatedAt较晚者胜出。 - 来源时间相同时,删除墓碑优先于有值载荷。
- 同级且同时间的有值冲突,按规范化内容键的字典序裁决,字典序较大的键胜出。
对于带时间戳的对端事件和 heal,当计算出的有效状态与磁盘已有状态完全相同时,接收端既不写盘也不触发事件通知;本地写入则即使可见载荷不变,也始终分配新的单调递增修订时间。有值基线之间同样按内容键排序,防止较晚创建的默认状态压过真实修改。字典序决胜保证多站点在并发冲突下收敛到同一结果;若业务期望特定配置,管理员仍应在冲突后在权威站点重新提交期望值。
比较键必须与落盘行为一致
复制状态统计、heal 选源与对端应用必须比较与持久化完全一致的表示:
- Policy: Go 中策略集合由 map 支撑,直接 JSON 编码会受 map 遍历顺序影响。服务端对已校验策略的完整 JSON 树(
Statement、Action/NotAction、Resource/NotResource、Principal、Condition)执行递归排序,并保留Sid和数字精度。此外,既有策略解析器支持NotAction与NotResource,但原结构体必填Action/Resource的编码会对空集合报错。修复统一采用显式字段编码,并同步应用于GetBucketPolicy、管理导出和对端复制,既防止含负集合的合法策略“写得进、读不出”,也保证状态统计与 heal 使用完全一致的比较键。 - Quota: 使用解析结果的 JSON 编码作为比较键。
{}、JSONnull或合法零配额文档仍是有值文档。旧版发送端曾将零配额PUT转为对端删除而在本地保留文档;对于mcli quota clear发送的零配额,升级后各站点统一保留该有值零配额文档,容量限制同样处于关闭状态。空 Policy 则遵循既有删除语义:合法的空策略PUT清除策略,后续GET返回NoSuchBucketPolicy。 - XML 配置(Tags、SSE、Versioning、Object Lock): 直接使用有效 XML 文档的字节切片作为比较键,不引入通用 XML 规范化器。Versioning 是唯一需要预处理的例外:比较前必须先应用 Object Lock 约束(启用 Object Lock 时强制
Enabled),再比较最终会落盘的有效文档;否则比较器接受未开启版本控制的文档而Save落盘时将其改写,会导致下一轮 heal 无限重复发送。
在同一把锁内完成决策与落盘
六类字段共同存储在一份 .metadata.bin 中。读取原始记录、校验载荷、比较时间戳、修改字段和写盘保存必须全部位于同一把 metadata.lock 锁内:若先在锁外比较、再进锁内写盘,并发写入下仍会使用过期状态;若拆成按字段加锁,整条记录的读改写又会互相覆盖。
本地写入在 metadata.lock 锁内分配单调时间:
该规则使本地纠正操作能够超过已保存的未来字段时间。带来源时间的对端事件保留其原始 UpdatedAt;完全重复或较旧的带时间对端事件在锁内直接返回,不产生磁盘写入。
批量(bulk)路径仅处理显式提供的字段,逐项校验通过后至多保存一次:省略字段保持不变,显式 null 按类型删除语义处理,任一字段非法都会在落盘前中止整批更新。导入(import)在每桶最终提交锁内为涉及的六类字段分配统一提交时间,磁盘状态和出站复制事件均使用本次提交返回的归一化快照,不在解锁后重新读取存储拼接旧时间。空 Policy 通过专用删除事件发送,避免被批量结构的 omitempty 丢弃。
桶接管不能制造假删除
桶接管(adoption)可能调整桶的 Created 时间戳。若把 Created 改早,却保留空字段原本等于旧 Created 的默认时间戳,空基线就会因 UpdatedAt > Created 被误判为真实删除;若把 Created 改晚,又可能使历史初始配置因 UpdatedAt < Created 变成无效状态。
修复在既有接管锁内,仅将这六类字段中原本为零或等于旧 Created 的默认时间同步调整为新 Created,真实更新和真实删除的时间戳保持不变。
所有入口共用同一套状态规则
| 入口 | 行为契约 |
|---|---|
| 本地 S3/Admin 写入 | 在 metadata.lock 内分配单调时间;由提交快照生成出站事件 |
| 专用对端事件 | 在 metadata.lock 内比较并保存原始来源时间;保留旧 Object Lock 载荷兼容 |
| Bulk/import | 显式区分字段存在性并原子保存;在最终提交锁内统一分配导入时间 |
| 首次同步 | 通过物理磁盘探测补齐缺失的 Created;保留历史有值基线;开关开启后补发真实删除 |
| 本地与远端 heal | 先过滤有效候选再选取最大状态,包含完整来源时间;未知 ID 与故障对端不阻断健康目标 |
| 状态导出 | 值与时间取自同一快照;新增删除时间受滚动升级开关控制 |
首次同步保留既有五类配置发送路径,Versioning 仍通过 MakeBucketHook 初始化并由 heal 对齐。当桶元数据未记录 Created 时间戳时,GetBucketInfo 与 ListBuckets 一致地返回物理目录探测时间,首次同步将该恢复时间传给建桶钩子并落盘,避免无元数据记录的旧桶无法写入配置。
heal 不再先取 map 第一项作为胜者再过滤默认时间,而是先筛选有效候选再取最大状态。公开 mismatch 计数不再作为唯一门控:内容相同但来源时间不同仍需同步;反之,最终有效状态相同时不再重复写盘或发送 RPC。
为什么需要默认关闭的升级开关
启动环境变量 MINIO_SITE_REPLICATION_METADATA_TOMBSTONES 默认为 off,用于控制无载荷的 Tags、SSE、Quota 删除墓碑时间是否在状态接口与首次同步中向外导出。
| 行为 | off(默认) |
on |
|---|---|---|
来源时间排序与 metadata.lock 原子应用 |
启用 | 启用 |
| 普通实时删除事件复制 | 启用 | 启用 |
| Policy 的既有删除时间导出 | 保留 | 保留 |
| Tags/SSE/Quota 无载荷时的真实删除时间导出 | 隐藏 | 导出 |
| 首次同步补发真实删除 | 仅 Policy | 补齐全部四类可删除字段 |
旧版节点无法安全消费全部新增的删除墓碑信息——例如旧版 Quota heal 会在清空原始载荷后保留已解析的内存缓存值。默认保持 off 能够安全隔离滚动升级窗口。
协调升级与降级步骤
- 将所有参与站点的全部节点升级至 Server 20260916 或更高版本,确认同站点配置一致并排空在途请求。
- 在全部节点统一设置
MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=on并重启。 - 如需回滚到旧版本,先在全部节点设置
MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off并重启,再滚动降级软件二进制。
在 off 模式下,被隐藏的 Tags、SSE、Quota 删除墓碑可能导致对端在 heal 时重复发送旧状态并由已升级接收端拒绝;只有全站点统一开启 on 且状态稳定后,后续 heal 周期才会达到零元数据 RPC。
保留与否决的候选方案
| 设计决策 | 工程依据 |
|---|---|
| 保留统一的内部六字段排序辅助函数 | 六类配置复现了完全相同的到达时间覆盖缺陷;共用比较器可消除各入口规则漂移,同时按类型区分删除能力 |
| 保留现有整桶锁、持久化字段与 heal 周期 | 分别承担原子性、删除持久性与断连恢复职责,无需引入外部协调服务 |
不止于把 UTCNow 替换成来源时间 |
仅替换时间戳仍会留下锁外过期判断、无载荷删除丢失、等时冲突与首次同步缺口 |
| 用默认关闭的开关门控新增墓碑导出 | 旧接收端与旧 Quota 缓存路径在混合版本下不安全,必须显式隔离滚动升级窗口 |
| 不以 deployment ID 决胜,也不引入 HLC 或新磁盘 schema | 既有时间戳配合规范化内容键已能保证确定性收敛,无需改动磁盘持久化格式 |
| 不一刀切拒绝零时间专用事件 | 旧版 Tag heal 确实漏传时间戳;以本地单调时间兼容零时间事件可保持协议平滑过渡 |
| 不把所有桶元数据泛化为同一种删除语义 | Versioning 与 Object Lock 不可删除,而 Lifecycle 与 CORS 拥有各自的合并与删除机制 |
生产代码共新增 729 行、删除 692 行 Go 代码(净增 37 行),主要以统一的状态比较器替换原先重复的各类型应用与 heal 分支。每一项保留机制都对应已复现的故障模式:去掉整桶锁会重现跨字段读改写覆盖,去掉规范化 Policy 比较键会在负集合策略上造成永久假 mismatch,去掉升级开关则会在滚动升级期间击穿旧版节点的 Quota 缓存。
验证与测试证据
位于 cmd/site-replication-metadata_test.go、cmd/site-replication-metadata-heal_test.go 与 cmd/site-replication-metadata-gate_test.go 的回归测试套件在 ErasureSD 和 16 盘 Erasure 两种对象层上验证了完整契约:
| 验证范围 | 验证结论 |
|---|---|
| 来源时间保留、四类可删除配置、重复与乱序事件、锁前排队、跨字段并发写入 | 回归测试与 -race 竞态检查在两种对象层上全部通过 |
等时双向到达顺序、删除优先于有值、Policy 规范键与负集合 GET、bulk/import 原子性 |
不同到达顺序收敛至完全相同的最终状态 |
| 创建时间物理探测恢复与旧顺序策略状态比较 | 真实磁盘对象层测试在每块盘上标记桶目录时间,验证无元数据记录的桶可正确恢复 Created,且乱序 Statement 策略不再触发假 mismatch |
| 两个真实站点进程实验(每站 4 个数据目录) | 历史六类配置首次同步保留 Created;丢弃真实 PUT 的出站 RPC 并注入乱序事件后,正常 30 秒 heal 周期恢复一致 |
| 断连遗漏删除与源站重启 | 四类配置的删除墓碑落盘后跨源进程重启保留,恢复连接后自动收敛 |
| 稳态零 RPC 与诊断日志去重 | 连续两个 65 秒观察窗口(各含两个正常 heal 周期)无多余元数据 RPC;重复异常按“桶/字段/原因”独立去重 |
在测试复核中,早期使用桩对象(stub)伪造创建时间探测的测试被替换为操作真实本地盘桶目录时间戳的测试——确保该用例在叠加修复前生产代码时必定失败,仅在真实恢复链路生效时通过。
实现复核与演进记录
从基线 5c5765816 与初版实现 1ee64a8d8 出发,复核发现的三处问题(真实磁盘创建时间恢复、旧顺序 Policy 状态键与 heal 诊断去重)已在 62cf066ff 与 fcbb93e89 中补齐,最终在 461e9a721 完成测试格式整理并通过全量 cmd/internal 测试与 lint 检查。
运维边界与已知限制
- 历史时间污染无法自动还原。 升级前被本地到达时间覆盖的配置和无时间戳旧事件已经丢失原始来源时间。部署升级后,应核对各站点配置并在权威站点重新提交期望的配置或删除操作。
- 零时间专用事件继续兼容。 不带时间戳的专用对端事件会分配本地单调时间并记录限频
legacy-zero警告;bulk 导入的零时间校验约束保持不变。 - 桶创建世代冲突不自动合并。 早于目标桶
Created的事件不予应用(记录before-created)。缺失Created的桶仅从真实物理桶目录修改时间恢复近似值;若已存在桶的物理创建时间仍为零,配置写入返回500 InternalError(缺失桶返回404 NoSuchBucket)。在配置写入或首次站点同步将该恢复时间落盘前,周期性 heal 会在两个方向上跳过该桶。 - 物理时钟不是因果时钟。 本地单调时间允许管理员在未来时间戳之后继续发起本地修正,但跨站点并发写入若与字典序决胜结果不符,仍需人工确认并重新提交。
- 诊断按桶、字段与原因有界去重。
legacy-zero、before-created、indeterminate、unreachable与peer-error复用LogOnceIf以 Warning 级别输出并按小时清理;空基线以及尚未拥有该桶的对端保持静默,而损坏的存量配置即使无可用来源也会输出indeterminate警告且不触发出站 RPC。
4.4 - SigV4 签名头覆盖边界:拒绝未签名的 x-amz-* 请求头
本文记录 SILO 的未签名请求头覆盖修复(核心提交 123325430,经 PR #173 合入,安全通告编号 SN-2026-011)以及 PR #177 中的后续签名与载荷校验修复(安全通告编号 SN-2026-012)。上述修复已随 Server 20260916 发布。该漏洞由 Oren Yomtov 报告,并在本地预签名与 Authorization 请求头两条 SigV4 路径上复现。
核心结论(TL;DR)
预签名 PUT URL 可以只对 host 一个请求头签名。此前 SILO 只校验签名头列表(SignedHeaders)中声明的请求头是否已到达,却未反向检查请求实际携带的全部请求头,导致列表之外的 x-amz-* 请求头被直接接受并参与业务处理。与此同时,cmd/api-router.go 仅凭请求中存在 x-amz-copy-source 头,就会将任意 PUT 请求分发至 CopyObjectHandler。两处逻辑叠加后,原本仅授权写入单个对象的预签名 URL,可被攻击者转化为以签名者身份读取该密钥可访问的任意对象的服务端复制操作(混淆代理,confused deputy)。当 Authorization 头签名请求未将 x-amz-copy-source 纳入 SignedHeaders 时,同样会触发该问题。
本次修复确立了一条核心不变式:
这一行为与 AWS S3 一致——AWS S3 对携带未签名 x-amz-* 头的同类请求同样返回 AccessDenied(“There were headers present in the request which were not signed”)。
缺陷机制:单向覆盖缺口
cmd/signature-v4-utils.go 中的 extractSignedHeaders 遍历签名头列表,按名称逐一从请求头(或 query 参数)中提取对应值。这仅验证了签名声明的请求头均已存在,却遗漏了反向检查:实际收到的每一个 x-amz-* 请求头是否都包含在签名头列表中?
此前代码中唯一遍历实际到达请求头的函数是 checkMetaHeaders,但它仅匹配 X-Amz-Meta- 前缀,且只在预签名路径(doesPresignedSignatureMatch)中调用;Authorization 头验签路径(doesSignatureMatch)则完全没有对应检查。因此,未签名的 x-amz-copy-source(或任何其他影响操作语义的 x-amz-* 请求头)在两条路径上均能畅通无阻:
本地复现结果:对照组普通 PUT 返回 200 且响应体为空;对同一预签名 URL 附加一个未签名的 x-amz-copy-source 头后,请求同样返回 200,但响应体变为 CopyObjectResult(其 ETag 即为源对象的 MD5),从目标对象回读即可拿到源对象的完整内容。若目标存储桶本身允许匿名 GetObject,攻击者无需任何凭据即可直接读取被复制出来的私有对象。
历史溯源
该缺陷继承自上游 MinIO。cmd/signature-v4-utils.go 中的 SigV4 验签逻辑与 cmd/signature-v4.go 中的 doesSignatureMatch 可追溯至 2016 年;cmd/api-router.go 中基于请求头分发 CopyObject 的路由规则可追溯至 2019 年。上游曾在 2023-07-27 通过 minio/minio#17737(提交 535f97ba6)引入 checkMetaHeaders,当时已注意到未签名请求头需与签名集合保持一致,但将该检查局限在 X-Amz-Meta- 前缀与预签名路径,遗漏了 x-amz-copy-source 等操作控制头以及整条 Authorization 头验签路径。
在本次修复前,SILO 对 cmd/signature-v4-utils.go 仅做过一行依赖导入路径调整(9b11dc946 将 policy 包导入迁移至 pgsty/silo-pkg/v3)。核心修复(123325430)与 PR #177 的后续加固完整闭合了这一边界。
修复设计
修复将 checkMetaHeaders 重命名为 checkUnsignedHeaders,将匹配前缀从 X-Amz-Meta- 扩展至整个 X-Amz- 命名空间,并在预签名与 Authorization 头两条路径上统一调用。任何未被签名集合覆盖的 x-amz-* 请求头,都会在进入业务 handler 之前被直接拒绝并返回 ErrUnsignedHeaders。
以下四项设计决策界定了具体的检验边界:
检查集合成员资格
原先的 checkMetaHeaders 采用 signedHeadersMap.Get(k) == val[0] 比较值是否相等。对于不在签名映射表中的请求头,Get 会返回空字符串 "";一旦攻击者构造首个元素为空串的多值请求头(例如 X-Amz-Copy-Source: ["", "/src/secret"]),val[0] 同样为空串,就能绕过相等比较并将第二个未签名的复制源路径注入后端。修复改为直接检查键是否存在于 signedHeadersMap 中:只要请求头已纳入签名列表,其具体取值自然受签名摘要保护,验签阶段只需判定集合成员资格。
豁免 X-Amz-Content-Sha256
客户端发送 X-Amz-Content-Sha256 时可以不将其列入 SignedHeaders,因为有效载荷哈希已作为独立字段写入 SigV4 规范请求(Canonical Request)。预签名请求优先取 query 参数中的哈希值,仅在 query 未提供时回退读取请求头,显式的 UNSIGNED-PAYLOAD 同样受支持。PR #177 将 IAM 策略条件求值统一对齐到这一有效哈希值(同时保留判断请求头是否存在的语义),并在通用认证路径中补齐了仅通过请求头携带哈希的预签名请求正文摘要校验,确保签名绑定、策略评估与正文校验使用同一个值。
直接从签名日期计算签名年龄
初版修复曾豁免验签后写入内部请求头的临时字段 x-amz-signature-age,但 PUT 和 UploadPart 的授权评估发生在流式验签之前,此时该内部头尚未生成。PR #177 改为直接根据已受签名保护的 X-Amz-Date 计算 s3:signatureAge,并彻底移除了内部临时头、相关常量及豁免分支。伪造日期会直接导致验签失败,客户端自行传入的同名未签名头则会被拦截;同时验签过程不再修改请求头映射,多次调用保持幂等。
将 X-Amz-Tagging 注入移至验签之后
PutObjectTaggingHandler 会从请求正文 XML 中解析并合成 X-Amz-Tagging 请求头供 IAM 策略条件评估读取,而原先这一注入发生在调用 authenticateRequest 之前。将未签名头检查扩展到全部 X-Amz- 前缀后,这个由服务端合成、客户端从未签名的请求头会被误判为未签名头而遭到拒绝。修复将注入时机调整至签名验证通过之后、策略授权评估之前,既不影响策略条件读取标签,又保持了验签边界的纯净。我们否决了“像豁免 content-sha256 一样全局豁免 X-Amz-Tagging”的替代方案——因为一旦全局豁免,客户端就能在任意签名或预签名写入请求上附加未签名的 X-Amz-Tagging 头来篡改对象标签。
跨签名模式的适用范围
Authorization头签名与预签名 SigV4: 两条路径均已强制执行checkUnsignedHeaders。- 流式 SigV4(Streaming SigV4): 种子签名校验器未调用
checkUnsignedHeaders。Copy 系列 handler 通过常规认证分发直接拒绝流式认证,而流式PUT/UploadPart的请求头覆盖属于独立边界。 - SigV2: 不受影响。SigV2 规范化算法在构造待签字符串(string-to-sign)时默认纳入全部
x-amz-*请求头,任何额外附加的x-amz-*头都会改变计算出的签名并触发签名不匹配错误。
状态码权衡:400 与 403
AWS S3 对未签名请求头返回 403 Forbidden,而 SILO 沿用上游 ErrUnsignedHeaders 定义返回 400 AccessDenied。两者的 S3 错误码字符串均为 AccessDenied,仅 HTTP 状态码不同。若将 cmd/api-errors.go 中的状态码改为 403,虽然只需修改一行代码,但会同步改变既有的未签名 x-amz-meta-* 请求头返回码。为避免在安全补丁中引入额外的状态码兼容变更,此处保持 400 AccessDenied 不变。
回归测试
代码中原有的若干单元测试会先构造签名请求,在完成签名之后再手动追加 x-amz-copy-source、x-amz-copy-source-range 或 x-amz-metadata-directive 请求头。这些测试已更新为在设置完操作控制头后重新调用 signRequestV4 签名(signRequestV4 会自动将已有的 Authorization 头排除在签名集合外,因此可安全重签)。新增单元测试覆盖了 checkUnsignedHeaders 的空首值多值头拦截、X-Amz-Content-Sha256 豁免、旧版未签名 x-amz-signature-age 头拒绝,以及对同一预签名请求连续验签两次的幂等性测试 TestPresignedVerifyIdempotent。
验证证据
- 在真实构建的服务端实例上,复现脚本在预签名与
Authorization头两条路径上均确认了修复前的混淆代理行为,并在修复后验证两者均被拒绝;与此同时,对照组的普通PUT、标准minio-goCopyObject、携带用户元数据与标签的PutObject以及基于请求正文的PutObjectTagging均正常通过。 go test ./cmd/全量通过,gofmt、gofumpt与go vet检查无告警。- 首轮代码审查指出的三处边界问题(临时请求头破坏验签幂等性、空首值绕过相等检查、未签名
x-amz-content-sha256误拒)均已修复并通过回归测试验证。 - 第二轮复核确认重签后的既有测试保持原有测试意图:无效 AccessKey 仍返回
InvalidAccessKeyId,错误的 SSE-C 密钥在签名校验通过后仍返回403。
兼容性与运维影响
- 标准客户端: 零影响。遵循 SigV4 规范的客户端与 SDK 会自动将发送的非豁免
x-amz-*请求头纳入SignedHeaders。 - 附加未签名
x-amz-*头的客户端: 请求将被拒绝并返回AccessDenied,与 AWS S3 行为一致。 - 滚动升级: 线协议与底层存储格式无变化。升级过程中,已升级节点立即强制执行新边界,未升级节点在完成替换前仍存在暴露窗口。
- 版本回滚: 新版本写入的数据可被旧版本正常读取,但回滚服务端二进制会重新暴露该混淆代理漏洞。
关联修复与延伸原则
- 安全通告编号: 未签名请求头覆盖修复对应
SN-2026-011,PR #177 中的相邻签名与载荷校验修复对应SN-2026-012。 - HTTP 状态码: 上述
400与403的状态码对齐留待后续大版本评估。 - 相邻签名修复: PR #177 一并消除了重复
x-amz-copy-source请求头的解析歧义、修正了签名年龄的授权评估时序与载荷哈希策略条件取值,并补齐了仅通过请求头携带哈希的预签名请求正文摘要校验。回归测试覆盖了普通签名与预签名请求、上传验签前的策略评估以及真实 HTTP 存储桶策略防篡改验证。 - 通用设计原则: 凡是允许请求语法改变服务端操作语义的控制项,都必须在进入路由或业务分发前确认其已受签名保护,且验签所绑定的值与业务处理器实际消费的值必须严格一致。
总结
SigV4 验签不仅要确认签名头列表声明的请求头悉数到达,还必须反向确认实际收到的每一个 x-amz-* 请求头都已纳入签名列表。在业务处理器执行前拒绝未签名的 x-amz-* 请求头,从根源上闭合了普通签名与预签名 SigV4 路径上的混淆代理漏洞。虽然签名校验改动本身不涉及存储格式变更,但升级至 Server 20260916 同时包含需要协调升级的 IAM 变更。
4.5 - Object Lock 复制排序
Object Lock 复制时间戳排序的初始修复(f4c1286c9)已随 SILO Server 20260903 发布(收录于安全公告台账)。针对仅携带时间戳的删除(墓碑)、SSE-C 副本完整重传以及跨存储池 Object Lock 状态合并的后续修复(PR #129、PR #134 与 PR #178)已随 SILO Server 20260916 正式发布。
在重建目标元数据前捕获已存状态
此前,副本 COPY 在比较 Object Lock 保留期(Retention)与法务锁定(Legal Hold)修订时间戳之前,会先根据入站请求重建目标端元数据映射表。这导致目标端原有的修订时间戳在比较前即被覆盖丢失,使迟到的旧复制请求能够覆盖目标端较新的锁定状态;此外,旧逻辑还误将法务锁定的时间戳写入了保留期的时间戳键名下。
提交 f4c1286c9 改为在重建目标元数据映射表之前先捕获已落盘的 Object Lock 状态,并将每个字段的修订时间戳独立保存在各自的内部元数据键中。
保留期(mode + retain-until-date)与法务锁定(status)是两个彼此独立的状态寄存器:
- 较新的保留期时间戳不会使同一请求中较旧的法务锁定生效(反之亦然);
- 对象数据本体的修改时间(
ModTime)不能替代任一字段自身的显式修订时间戳; - 仅当入站字段的时间戳严格新于目标端已存储的对应字段时间戳时才应用更新;对于更旧或时间戳相等的入站更新,一律保留目标端已存状态。
将删除操作同样视为带时间戳的有序状态
清空对象的保留期或解除法务锁定后,字段本身不再包含有效值,但仍携带产生该变更的源端时间戳。如果因为字段值为空就丢弃对应的时间戳,网络中延迟到达的旧复制请求就会将已移除的旧保留期或旧法务锁定重新写回。
PR #129 与 PR #134 在复制接收、时间戳比对与重新发送路径中完整识别并传递“仅含时间戳的空值删除”,严格区分“从未设置过该字段(无排序证据)”与“在指定时刻显式清空了该字段(有时间戳的删除)”。
接收端在普通元数据 COPY 与完整的 SSE-C 副本对象重传路径中均保持这一语义:即便因 SSE-C 重传而重写整个对象密文数据流,也不会抹去目标端已有的更新法务锁定或回退目标端更新的保留期。
跨存储池权威状态合并
当同一对象版本在多个存储池中存在物理副本时(例如扩容或退役迁移期间),仅读取单个本地纠删集无法决定最新的字段状态。多池一致性协调层(PR #178,关闭 #133)会在共享命名空间对象锁下收集所有存储池中的同版本副本,并按各自的时间戳分别合并保留期与法务锁定状态。不可读或未达法定人数的存储池会直接报错,而不会被视为空状态。
授权与运维边界
时间戳排序仅用于在已获授权的副本写入上裁决新旧冲突,本身不会授予修改 Object Lock 的权限;复制信任边界校验与相应的 S3/管理端权限(s3:ReplicateObject、s3:BypassGovernanceRetention、s3:PutObjectRetention、s3:PutObjectLegalHold)依然强制执行。该机制建立的是基于源端时间戳的逐跳字段顺序,不提供跨站点未同步时钟之间的因果全序,也不提供部分磁盘故障后的分布式事务回滚。
升级至包含修复的版本可阻止旧复制事件再次覆盖较新的锁定状态,但无法自动重建升级前已被旧事件覆盖的历史状态。运维人员在确认恢复前,应结合权威记录核对各站点精确版本的保留期与法务锁定状态。
验证与参考
cmd/erasure-server-pool-consistency.go 中的 Object Lock 合并实现与 cmd/erasure-object.go 中的副本写入路径已通过新旧字段更新、空值删除墓碑、独立字段时间戳、SSE-C 重传与多池场景回归测试验证。
请结合副本元数据审计手册、SSE-C 副本完整性记录以及组件版本矩阵使用,并在依赖完整排序契约前协同升级所有参与复制的节点。
4.6 - SSE-C 副本完整性
本文记录由 #122、#123、#124、#126 和 #134 共同构成的 SSE-C 副本完整性契约(随 Server 20260916 发布),与此前在 Server 20260903 中发布的零字节/属性读取认证及目标密钥 checksum 响应修复衔接。
密文与信任
普通 SSE-C 请求提供客户密钥,操作明文。经过授权的副本传输可以携带原始密文和保留原对象所需的密封对象密钥元数据。目标必须原样存储这些字节,再加密一次会产生不可读的双重加密对象。内部标记本身不是授权:仍需精确复制标记和要求的复制权限。
该路径不会泄露客户密钥,也不会允许普通无密钥读取。普通 GET/HEAD 与 GetObjectAttributes 保留各自密钥/权限检查。联邦原始 SSE-C 副本 COPY 明确不支持。
分段大小与校验和
加密分段大小与逻辑明文分段大小不同。副本多段记录必须保留每段实际逻辑大小,使覆盖后的 partNumber 读取、范围读取和 GetObjectAttributes 与源一致。Checksum 必须保留正确加密上下文和逻辑含义;对象已用另一把目标密钥提交后,不能仍用源密钥解密响应 checksum。
普通 SSE-C 密钥轮换只要显式请求 checksum 算法,就走完整重写,即使算法与原值相同。多段源成为单段目标,ETag 可能变化,复制重传对象字节。不带该请求且满足元数据原位更新条件时,保留原 checksum 状态,包括缺失状态。详见操作步骤。
重传与 Object Lock
目标无密钥 HEAD 可能把已存在的 SSE-C 对象报告为不可访问,而不是不存在。发送方须区分它与 NoSuchKey,不能假设普通元数据 COPY 能修好副本。已存在 SSE-C 副本走对象重传。旧副本状态无法解码时,可通过修复后的重传路径替换,同时保留目标更新的 Object Lock 状态,并正确排序删除时间戳。
升级 Server 不会自动修复已损坏的历史副本。审计存量副本时,应使用批准的密钥访问流程重新读取精确源/副本版本,比较逻辑字节和分段边界,并另行检查保留期及 legal hold,不能仅凭元数据 HEAD 成功推断完整性。
压缩与历史对象
SILO 排除所有新 SSE-C 写入的压缩,包括普通 PUT、多段初始化、COPY 和 Snowball。SSE-S3/SSE-KMS 仍服从加密压缩选项。这避免生成传送密文却缺少所需压缩元数据的副本格式。
历史压缩 SSE-C 对象不会自动重写。应保留密钥和精确版本身份,清点影响范围并演练受支持的重写/恢复路径。
验证边界
压缩判定、replication-trust-ssec-replica_test.go、erasure-multipart-ssec-replica_test.go、replication-ssec-retransmit_test.go 和 compression-ssec_test.go 固定了相关源码及回归契约,涵盖错误密钥/未授权对照、多段字节比较和重传场景。
相关内容见 Object Lock 排序、多池一致性及副本恢复。依赖组合行为前须升级所有参与 Server。
4.7 - 持久化 IAM 撤销
SILO 保留已删除 IAM 记录的版本(Revision),使离线站点在重新连接后无法将已删除的旧身份或旧授权恢复为有效状态。该机制覆盖内置用户、服务账号、组、策略文档,以及用户、STS 父身份和组各自命名空间中的策略映射。内置用户被删除后,即使管理员有意重建同名用户,此前签发的旧服务账号、STS 临时凭证与组授权也不会因此复活。
持久化 IAM 撤销架构(PR #191、#192,提交 f99ed829b5eba549160725f035156c9e020b6a07)已随 Server 20260916 发布并关闭 SN-2026-013;针对直接 RevokeTokens 的按范围 STS 撤销扩展(0cd388c66)已随 Server 20261018 发布。多站点协调升级步骤见 IAM 升级与恢复手册。
十月十八日:按范围撤销 STS
Server 20261018 中的提交 0cd388c66 将九月持久化撤销机制扩展到直接 RevokeTokens 操作。
在旧实现中,节点可能漏掉某次签发通知,进而在执行撤销时只清理自身内存缓存中已有的令牌。修复后,RevokeTokens 会读取持久化的 STS 身份记录并与内存缓存合并,按操作截点(Cutoff)筛选指定父身份及可选令牌类型,为每个命中的不可变令牌 key 提交独立的删除墓碑(Tombstone)。即使批次中某个 key 保存失败,所有已提交的 key 仍会立即通知同集群兄弟节点并发送给站点复制对端,同时向调用者返回保存错误以便重试。
私有 IAM 复制消息携带令牌删除标记与过期时间。即使撤销消息先于创建消息抵达,墓碑仍能阻止随后的重放;带有更晚事件时间戳的创建消息也无法复活这一不可变令牌。既有删除自愈(Deletion Healing)机制负责离线重投。对象存储和 etcd 的完整 IAM 刷新会重建 STS 修订记录——即使并发 IAM 写入推迟了整表缓存替换,也会先从活动缓存中剔除已撤销条目。墓碑保留至令牌自然到期时间加上时钟偏差容限;若过期时间未知则采取保守保留。
单次撤销快照覆盖请求集群在截点之前已知的 STS 身份;在孤立站点签发且从未复制到本集群的令牌不在该快照内。截点之后新签发的 key、无关用户及可复用的父身份策略映射不受影响。所有参与节点须协调升级,旧版本节点未实现新增的私有 STS 复制消息。
执行开销: 管理端撤销会枚举持久化的 STS 身份并按父身份与类型过滤,其读取量与临时映射内存随活跃令牌数量线性增长。扫描期间会释放鉴权缓存锁,但仍持有 IAM 写串行锁,因此其他并发 IAM 写操作可能短暂等待。etcd 既有的周期性刷新也会读取 STS 记录。普通缓存命中鉴权不增加任何网络调用,也未引入新的后台定时器或外部依赖。
回归测试位于 iam-sts-revocation_test.go,覆盖三站点断连/重启恢复以及四进程共享 IAM 存储场景。升级后应逐端点验证旧令牌被拒绝、新令牌继续有效以及节点重启后的状态恢复,详见升级流程。
排序与持久化
删除记录保存在原 IAM 配置路径下,包含源时间戳、Deleted 标志以及必要的 RevokedBefore 边界;身份删除记录不保存 secret key 或 session token。常规 IAM 列表查询与策略求值自动隐藏已删除记录。对象存储与 etcd 均通过分布式锁串行化每个配置路径的版本比较与写入;接收端直接持久化源时间戳,绝不将其替换为接收节点的本地时钟。
版本演进遵循以下确定性规则:
- 旧事件不能覆盖新版本;相同时间戳下,删除记录优先于活记录。
- 有意的本地同名重建会获得比已存储删除记录更新的版本号。
- 若旧的用户或组删除消息在同名重建之后才延迟到达,系统会更新并保留其
RevokedBefore撤销边界,同时完整保留较新的重建活记录。 - 重复收到已应用的 tombstone 不会触发重写或推进版本号。
- 节点冷启动加载持久化 IAM 状态时同样执行上述合并规则。
用户或组的版本写入是删除操作的权威提交点,策略映射与子凭证清理在其后执行。即使后续附属清理失败,也不会回滚已提交的父身份撤销;API 会向调用方返回清理错误,但仍会通知同后端兄弟节点重载已提交的撤销状态。因此,管理接口返回 HTTP 500 清理错误并不代表旧身份仍然有效。管理员可在暂停同名重建与其他 IAM 变更的前提下安全重试该删除操作。当发生“撤销已提交但附属清理失败”时,Admin Handler 不会触发即时跨站点推送钩子,而是交由常规后台删除自愈循环(Deletion Healing)重试跨站点传播。同后端兄弟节点收到删除通知后会重读当前共享状态,因此延迟到达的通知不会误删随后合法重建的同名身份;etcd 部署下兄弟节点还会通过 watch 机制接收持久化变更。
运维上必须保持各节点时钟同步并监控时钟偏移。版本排序基于源端墙钟时间戳,并对同一路径的本地连续写入保证单调递增,但不跨站点建立并发写入的因果偏序。
用户、子凭证与组
同名重建后的用户会保留历史 RevokedBefore 边界。新创建的服务账号与内置 STS 临时凭证均携带经加密签名的 siloParentRevocation 声明(Claim),记录签发时刻已知的父身份撤销边界。编辑或重放旧子凭证不会更新这一签名声明;因此即使旧子凭证自身的更新时间晚于父身份删除时间,鉴权时仍会被判定为失效,而针对重建后父身份新签发的子凭证则正常有效。凭证加载与写入时均从已验签的令牌中提取该声明。
在站点复制中,版本号更新的服务账号快照可以替换相同 AccessKey 的旧服务账号(包括有意更换所属父用户或轮换密钥),而本地直接重复创建相同 AccessKey 仍会被拒绝。复制快照会保留禁用状态(Disabled)与服务账号自身的撤销边界,防止旧策略映射重新附着到新服务账号上。相同版本号的重试仅触发重新加载已提交身份而不重复落盘。周期性活记录自愈(Live Healing)会直接比较源版本号(即使公开状态摘要未发生变化),并将已禁用身份一并纳入自愈源。
服务账号快照同时保留绝对过期时间,接收站点不会将本地新签发凭证的最短有效期规则重新套用到复制快照上。版本号更新但已过期的快照仍会覆盖旧密钥、在鉴权时被拒绝,并在常规加载流程中被归集为持久化服务账号墓碑。若缓存或声明加载失败,接收端会直接返回错误以触发重试,而不会提前确认成功;一旦替换提交完成,内存中陈旧的缓存密钥会被立即驱逐。若复制快照与现有内置用户或已缓存 STS 身份发生 AccessKey 类型冲突,系统会拒绝静默转换凭证类型并报错,交由管理员显式处置。
用户组的每个成员都拥有独立的 MemberGrants 授权时间戳。增删其他成员或切换组的启用状态不会刷新既有成员的授权时间。成员关系唯有在其 MemberGrants 时间戳同时晚于该用户与该组各自保留的 RevokedBefore 边界时才算有效;列表展示与 IAM 策略求值共用同一套有效成员判定逻辑。对端复制快照会保留原始授权时间戳(包括旧格式下的未知授权时间),不会把近期快照同步时间当作对已撤销身份的新授权;唯有管理员发起新的显式入组授权才能恢复其组权限。
继承自上游的活组快照合并逻辑仅作增量添加成员,不会自动消解站点断连期间漏掉的普通成员移出操作;本文机制聚焦于用户或组删除边界以及同名重建后的隔离保证。
保留期与过期回收
永久身份(内置用户、服务账号)、用户组、策略文档及策略映射的墓碑记录不设自动 TTL。断连的远端站点或旧备份可能在任意时长后重新接入;对端重放成功确认仅用于抑制重复网络发送,不作为物理回收墓碑历史的依据。
不可变 STS 临时令牌自然过期时,系统会物理删除其 token-key 记录及遗留映射,不会产生永久墓碑。而被提前显式撤销的 STS 令牌墓碑则会保留至该令牌原定过期时间加上时钟偏差容限,从而阻止带更晚事件时间戳的重放请求将其复活。etcd 后端利用原生过期租约(Lease)自动回收;对象存储后端则在既有的凭证加载与清理周期中顺带清理已过期的 STS 墓碑。过期时间未知的记录按保守策略持久保留。
可选的过期回收写入使用短锁超时预算并以尽力而为(Best-Effort)方式执行:若某次清理写入失败,当轮加载会停止可选回收、记录错误日志并继续加载正常用户;已过期凭证依然处于拒绝访问状态并保留既有持久化版本,留待下一轮加载重试。外部 IdP 身份禁用属于提前撤销而非自然过期,其关联的缓存 STS 与服务账号同样纳入清理。带过期时间的服务账号由于其 AccessKey 可被更新复用且旧版本可能未设过期时间,仍会保留持久化修订记录。
在九月 20260916 实现中,删除内置父用户通过持久化父边界即可使所有旧子凭证失效(含未出现在当前删除节点缓存中的子凭证);而跨站点逐令牌的直接 RevokeTokens 同步则由上文十月十八日按范围撤销 STS 补齐。
自愈循环、故障处理与运维成本
每个服务进程启动单个后台自愈循环(Healing Loop)。当节点失去分布式 Leader 租约时,循环暂停等待重新获得领导权,不会永久退出;配置热重载也不会派生额外循环。每次取得领导权后以及每轮自愈完成后等待 30 秒再启动下一轮。
常规 IAM 加载器在内存中维护一份不含敏感密钥的删除记录与撤销边界索引。自愈循环直接读取该内存索引,避免在每个 30 秒周期内重复全量遍历磁盘上的 config/iam/ 目录;仅在进程启动与全量 IAM 刷新时扫描包含墓碑在内的持久化存储记录。
自愈同步按每个对端每批最多 128 条记录发送。发送方记录每个对端已确认的路径与版本号,任一站点上无关路径的新变更不会重置既有确认进度。若某批次发送失败,该批次保持挂起待重试,后续独立批次仍可继续推进;响应丢失只会触发安全的幂等重放。单轮自愈设有总时长上限,超时前已收到的成功确认会被保留。同步协议会汇报节点名称与进程实例 ID:负载均衡器在已知实例间切换不会重置确认进度,唯有检测到全新进程实例首次出现时才会保守地作废旧确认并重新核对一次。因此在从备份恢复持久化状态前,必须先停止相关服务进程,防止恢复后误复用旧进程留下的内存确认记录。
稳态运行下,自愈循环仅遍历并排序内存中的保留集合、检查对端协议状态,并在对端确认后抑制重复的删除 PUT 请求。内存占用随保留路径数与对端站点数量线性增长,冷启动存储读取量随历史记录数增长。首次升级后,若部分存量活记录此前在各接收站点上被赋予了不同的本地时间戳,升级后会触发一轮初始版本对齐写入;规划维护窗口时应预留相应的存储写入与兄弟节点通知开销。
缓存凭证查找仅比对内存中的父版本索引,不产生额外存储读取;STS 签发与冷凭证加载则会核对持久化父版本。版本 I/O 与分布式锁等待期间会释放 IAM 缓存读/写锁,由独立的本地写入互斥锁(writeMu)维护本节点写入顺序,且所有存储与 etcd 锁/租约操作均受超时上下文约束(详见 IAM 策略重载:锁粒度、请求取消与超时预算隔离)。
复制协议与协调升级要求
服务端专用的版本化复制路由为 /minio/admin/v3/site-replication/peer/iam-revisions,在内部传输源版本号、成员授权时间戳以及独立的用户/组撤销条目,不改变公开 Admin 客户端 SDK 或 S3 API。旧版本服务端会拒绝该路由,发送方会直接报告同步失败,而不会降级回退到会丢失版本元数据的旧路由。既有旧 IAM 路由仍保留读取兼容。
集群中的所有参与节点(包括未启用跨站点复制但共享同一 IAM 存储后端的节点)必须统一升级,不支持同一后端下新旧二进制混跑或滚动降级,因为旧版本二进制无法识别墓碑记录与签名父边界。请按以下维护窗口步骤执行协调升级:
- 暂停所有 IAM 变更,并隔离状态未知的离线站点或旧备份。
- 完整备份各站点的底层 IAM 存储及所需 KMS/加密材料;常规在线 IAM Admin 导出不包含删除墓碑历史,不能替代底层存储备份。
- 停止共享同一 IAM 后端的全部节点,替换二进制文件后以新版本启动。所有参与站点完成升级后再恢复 IAM 写操作。
- 检查 IAM 加载日志、站点复制状态与撤销收敛指标,验证代表性的已撤销旧凭证被拒绝、新签发凭证正常可用。
- 显式处置升级前已遗失的撤销记录:若某些旧记录仍残留在部分站点,应在这些站点上再次执行删除;长期离线的陈旧站点应在重新接入前基于已批准的最新状态重建,切勿为了查看其旧凭证而直接将未知旧快照并网。
旧版本服务端曾为同名重建父身份签发的子凭证缺少签名父边界声明,升级后必须由新版本服务端重新签发;从未发生过删除与同名重建的父身份则完全保持既有凭证行为。
20261026 内置策略 Reset 更新: 未经修改的内置默认策略仍受保护,禁止本地直接删除。在 Server 20261026中,删除对内置策略的显式覆盖内容会立即恢复原生内置定义,并保留删除版本以拒绝旧覆盖回放(取代了 Server 20260916/20261018 阻止自动恢复默认策略的旧行为)。对端与 etcd 通知会重新加载已提交状态,保留延迟删除事件之后的新编辑;在用策略检查与自定义策略删除语义保持不变。本地删除不存在的策略保持幂等,不新增墓碑;对端同步来的未知策略删除则保留其版本记录。
如需回滚,必须先停止并隔离受影响站点,评估自备份以来的权限变更后再恢复兼容状态。恢复旧备份本身会丢失备份点之后的撤销记录,重新开放访问前必须完成权限核对与密钥轮换。切勿在线手动删除墓碑文件或仅转换活记录以强行启动旧二进制。
回归与性能验证
专项测试集位于 iam-revocation_test.go、iam-revision_test.go、iam-revision-lock_test.go、iam-revision-boundary_test.go、iam-replication-protocol_test.go、iam-credential-retention_test.go、iam-peer-reload_test.go 与 iam-replay_test.go。将环境变量 SILO_TEST_IAM_REVOCATION_ETCD 指向测试用 etcd 实例即可运行包含隔离命名空间的 etcd 生命周期、分布式锁与边界测试。
基准测试覆盖:
BenchmarkIAMCachedCredential与BenchmarkIAMSetTempUser:测量缓存鉴权与临时用户签发的基线开销。BenchmarkIAMRevisionConvergedHealing:覆盖 1,000 与 10,000 条保留记录下的稳态内存索引遍历与零重复PUT断言。BenchmarkIAMColdLoadExpiredServices:测量存在 100 或 1,000 个已过期可复用凭证时的冷启动加载与清理耗时(使用-benchtime=1x运行)。
错误语义与可观测性
十月二十三日补充(pgsty/silo#250):LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界区分了“单分量超过 255 字节、物理上无法存入对象后端的纯 ASCII 键”与“当前无法读取的已存在记录”。loadIAMConfigBytesWithMetadata 仅将前者按 errConfigNotFound(记录不存在)处理;完整路径超限(PATH_MAX)与真实存储故障继续按 Fail-Closed 向上报错,确保磁盘上已有的撤销墓碑绝不会被掩盖。对象存储后端仍不支持写入超长路径分量的个人策略映射或父身份版本记录。
当权威撤销版本已成功落盘、但后续附属清理步骤失败时,Admin 删除请求可能返回 HTTP 500;这并不代表旧身份仍可使用。此时应在暂停 IAM 写入与同名重建的前提下重试删除,并在各站点验证旧凭证已被拒绝。当持久化父身份版本不可读或签发记录无法提交时,STS 会通过内部错误路径(含 STSInternalError)Fail-Closed 拒绝签发,绝不通过猜测缺失边界来放行新令牌。
可通过每个进程需认证的 /minio/metrics/v3/cluster/iam 端点采集以下指标:
| 指标名称 | 含义说明 |
|---|---|
minio_cluster_iam_revocation_records |
当前进程内存索引中保留的删除墓碑记录与父撤销边界总数 |
minio_cluster_iam_revocation_heal_failures |
进程启动以来失败的自愈收敛轮数 |
minio_cluster_iam_revocation_heal_duration_millis |
最近一轮自愈收敛耗时(毫秒) |
minio_cluster_iam_revocation_heal_last_success_timestamp_seconds |
最近一轮自愈成功的 Unix 时间戳(秒) |
上述索引指标按单进程统计,对共享同一后端的多个节点直接求和会重复计数。自愈指标仅在启用了跨站点复制且当前节点持有 Leader 租约时推进;仅共享后端而未启用站点复制的部署不运行跨站点自愈循环,后三项指标为零属于正常现象。
为什么更简单的方案不成立
在多站点与离线重连场景下,以下看似更简单的方案均存在安全漏洞:
- 直接物理删除配置记录:一旦物理删除,重新连入的离线节点或延迟复制消息将缺少判断新旧顺序的墓碑证据,导致已删除身份被旧快照重新写回。
- 仅比较子凭证自身的更新时间(
UpdatedAt):父用户被删除并重建后,攻击者或延迟重放若对旧子凭证执行一次元数据编辑,其更新时间就会晚于父用户删除时间并借此恢复访问;而签发时写入加密令牌的siloParentRevocation签名父边界不会随编辑而改变。 - 用接收节点的本地时间覆盖源事件时间戳:会破坏跨站点因果与源端顺序,甚至将一次幂等网络重试变成一次具有更新时间戳的新写入。本实现严格保留源时间戳,仅对有意的本地写入推进单调版本号。
- 为永久身份墓碑设置固定 TTL 或仅导入导出活记录:离线站点或冷备份的断连时长无法预设上限,提前淘汰永久身份墓碑或仅恢复活记录导出会直接抹去阻止旧身份重放的历史边界。
具体实现契约参见 cmd/iam-revision.go 与 cmd/metrics-v3-cluster-iam.go。
4.8 - 多池对象一致性
随 SILO Server 20260916 发布的 PR #178 修复了多存储池修改顺序问题,PR #207 将当前逻辑对象选择扩展至条件 PUT(关闭 #133 与 #144),提交 fced86303 则修复了跨池再平衡(rebalance)与退役(decommission)迁移丢失对象标签的缺陷。随后的 SILO Server 20261018 进一步合入 60c7179dd(纳入 PR #248)与 1748f62c7,修复了跨池迁移期间旧数据覆盖新副本以及删除标记(Delete Marker)丢失的问题。
10 月 26 日:存储超时与回滚边界
Server 20261026 完整保留了下文的多池删除保护,并转发覆盖写回滚所需的存储 RPC 删除选项(旧数据目录)。在最终发版前,提交 dcc2f20ba 撤回了 10 月维护草稿中的已接纳 I/O 排空等待、逐对象变更锁(mutation guard)、回滚所有权校验与备份互斥方案,维持既有的磁盘/RPC 超时、请求取消、对象锁、读仲裁与自愈行为。
需要注意:客户端收到超时响应并不代表底层磁盘或 RPC I/O 已经停止,也不代表写入未提交。 在部分磁盘故障、锁租约失效或网络分区后,仍可能出现迟到提交、延迟回滚,或在部分元数据更新后短暂读到旧值与旧标签;对象锁本身不提供跨节点分布式 fencing,升级也不会自动修复存量不一致对象(更严格的诊断检查仍保留在非默认的 consistency_strict 构建标签下)。
10 月 24 日更新:删除失败时保护最新副本
PR #257(提交 aafe4f8886)将多池删除保护扩展到普通无条件删除与批量删除入口。批量数据删除复用既有共享锁与解析器,先清理非权威池中的旧副本,再删除每项的权威副本,同时完整保留请求项的响应身份、支持部分物理删除后的 quorum 重试,并保护共享远端分层引用。该修改直接作用于现有的容量加权分配器,不依赖 #245。详见多池删除:失败时保住最新副本。
10 月 18 日更新:在池迁移期间保护数据与删除标记
SILO Server 20261018 新增的 60c7179dd(PR #248)与 1748f62c7,补齐了九月修复尚未覆盖的两类 rebalance / decommission 迁移竞态:旧载荷覆盖新副本以及删除标记丢失。
在向目标池提交迁移版本前,池协调层会在共享命名空间对象锁内检查所有池中的当前副本,拒绝源快照已消失、已被替换或其他存储池中已存在更新副本的搬迁操作。若修改时间戳完全相同,则沿用读取路径的池索引决胜规则。验证通过的元数据快照会直接复用于后续字段协调,避免重复读取;多段上传(Multipart)完成阶段会在复制完所有分片后再次复核,分层对象(Tiered Object)搬迁同样启用该保护。
即便删除标记是源池中的唯一版本,迁移流程也会将其迁移至目标池,因为其他存储池中可能仍存有该键的更旧数据版本。null 版本与 UUID 版本的删除标记均保留各自的替换与重试语义。源池清理改为在对象写锁内逐一复核并仅删除遍历快照中确认过的具体版本;若源版本已发生变化,或没有任何目标池副本能继续维持当前外部读取结果,则立即中止源清理,不再盲目删除可能已包含并发新写入或新删除标记的整个对象前缀。
运行时成本与边界: 迁移流程增加了逐池元数据读取与逐版本清理。对于 $P$ 个存储池和 $V$ 个源版本,清理阶段至少执行 $\mathcal{O}(P \times V)$ 次元数据操作,且解码与重写版本元数据的开销随版本历史长度增长。普通已解析的读取请求不会进入该迁移检查路径;本修复未引入新的磁盘格式、公开 S3 参数或外部依赖。历史版本迁移中已经丢失的数据、标签或删除标记无法自动重建,跨站点延迟到达的删除标记问题(#217)仍属独立议题。
版本验收测试实际运行了 2 池与 3 池的 rebalance 和 decommission,完整覆盖普通对象、多段对象、null/UUID 版本与删除标记、并发写删以及双池迁移中断恢复四组场景(详见 20261018 发布验证记录)。在生产环境正式退役源池前,应通过公开 S3 API 核对已确认的版本 ID、字节数、ETag、标签及删除状态。
为什么同一个键会存在多份物理副本
扩容存储池、退役旧池或清理中途中断,都可能在不同存储池中留下同一个对象键的多份物理副本。单纠删集(per-set)锁无法与路由到另一存储池的并发写入串行化;仅读取第一个响应的副本也无法证明其 ETag、标签或 Object Lock 保留期代表当前的逻辑对象。
缺乏跨池协调会导致三类典型故障:
- 某个池中的陈旧 ETag 错误地满足了条件写入(
If-Match/If-None-Match)或条件删除; - 某个池中较新的保留期(Retention)或法务锁定(Legal Hold)被另一池中较旧的对象元数据遮蔽;
- 删除当前选中的副本后,另一池中的旧副本重新暴露,导致已删除数据“复活”。
孤立地操作每个存储池并在最后仅汇总成功状态,无法消除上述并发竞争。
共享选择与锁边界
在 cmd/erasure-server-pool-consistency.go 与 cmd/erasure-server-pool.go 中,池协调层在“选择候选对象”到“完成修改”的全过程中持有同一把命名空间对象锁。objectPoolInfos 会读取每个池(包括正在排空的退役池)中的指定版本;未知或不可读状态会被直接判定为错误,而不会当作“对象不存在”的证据。各池副本按修改时间(ModTime)排序,时间戳相同时以存储池索引决胜。
不带 versionId 的元数据请求会先选定逻辑当前版本,再收集所有池中属于该特定版本的物理副本,绝不混合不同对象版本的元数据。Object Lock 保留期、法务锁定和用户标签各自拥有独立的源修订时间戳;mergedPoolObjectInfo 按各字段自身的修订时间戳顺序合并,而非把对象数据本体的修改时间当作所有元数据字段的版本号。带时间戳的标签清空或锁定解除同样作为显式状态参与排序。
元数据修改、相关对象写入与后台自愈(healing)共用同一把协调锁。需注意,该锁用于串行化并发访问,但并非跨池回滚事务——若在多池落盘中途遭遇底层磁盘故障,已完成写入的池不会自动回滚。
条件请求与删除
对于条件 DELETE,协调层在执行跨池清理前针对选定的逻辑对象统一校验一次前置条件,并在调用下层各池删除接口前清除条件回调;若请求指定了显式 versionId,则针对该特定版本进行比对。
对于条件 PUT,PR #207 在外层命名空间锁保护下先跨所有可用池选出最新的逻辑对象表示(包括删除标记状态),再写入新内容。这修复了目标池中残留的陈旧副本导致 If-Match / If-None-Match 判断结果与集群当前逻辑对象不一致的缺陷。
若跨池清理或元数据传播在修改了部分物理副本后遇到存储错误,恢复后应重新核对对象状态并重试。批量 XML DeleteObjects 的 ETag 条件仍不受支持,详见条件 DELETE 设计说明。
Object Lock 与桶复制
副本写入会跨所有存储池与同版本的权威副本协调目标端保留期和法务锁定状态,防止乱序到达的旧副本缩短较新的保留期或关闭较新的法务锁定。对象标签同样携其修订时间戳(包括表示删除的空值)一同传递与比对。详见 Object Lock 复制顺序保护与复制标签顺序一致性。
跨池迁移中的对象标签保持
rebalance 与 decommission 会将每个对象版本读取为 FileInfo,再转换为 ObjectInfo——这一步会将内部标签头从普通 UserDefined 元数据映射表移至独立的 UserTags 字段——随后再将对象写入目标池。自 2022 年引入迁移写入逻辑以来,四个迁移写入入口仅复制了普通 UserDefined 元数据映射表,导致普通对象与多段对象在 rebalance 和 decommission 过程中均会丢失用户标签(2020 年引入的字段拆分本身并无问题)。
提交 fced86303 让四个迁移写入入口共用统一的元数据组装函数:在既有协调锁保护下克隆元数据映射表、写回 UserTags,并原样保留已落盘的标签修订时间戳字段而不伪造新时间戳。回归测试覆盖了普通对象与多段对象、初始标签、更新后标签、清空标签以及版本化与旧格式读取。此前在旧版本迁移中已丢失的标签需从源端或备份恢复。
验证与运维指引
cmd/erasure-server-pool-consistency.go 与 cmd/erasure-server-pool.go 中的多池协调实现已通过多存储池、陈旧副本、删除标记、显式版本、并发写入及故障路径测试验证。
在依赖共享锁一致性保证前,请协同升级集群中的所有参与节点。对于此前曾执行过跨池迁移或多池并发写入的存量部署,建议结合副本元数据审计手册与组件版本矩阵盘点存量副本、标签及 Object Lock 状态。
4.9 - 联邦 CopyObject:保持目标对象契约
Server 20260916 通过 PR #157、PR #159、PR #163、PR #177 与 PR #179 修复了旧版 etcd 存储桶联邦(bucket federation)的 CopyObject 转发路径。当代理节点将跨集群复制作为远端 PutObject 转发到目标集群时,代理节点流式读取并发送源对象的逻辑字节,由目标端执行加密与 Object Lock 规则,校验完整对象校验和,并原样返回目标端实际提交的写入元数据。
转发一次逻辑字节,由目标端执行加密
代理节点在本地按需解密并解压源对象,读取其逻辑字节流,并在向目标集群发起的 PutObject 请求中声明逻辑字节长度:
- 禁止双重转换: 代理节点绝不在本地预先加密或压缩后再转发给远端重复处理。
- SSE-C 与 SSE-KMS 边界: 读取 SSE-C 源对象仍要求提供源客户密钥并使用安全传输。新对象的加密方式完全由客户端显式携带的目标 SSE 请求头或目标桶自身的默认加密配置决定;当客户端未指定加密头时,代理节点不会注入本地默认加密配置。SSE-KMS 上下文按标准 JSON 对象格式转发。
- 拒绝原始 SSE-C 副本复制: 联邦路径在创建目标对象前以
501 NotImplemented显式拒绝可信原始 SSE-C 副本的CopyObject请求。
校验和与元数据过滤规则
普通转发写入按大小写不敏感规则剥离所有保留的内部元数据前缀,仅通过标准 S3 字段转发公开的用户元数据与对象标签。转发写入的校验和必须描述实际写入目标端的完整逻辑对象:
- 整对象校验和转换: 当请求显式指定校验和算法,或源对象携带分段复合校验和(
COMPOSITE)时,目标端必须生成并保存整对象校验和(FULL_OBJECT)。 - 流式 trailer 与请求头传递: 非空对象流通过
x-amz-trailer传递尾部校验和并由目标端验证;零字节对象无流式尾部,改用普通x-amz-checksum-*请求头;源对象已有的FULL_OBJECT校验和可直接作为普通校验和请求头转发给目标端验证。 - 远端响应校验: 若远端
PutObject响应缺失必需的校验和、格式非法或仍带有分段-N后缀,代理节点直接报错,不会将其包装成合法的整对象结果。由于报错时远端写入可能已经落盘,针对版本化桶重试前应先检查目标端实际生成的版本。
Object Lock 头部类型与时间精度
代理节点通过类型化的 LegalHold 客户端选项转发法律保留状态,确保请求发送标准的 x-amz-object-lock-legal-hold 请求头,而非误转为 x-amz-meta-* 用户元数据。对象保留期(Retention)时间戳完整保留亚秒级精度,不再截断为整秒。目标端继续独立执行权限认证与桶级 Object Lock 校验。
返回实际提交的目标写入身份
CopyObjectResult XML 响应与 ObjectCreated:Copy 事件均直接采用目标端本次写入返回的键名、逻辑大小、ETag、精确 VersionID 与修改时间。代理节点既不在本地伪造修改时间,也不通过后续不带版本号的 HEAD 请求二次读取(避免并发写入下读到其他版本的元数据)。当请求指定了源 versionId 时,源版本响应头继续标识所选中的源版本。
源码与测试索引
- 实现源码:
cmd/object-handlers.go、cmd/object-handlers-common.go(PR #157、PR #159、PR #163、PR #177、PR #179) - 回归测试:
cmd/object-copy-federation*_test.go、cmd/object-federation-time_test.go - 关联记录: SSE-C 副本完整性 · Object Lock 复制排序 · 组件版本矩阵
4.10 - 复制可靠性:删除完成、MRF 可见性与 resync 取消
本文记录随 Server 20260916 交付的三轮复制可靠性修复的问题分析、方案取舍与验收结论:
- 第一轮(PR #162,合并为
d1105bbb): 修复 #153、#152 与 #137,分别涉及请求入口的操作分类、后台恢复队列的可观测性以及任务取消生命周期。 - 第二轮(PR #196,提交
0c61128d2): 修复复制工作协程(worker)的删除出口、外层状态聚合与持久化 MRF 标记恢复路径(见第二轮小节)。 - 第三轮(提交
eb4f5e5b3、254b19ac0与358ab38fb): 修复指定版本清除(purge)的磁盘写回与仲裁语义、删除标记自愈元数据保留,以及清除后排队创建任务的过期复核(见第三轮小节)。
整体判断与系列边界
本系列修复遵循同一工程准则:在拥有对应不变量的最小模块边界内修复已复现的缺陷,复用现有后台恢复机制,并用确定性测试验证收敛。
| 问题 | SILO 中确认的缺陷 | 选定修复 |
|---|---|---|
| #153:删除标记 purge | 单对象 DELETE 将永久删除误判为删除标记复制,导致远端已删除而源端 purge 状态长期停留在 PENDING |
按 purge 状态分类,与批量删除、scanner/heal、resync 保持一致 |
| #152:MRF 丢弃不可见 | 队列已设容量上限,但内部丢弃计数未输出到管理接口与监控指标;对象与删除的 worker 路由参数顺序不一致 | 暴露现有丢弃计数、在实际丢弃点输出去重告警、统一 worker 路由键 |
| #137:resync 取消不可靠 | 单个共享 token 无法取消并发的多桶任务;阻塞阶段不响应取消;旧任务收尾可能覆盖新状态 | 为每次运行分配专属 context,按 resync ID 取消,并串行化注册、收尾与状态写回 |
这一系列此前已分别闭合另外两项复制不变量:
- #136 / PR #138 修复了计数完整性:先接收并累加最后一个结果,再持久化终态,不再依赖一分钟一次的周期性刷新。
- #139 修复了结果真实性:仅当目标端本次复制请求实际成功时才计入成功,不再因目标端已有同名对象而误判。
- #137 修复了取消与资源生命周期:取消指令下达后,目录遍历器(walker)、工作协程与结果收集器均能及时退出,且已终止的旧任务不会覆盖新任务状态。
复制请求是否有权使用内部特权语义,由鉴权前不做 I/O,Header 不产生权限定义;跨存储池的 Object Lock 权威状态选择由 #133 跟踪并在 #178 中修复,详见多池对象一致性。
#153:先分清复制删除标记,还是永久删除版本
报告与复现为什么不完全相同
原议题报告称生命周期管理(ILM)与复制共同触发了持续的 HTTP 405 请求风暴,并建议将删除标记探测返回的 405 一律视作完成。源码核查与本地复现表明,这一归因并不准确,直接采纳该补丁会破坏删除一致性。
以 450dcb848 为基线,测试启动两个本地构建的 SILO 服务端实例,配合 mcli 与 minio-go 客户端进行验证。排查发现:mcli 即使只删除单个对象键,也会调用批量 DELETE 接口;而批量删除的分类逻辑原本就是正确的。 因此必须直接发送单对象 S3 DELETE 请求才能触发故障路径。
| 检查点 | 修复前的单对象 DELETE | 修复后 |
|---|---|---|
| 目标端对应删除标记版本 | 已删除 | 已删除 |
源端 xl.meta 中同一版本的 purge 状态 |
仍为 PENDING,普通复制状态却被标为完成 |
首次复制后即完成清理 |
| 原始数据版本 | 保留 | 保留 |
| 是否需要等待 scanner 补做清理 | 依赖后续后台恢复 | 无需 scanner 介入 |
如果只检查远端版本是否消失,就会漏掉源端 xl.meta 残留的 PENDING 状态。本地实验复现了首次 purge 的分类错误、完成状态误写以及一次多余的 HEAD 探测;但由于现有的 scanner/heal 与 resync 已经按 purge 状态走正确分支,并不会产生持续的 405 风暴。
一处分类修正为什么足够
永久删除一个已有的删除标记时,对象层会同时返回 DeleteMarker=true 和非空的 VersionPurgeStatus:前者表示被操作版本的对象类型,后者表示当前正在执行永久清除,二者并不矛盾。
旧版单对象删除处理函数仅检查 DeleteMarker,直接将版本 ID 填入 DeleteMarkerVersionID。复制完成后,工作协程随之更新了普通的 ReplicationStatus,而非待完成的 VersionPurgeStatus。DeleteObject 处理函数中的修复逻辑如下:
这一判断条件与批量删除、scanner/heal 及 resync 的既有逻辑完全对齐,无需调整磁盘存储格式或新增后台恢复任务;历史遗留的 PENDING 记录仍由现有的 scanner/heal 清除路径自动回收。
为什么不把 405 一律视为成功
对带版本号的对象发送 HEAD 请求收到 HTTP 405,仅说明“目标端存在该版本的删除标记”。对于复制删除标记,这代表目标端已就位,可按幂等成功处理;但对于永久删除该版本,405 恰恰说明目标端的删除标记尚未清除。
如果在收到 HEAD 405 时直接写入 VersionPurgeComplete,源端就会清理本地元数据,而目标端仍残留本应删除的标记版本。同理,远端返回的 403、405、503 等错误均代表删除未完成,必须按失败重试处理。
追溯提交历史可以看到,仅按 DeleteMarker 分类的逻辑源于上游 2020-11-19 的提交。2023-07-10 的优化将入队判断条件从 dsc.ReplicateAny() 改为检查返回对象的复制状态与 PENDING 清除状态,但保留了仅按 DeleteMarker 赋值的分支;2025-04-02 的提交又在该处将 Pending 枚举替换为 replication.VersionPurgePending。
#152:让有界 MRF 队列的丢弃行为可见
MRF(Most Recent Failures)队列用于暂存近期失败的复制任务,以便后台快速重试。该队列设有明确容量上限:mrfSaveCh 容量为 100000,单条任务最大重试次数 mrfRetryLimit 为 3;当 RetryCount > mrfRetryLimit 或通道已满时,队列会淘汰该条目。源对象及其待复制状态仍保留在磁盘上,被内存队列淘汰的条目后续由后台 scanner 扫描并重新发起恢复。
此前的缺陷在于:TotalDroppedCount 与 TotalDroppedBytes 虽在内部原子递增,却未写入两处管理统计快照,也未注册到 Prometheus v2/v3 指标中。使用容量为 1 的确定性测试夹具可以稳定复现:1 条任务成功入队,20 字节的通道溢出条目与 30 字节的超限重试条目被淘汰后,内部计数为 2 / 50,而管理接口仍返回 0 / 0。
选定的最小改动
- 在两处管理统计快照中原子读取并填充现有的丢弃计数。
- 在 Prometheus v2 与 v3 中注册并导出对应的累计计数器。
- 在重试超限与 MRF 通道已满这两个实际丢弃点记录去重警告,使用固定的日志消息与去重键,避免因计数值变化使日志去重失效。普通工作队列溢出转入 MRF 时并未丢弃任务,不在该处触发丢弃告警。
- 普通对象复制、自愈与删除路径统一按
(bucket, objectName)计算工作协程哈希,保证同一对象的操作路由到同一个 worker。
| 接口 | 新增指标 |
|---|---|
| Prometheus v2 | minio_node_replication_mrf_dropped_operations_total |
| Prometheus v2 | minio_node_replication_mrf_dropped_bytes_total |
| Prometheus v3 | minio_replication_mrf_dropped_operations_total |
| Prometheus v3 | minio_replication_mrf_dropped_bytes_total |
这组指标统计服务端启动以来的累计丢弃量,重启后归零。操作数按队列条目计数,同一对象的多次失败会重复累加;字节数统计已知的对象大小,删除条目记为零字节。运维监控应关注其增长速率,并结合复制积压量与目标站点健康状态综合判断。
本次修复保持原有队列容量与重试次数上限不变,由有界队列控制内存占用,由后台 scanner 承担最终兜底恢复。
#137:绑定单次运行的 resync 取消生命周期
一个共享 token 无法取消一组多桶任务
原实现在取消 resync 时,仅向全局共享通道写入一个不带任务标识的 token。而一次站点级 resync 可能涵盖多个桶(最多 10 个桶并发执行),分发器(dispatcher)与多个工作协程又会竞争消费同一个通道。这会导致只有单个桶停止、无关任务误消费取消信号,或者残留的 token 错误中止后续发起的新任务。
此外还存在两处阻塞死锁:从目录遍历器 Walk 的输出通道读取时未监听取消信号;向已满的 worker 通道发送任务时同样未监听取消信号。一旦 worker 先行退出,dispatcher 就会永久阻塞在无人接收的通道上。同时,Walk 直接复用了父级 context,函数提前返回时无法通知底层的遍历协程退出。
状态管理同样存在漏洞:站点级 updateState 修改了栈上局部变量却未写回 map;桶级 Canceled 分支处理缺失;已取消任务的延迟收尾(finalizer)还可能将状态重新覆盖为 Completed。
注册、取消与状态使用同一个写入边界
修复后,每个 resyncBucket 任务在等待并发槽位之前先创建专属的 context.WithCancelCause 并完成注册。注册与 cancelResyncID 共用 resyncer 的状态互斥锁:
- 注册时校验复制目标仍然有效、resync ID 仍然匹配,并立即检查当前是否已处于取消状态。
- 取消时先将同一 resync ID 下所有处于
Pending或Started的记录标记为Canceled,随后逐一取消已注册的对应 context。 - 已注册但仍在等待并发槽位的任务能立即收到取消信号;取消发生后才尝试注册的任务也会直接读到已取消状态并退出。
Walk、任务分发、worker 执行与结果发送全程监听当前运行专属的 context;不同 resync ID 之间互不干扰,也不会在通道中残留取消 token。
站点级启动与取消的配置准备阶段由专用操作锁串行化;即使部分桶的目标配置更新失败,已启动任务的取消动作仍会照常执行。桶级收尾函数与进度更新均会核对当前目标与 resync ID,自动忽略已被移除、替换或取消的旧任务结果;站点级状态正确写回 map,Canceled 终态不会再被迟到的 Completed 覆盖。
成功与失败的收尾顺序不同
任务收尾必须严格保持 #136 建立的契约——待结果收集协程处理完最后一条结果后再持久化终态:
如果在正常完成路径上先取消 context 再等待 worker,尚未处理完的任务就会被提前中断,导致原本成功的运行变成 Failed。最终实现通过单一 defer 调用 finish,按固定顺序完成清理,无需额外引入 sync.Once。
借助 WithCancelCause,代码能够区分用户主动取消与服务关闭导致的父级 context 中断:用户取消记录为 Canceled;父级中断则不会将未跑完的任务误记为 Completed;若任务在等待并发槽位期间遇到服务关闭,则保留原有的 Pending 状态,以便重启后自动恢复。
取消终态与恢复任务也必须受保护
在检查 context 状态与落盘保存终态之间仍可能并发到达取消请求。因此 markStatus 在持有状态锁时,若发现内存状态已变为 Canceled,即使收尾流程此前计算出的结果是 Completed,也必须保持 Canceled;同时拒绝旧 resync ID 覆盖新任务的状态。
若某个桶在站点级取消到达前已完成并落盘,最终状态会记录为该桶 Completed、站点整体 Canceled。锁内状态检查确保已进入 Canceled 状态的任务不会被迟到的完成信号改写为 Completed。
在重启恢复路径上,loadResync 原本在启动后台恢复协程后立即执行 defer cancel()。核查 SILO 的分布式共享锁实现确认,该 cancel() 会直接终止合并后的 leader context,导致恢复协程被立即取消。修复方案引入 WaitGroup,保持该 context 存活至所有恢复协程退出;当节点失去 leader 身份时,底层 context 仍会按预期触发取消。此外,从磁盘加载历史状态时也不会覆盖内存中更新的启动或取消状态。
设计审查与复杂度取舍
针对基线复现与备选方案的评审结论汇总如下:
| 方案或评审意见 | 最终判断 |
|---|---|
| 将 purge 的 405 探测响应直接判为完成 | 否决:删除标记存在不代表永久删除已完成 |
| 为 MRF 新增持久化的退避重试调度器 | 不引入:现有队列与后台 scanner 已提供完整恢复链路,实际缺口在于可观测性 |
| 向共享通道发送更多取消 token | 否决:无法解决按 ID 路由、多桶广播及后续任务串扰问题 |
| 为取消操作维护独立的墓碑注册表 | 不引入:在同一把状态锁下结合目标状态与 resync ID 即可闭合注册竞态 |
| 为每次运行分配专属 context 并改造阻塞点 | 采用:满队列死锁与遍历器泄漏均有确定性复现用例 |
用 sync.Once 保护 finish |
收窄:合并为单一 defer 调用点后,无需额外引入 sync.Once |
| 等待恢复任务结束后再释放 leader context | 采用:SILO 的 leader context 取消会立即生效,需通过 WaitGroup 延长生命周期并补齐失主测试 |
此外,低频的 resync 启动操作在状态锁内完成配置读写,与终态落盘保持同一锁边界;活跃任务注册表直接复用包含 resyncBefore 的 resyncOpts 作为键。
验收证据与可复验入口
所有修复均遵循“先在未修改基线上编写失败回归测试,再提交生产修复”的流程。新增测试直接驱动生产环境处理函数、纠删码存储层、Prometheus 指标注册表与 resyncBucket 完整流程。
| 验收范围 | 结果与证据 |
|---|---|
| 单对象删除 purge | 在单盘(ErasureSD)与多盘纠删码(Erasure)下验证源端与目标端同步清理;覆盖存量 PENDING 恢复、删除标记幂等,以及 403/405/503 错误处理 |
| MRF 可观测性 | 容量为 1 的队列溢出与重试超限测试;校验管理接口 JSON、Prometheus v2/v3 注册计数器的类型与数值、对象与删除的 worker 路由一致性 |
| resync 取消 | 使用 testing/synctest 覆盖 Walk 阻塞、满 worker 队列、主动取消、排队期取消、跨 ID 隔离、后续任务启动、终态并发竞争、过期 ID 忽略、槽位释放与 leader 恢复 |
| 本地完整套件 | go test ./... -count=1 -timeout=30m 全量通过(共 50 个含测试的包) |
| 并发与重复测试 | 复制相关定向 -race 测试通过;取消回归用例连续运行 100 次全部通过 |
| 工具与契约守卫 | 本地编译、go vet、静态检查、生成文件检查、rebrand-guard 与 git diff --check 全部通过 |
| 双实例联调 | 基于本地源码构建两个服务端实例,直接发送单对象 DELETE:目标端删除标记消失,源端 xl.meta 完成清理,原始数据版本完整保留 |
| 远端 CI | PR 八项检查通过;合入主干后 Go CI 六项任务与 VulnCheck 全部通过 |
固定提交上的回归测试文件:删除标记、MRF 可见性、取消生命周期。复现与验证命令:
| 证据点 | 精确标识 |
|---|---|
| 修复前基线 | 450dcb8484bc1337deba0cf608cc893a6691d794 |
| 最终 PR head | 66fe61ff65c83d68b74baa637a11623015c7aa21 |
| 合并主干 | d1105bbb3d4a0afa33b3a4ac11b821235038ed0e |
| PR Go CI | 34320440012 |
| 主干 Go CI | 34321319278 |
| 主干 VulnCheck | 34321319274 |
后续维护原则
复制与 resync 的回归测试需分别断言五项独立不变量:操作类型判定准确、队列淘汰可观测、单对象复制结果真实、终态计数完整、取消后协程与并发槽位完全释放。
第二轮(2026-09-16):worker 侧 purge 分类与持久 MRF 恢复
第一轮在 HTTP 请求入口修正了删除分类(#153)、公开了 MRF 队列丢弃计数(#152)并闭合了 resync 取消生命周期(#137)。第二轮——PR #196,提交 0c61128d2,集成验证记录 aea3882c9——深入到后台复制链路,修复了 worker 自身的删除出口、外层状态聚合,以及删除标记的持久化 MRF 恢复路径。
第二轮排查出的遗留缺陷
- 旧形态任务被整体跳过。 当任务的
VersionID为空、DeleteMarkerVersionID非空、目标端创建状态为COMPLETED且清除(purge)状态为PENDING时,目标侧的“创建已完成”早退分支会直接跳过该任务,导致永不发出 DELETE 请求。 - 仅修复内层目标函数会导致外层状态误报。 外层状态聚合原本以
VersionID与创建状态为键;若仅放行内层函数,执行失败的旧形态清除任务会被外层汇总为COMPLETED,触发ObjectReplicationComplete事件并跳过持久化 MRF 入队。 - 持久化 MRF 恢复路径丢弃了所有删除标记的 405 响应。 重放磁盘 MRF 中针对删除标记版本的条目时,本地读取会返回真实的删除标记元数据与
MethodNotAllowed错误;原错误处理分支直接将其丢弃,导致删除标记无法通过持久化 MRF 恢复。 - 状态覆盖与重复发送。 失败的清除操作会覆盖已成功的 resync 标记,已完成的清除操作被重复发送,而尚未就绪的
HEAD探测也会无条件覆盖创建状态。 - 多目标空状态解析错误会破坏创建元数据。 当磁盘上的删除标记仅含创建元数据、而内存任务携带清除状态时,正则解析会将两个空的目标状态误判为
Pending;随后的已删除标志守卫会将整个创建元数据块重写为带新时间戳的空记录。
根因在于三处缺失:首先,worker 缺少任务级的清除判定(社区 PR #184 提出的按单目标判定谓词存在缺陷,复合清除状态经该分支处理后永远无法收敛为 COMPLETE,但 Julien Laurenceau 的问题排查与提案为本轮修复提供了重要参考);其次,MRF 恢复路径缺少识别合法删除标记 405 响应的准入条件;最后,删除任务未携带重试计数,导致 mrfRetryLimit 上限在删除路径上形同虚设。
第二轮修复方案
- 统一出口的清除判定。 新增
isVersionPurge()(当VersionID非空,或DeleteMarkerVersionID非空且携带复合清除状态时为真),同时驱动内层单目标执行函数与外层状态聚合。清除路径退出时仅更新VersionPurgeStatus,并将ReplicationStatus留空(存储层将其视作“不修改创建复制状态”)。 - 三字段清空守卫。 在清除路径上显式清空创建复制相关的三个状态字段,彻底阻断多目标空状态在落盘时触发正则误判的可能。
- 发送规范的永久删除请求。 清除操作统一携带显式
versionId与ReplicationDeleteMarker=false,不再执行前置HEAD就绪探测(权限校验直接由 DELETE 请求完成),同时防止网络丢包重试在无版本回退路径上意外重建删除标记。 - MRF 恢复的 405 身份校验。 重放 MRF 收到
MethodNotAllowed时,仅当返回对象确系删除标记、桶名/对象名/版本号完全匹配且修改时间非零时,才重新入队调度恢复。 - 有界重试预算。 删除任务新增重试计数器,在全部三个持久化 MRF 入口(聚合失败、获取锁失败、内存队列满回退)递增并受
mrfRetryLimit约束;超过上限淘汰后,仍可由后台 scanner 重新发起自愈。 - 审计与事件状态兼容映射。 仅在统计与事件通知边界将
COMPLETE映射为COMPLETED,复用既有常量定义。
405 的准确含义
对于创建操作(将删除标记复制到目标端),对目标标记版本发起 HEAD 收到 405 意味着目标标记已经存在,可视作幂等完成。对于清除操作(永久删除指定版本),MRF 恢复阶段探测本地版本收到带完整标记身份的 405 意味着本地标记仍在、尚待清除;清除操作本身的成败完全取决于远端 DELETE 请求的响应,DELETE 返回 403、405 或 503 均计为真实失败。空版本或 null 版本的标记返回的是 ObjectNotFound 而非 405,不会进入该分支。
实现边界
旧形态内存任务不会跨重启持久化,当前代码也已不再生成该形态,保留其兼容分支主要作为防御性保护。未配置远端客户端的目标站点仍仅记录日志;单目标级 resync 覆盖部分清除状态属于既有行为,不在本轮修改范围内。同系列可靠性修复中的标签排序与副本元数据归一化,另见复制标签排序与副本元数据归一化。
第三轮(2026-09-16):指定版本清除、标记 heal 与过期的排队创建
第二轮确保了 worker 的清除出口如实反映执行结果。第三轮——提交 eb4f5e5b3、254b19ac0 与 358ab38fb,随 Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a,配合 silo-pkg 6f27ee6)发布——进一步修复了指定版本清除在底层磁盘上的写回与仲裁逻辑,以及排队中的创建任务在版本清除之后乱序执行的问题。该问题由三站点容器集群测试(三个站点,每站点 4 进程 16 盘,EC 12+4)暴露:一个已被永久清除的删除标记在两个目标站点上死灰复燃,而源站点却仍返回正常的数据版本。
第三轮排查出的遗留缺陷
- 清除操作反而会在缺盘上创建待删标记。 查找层为了标记版本可见性,将待清除版本打上了已删除标志;而清除写回路径把该标志当成了“写入删除标记”的落盘指令。结果原本没有该标记的磁盘反而被写入了新标记,使清除了一半的磁盘组在反复重试中来回翻转、无法收敛。
- 过早确认版本缺失。 读取路径只要看到半数磁盘报告版本不存在,就判定该版本已缺失;导致清除重试直接返回 204 成功,而实际上仍有最多一半的磁盘残留着该删除标记。
- 自愈(heal)删除标记会抹掉其复制元数据。 自愈路径在重建删除标记时传入了空的元数据映射。修复后的删除标记丢失了原有的复制与清除状态,重新退化为待复制状态,随后被当作全新创建的标记再次复制到远端。
- 排队中的创建任务在清除完成后延迟执行。 针对处于 pending 状态的删除标记,每次 GET/HEAD/LIST 请求、后台 scanner 以及 MRF 都会入队一个携带当时标记快照的创建任务;来自多个前端节点的任务在单对象复制锁前排队串行执行。在复现时序中,用户的永久清除请求先在两个目标站点完成确认;约 85 毫秒后,排队中的创建任务携带相同的
VersionID与原始修改时间抵达两个目标站点。由于目标站点此时已无该版本,且请求报文中没有任何字段能区分“首次创建”与“过期的乱序创建”,两个目标站点各自重新创建了 16/16 份删除标记,而源站点为 0/16 份。
第三轮修复方案
- 单点判定清除意图(
isVersionPurge)。 仅当请求携带合法的显式VersionID,不含创建删除标记、前缀匹配、数据迁移、免版本控制、过期或生命周期转换选项,且复制状态为“清除已完成”、“无复制状态”或“可信副本请求”之一时,才判定为永久清除。清除路径绝不设置创建删除标记的落盘标志,并将“磁盘已完成物理删除”与“磁盘确认版本不存在”两类响应合并计入成功票数。仅当实际删除了磁盘上存储的删除标记时,响应才标识为删除标记;此前清除仍处于 pending 状态的数据版本则按正常数据版本返回。 - 确认版本缺失必须满足写仲裁多数(
N/2+1)。 当重试清除在查找阶段发现版本缺失时,会重新读取每一块磁盘。若明确报告“版本不存在”的磁盘不足N/2+1块,则按写仲裁不足返回失败;只要任意磁盘仍残留副本,就触发 MRF 自愈。离线、损坏或读取报错的磁盘绝不计入“版本不存在”票数。多存储池层对查找阶段跳过的存储池执行同样的多数派核查,副本接收端则按版本号跨存储池解析清除请求。 - 自愈删除标记保留完整持久化元数据。 自愈路径完整复制删除标记原有的持久化元数据表,仅剔除自愈、数据迁移与分层相关的临时操作键。类型化状态生成逻辑仅用于不带存量元数据的新建写入,且不再伪造时间戳。
- 发送前在复制锁内复核源端状态。 删除复制 worker 在向远端发送创建请求前,先在对象复制锁内重读源端对应版本。若该版本已不存在、不再是删除标记或已进入清除流程,则判定该排队任务已过期并直接丢弃,不修改源端状态;若重读因临时故障无法得出确定结论,则交由 MRF 重试,而不直接视为版本缺失。
证据与边界
在两组三站点分批故障恢复测试(涵盖源站点重启与不重启、单个目标站点分两批恢复上线)中,集群均在约 15 秒内完成收敛,并在随后 450 秒的观测窗口内保持完全一致。过期排队创建乱序复现用例在未合入修复的 main 分支上稳定复现失败,合入修复后在单盘与 16 盘纠删码夹具上全部通过。
源端复制锁内的发送前复核无法拦截已经发往网络的数据包,也无法拦截其他站点重放的创建请求;彻底消除这一跨站点乱序窗口需要在接收端持久化清除记录或引入单调序号,已由 #217 独立跟踪。此外,若某次清除刚达成多数派确认、所有服务端进程随即同时崩溃,此前离线磁盘上残留的少数派副本最多会保留七个后台扫描周期,在此期间所有前端读取仍一致返回正确的数据版本。
4.11 - FULL_OBJECT 校验和复制:在初始化上传时保留类型
Issue #254 报告了一类复制故障:使用 FULL_OBJECT CRC32 或 CRC32C 校验和的分片上传对象在源端可正常上传和读取,但复制到目标端并在最后调用 CompleteMultipartUpload 时会因 400 BadDigest 失败。Denis Babchuk(@botdeniska)提交的 PR #255 补回了出站复制请求遗漏的校验和类型头。
该补丁于 2026 年 10 月 24 日以 6d890fc86 合入主干并随 Server 20261026 发布,共修改 5 行生产代码(含 2 行注释) 并新增 257 行回归测试。
故障现象
当分片上传对象的校验和类型记录为 FULL_OBJECT、算法为 CRC32 或 CRC32C 时,启用桶复制或站点复制会出现以下现象:
- 原始分片上传在源端成功完成,源端版本可正常读取。
- 复制发送端将所有分片传往目标端后,目标端在
CompleteMultipartUpload阶段返回400 BadDigest。 - 源端对象复制状态停留在
FAILED,目标端无法生成该版本的完整副本。 - 后续每次复制重试(包括对
FAILED版本执行HeadObject触发的重入队)都会重新传输全部分片字节,并在完成阶段再次因同一错误失败。
分片 CRC64NVME 对象不受影响,因为接收端默认将 CRC64NVME 解释为 FULL_OBJECT;普通单次 PUT 与分片 COMPOSITE 上传也正常。此外,对受影响的分片 FULL_OBJECT 对象执行服务端 CopyObject 后再复制,会触发下文所述的另一条故障路径。
根因:仅转发算法无法区分校验和类型
对于分片 CRC32 和 CRC32C,同一算法支持两种截然不同的对象级校验和计算方式。设 P1 ... Pn 为各分片的字节内容,|| 表示字节拼接:
| 类型 | 对象校验和的计算方式 |
|---|---|
FULL_OBJECT |
对完整拼接内容 `P1 |
COMPOSITE |
拼接各分片 CRC 的原始摘要字节,再对其计算 CRC、编码为 Base64,并在输出时附加 -n 分片数后缀 |
即便分片上传只有一个分片,这两种方式算出的摘要也通常不同。仅转发算法名称与摘要值,不足以还原校验和语义。
复制发送端代码中的完整故障链路如下:
putReplicationOpts()调用getCRCMeta()构造对象校验和请求头。旧实现保留了摘要值和x-amz-checksum-algorithm,但丢弃了x-amz-checksum-type。replicateObjectWithMultipart()将这些选项传给目标端的NewMultipartUpload。当算法为 CRC32 或 CRC32C 且未指定类型时,目标端默认将该分片上传初始化并持久化为COMPOSITE。- 发送端传完各分片后,再次通过
getCRCMeta()构造完成阶段的对象级校验和头,其中携带的摘要仍是源端的整对象(FULL_OBJECT)CRC。 - 目标端的
CompleteMultipartUpload校验逻辑读取初始化时持久化的COMPOSITE类型,将收到的整对象 CRC 与“分片 CRC 的复合 CRC”进行比对,因摘要不匹配返回BadDigest。
| 阶段 | 修复前 | 修复后 |
|---|---|---|
| 源端校验和元数据 | CRC32/CRC32C + FULL_OBJECT |
不变 |
| 目标端初始化请求头 | 仅含算法,缺失类型 | 算法 + FULL_OBJECT |
| 目标端持久化的上传类型 | COMPOSITE |
FULL_OBJECT |
| 完成请求的校验和 | 源端整对象 CRC | 源端整对象 CRC + 显式 FULL_OBJECT 类型 |
| 目标端校验结果 | 400 BadDigest |
校验通过,完成复制 |
核心不变量:复制必须在初始化与完成两个阶段同时保留校验和的算法、摘要值与类型解释方式,使目标端按与源端一致的规则执行完整性校验。
历史演进与上游回归来源
上游 MinIO 于 2025 年 8 月 22 日在 PR #21535 中合入提交 4ea6f3b06,从 getCRCMeta() 中删掉了校验和类型转发。在那之前,getCRCMeta() 会无条件转发所有校验和的类型;但直接回退到无条件转发又会破坏普通单次 PUT 的 SHA 校验和复制(见下文)。
此前 SILO 针对分片完成阶段做过三项相关修复,均处于不同链路环节:
| 此前修复 | 解决的问题 | 为何未覆盖本次复制故障 |
|---|---|---|
c8590413f(#31) |
允许 FULL_OBJECT 完成请求省略逐分片校验和并完成整对象校验 |
前提是接收端在初始化时已知该上传为 FULL_OBJECT |
7e079ff05 |
校验完成请求显式携带的类型是否与初始化类型一致 | 复制发送端在初始化和完成两个阶段均未发送类型 |
| CompleteMultipartUpload 响应补齐 ChecksumType | 向客户端 SDK 的完成响应返回 ChecksumType |
仅作用于上传完成后的响应编码,不影响复制发送端构造的出站请求头 |
最小修复实现
在 getCRCMeta() 复制已识别的校验和摘要与算法之后,新增三行判断:
三个条件分别约束了精确的转发范围:
isMP:解码自已落盘校验和的分片元数据标记(不要求当前对象的 ETag 一定是带-n的分片格式)。cktype.CanMerge():复用既有算法能力判断,仅匹配支持将分片 CRC 合并为整对象 CRC 的算法(CRC32、CRC32C 与 CRC64NVME)。cs[xhttp.AmzChecksumType] == xhttp.AmzChecksumTypeFullObject:仅当源端持久化类型确为FULL_OBJECT时才转发,绝不改动COMPOSITE对象。
由于分片复制的初始化阶段(NewMultipartUpload)与完成阶段(CompleteMultipartUpload)共用 getCRCMeta(),这一处修改同时补齐了两个阶段的请求头。逐分片上传请求读取的是不含对象级类型的分片校验和映射,其请求头保持不变;CRC64NVME 则显式携带与其默认行为一致的 FULL_OBJECT。
为什么不能转发所有已存类型?
普通单次 PUT 上传的 SHA1 或 SHA256 对象,在内部存储和读取元数据中同样会标记为 FULL_OBJECT。但在 S3 请求侧,getContentChecksum() 解析器会拒绝为不可合并算法(如 SHA1、SHA256)显式指定 x-amz-checksum-type: FULL_OBJECT。
若无条件转发所有已存类型,单次 PUT 的 SHA1/SHA256 对象在复制时反而会被接收端拒绝。通过 isMP && cktype.CanMerge() 收窄条件后,分片 CRC 请求得以恢复,同时普通单次 PUT 与 COMPOSITE 上传的既有请求头完全不受影响。
CopyObject 自动恢复单次 PUT 复制路径
对分片 FULL_OBJECT 对象执行服务端 CopyObject 后,生成的新对象不再带有 -n 分片 ETag(!objInfo.isMultipart()),但其底层校验和元数据仍保留分片 FULL_OBJECT 标记。由于 FULL_OBJECT 摘要本身就是完整对象字节的 CRC,通过单次 PutObject 传输到目标端后依然完全有效。
putReplicationOpts() 中原本就包含针对该场景的路由分支:
修复前,由于 getCRCMeta() 丢掉了 x-amz-checksum-type,上述条件永远为假,导致 CopyObject 生成的对象被错误路由到单分片 Multipart 复制路径并同样触发 BadDigest。在 getCRCMeta() 补回类型后,这段既有路由逻辑自动生效:此类对象直接通过单次 PutObject 完成复制,无需额外编写 Copy 专用分支或重算校验和。
备选方案取舍
| 备选方案 | 结论与原因 |
|---|---|
| 恢复上游变更前的无条件类型转发 | 不采纳:显式携带 FULL_OBJECT 的 SHA1/SHA256 请求会被 getContentChecksum() 拒绝 |
仅在 CompleteMultipartUpload 阶段补类型 |
不采纳:目标端在初始化时已持久化为 COMPOSITE,完成阶段传入不匹配的类型同样会被拒绝 |
将所有 CRC32/CRC32C 分片上传默认改为 FULL_OBJECT |
不采纳:破坏合法的 S3 默认 COMPOSITE 语义 |
忽略对象级校验和或跳过 BadDigest 检查 |
不采纳:牺牲复制过程的数据完整性验证 |
在发送端重新计算 COMPOSITE 摘要以迎合目标端 |
不采纳:篡改了源端对象的校验和类型与摘要值,且引入额外计算开销 |
单独编写 CopyObject 复制分支或排除 CRC64NVME |
不需要:putReplicationOpts() 既有路由分支与 CanMerge() 已自然覆盖 |
| 调整复制重试预算、队列或 resync 调度 | 无效:不修复请求头缺失,重试多少次都会发送相同的错误请求 |
验证与实机验收
在合入前的基线 aafe4f888 上运行新增回归测试:FULL_OBJECT CRC32/CRC32C 复制稳定复现 BadDigest,CopyObject 用例在路径选择断言处失败(got true, want false);应用补丁后全部通过。
入库的单元测试与集成测试包含 8 个请求头表驱动用例(含逐分片请求头检查)和 8 个双版本桶 API 集成用例,验证复制路径选择与两端校验和元数据一致性。
独立实机验收启动两个单盘 SILO 服务端实例,通过 mcli 配置站点复制,实测 12 个端到端用例:
| 用例类别 | 算法或变体 | 数量 |
|---|---|---|
单次 PUT |
SHA256、SHA1、CRC32 | 3 |
分片 COMPOSITE |
CRC32、CRC32C、SHA256 | 3 |
分片 FULL_OBJECT |
CRC32、CRC32C | 2 |
默认 FULL_OBJECT 的分片上传 |
CRC64NVME | 1 |
对分片 FULL_OBJECT 对象执行 CopyObject |
CRC32、CRC32C | 2 |
仅含 1 个分片的 FULL_OBJECT 上传 |
CRC32 | 1 |
全部 12 个用例均达到源端 COMPLETED、目标端 REPLICA;独立计算的校验和摘要与类型、版本 ID、ETag、对象大小及目标端下载字节均与源端完全一致。
可复现测试命令:
PR 检查(Go CI、DCO、VulnCheck)与合并提交检查(Go CI、VulnCheck)全部通过。
升级与运维要点
- 升级复制源端:该修复作用于复制发送端构造的请求头。仅升级目标端无法补回被源端丢弃的
x-amz-checksum-type;在双向(active-active)复制拓扑中,两端站点均作为发送端,需同时升级。贡献者在 PR #255 中的混合版本测试验证了已修复源端可向未修复 SILO 目标端正常复制,而未修复源端向已修复目标端复制仍会失败。 - 覆盖路径:桶复制、站点复制与批量复制(
batchReplicationOpts())均通过putReplicationOpts()构造请求头,因此统一获得修复。SSE-C 加密对象继续通过X-Minio-Replication-Ssec-Crc传递加密校验和元数据。 - 存量
FAILED版本恢复与回滚:本次修改不涉及配置项、磁盘元数据格式或 API 变更。此前因该问题停留在FAILED状态的对象版本在源端保有完整的校验和元数据,升级源端后,触发复制重试或 resync 即可完成同步;回滚旧版本不会影响已落盘的目标端副本。
4.12 - IAM 策略重载:锁粒度、请求取消与超时预算隔离
提交 1b5477f3d(pgsty/silo#251)解决了 KMS/KES 响应缓慢时的两类级联故障:
- 鉴权缓存锁争用:
ListPolicies与ListPolicyDocs在保留 IAM 写操作串行锁(writeMu)的前提下,将慢速的后端策略读取与解密移出内存鉴权缓存锁(store.lock()),避免管理员列举策略时阻塞普通用户的 S3 读写请求。 - 全链路取消传播与超时预算隔离:将调用方的请求
context.Context贯通至 IAM 配置解密、SSE-S3 列表密钥解封以及底层的KES.Decrypt调用;同时将底层存储 RPC 超时严格限制在存储读取阶段,确保耗时超过 30 秒的合法慢查询不会被误杀。
两种故障表象与对应机制
在排查 pgsty/silo#251 时,受控复现实验将 KES 慢响应下的服务端异常拆解为两条独立机制:
- 当管理员在 KES 响应缓慢时执行策略列表查询,查询本身耗时数秒,而普通用户访问无关对象的
GET、HEAD、PUT请求也随之停滞。 - 当客户端中途放弃并断开 SSE-S3 对象列表请求(需解密对象元数据的
ListObjects)后,服务端仍继续向 KES 发起后续对象的密钥解密请求。
| 故障表象 | 根因机制 | 修复措施 |
|---|---|---|
普通用户的 GET、HEAD、PUT 被慢速策略列表阻塞 |
ListPolicies / ListPolicyDocs 在读取和解密全部策略文档期间一直持有内存鉴权缓存写锁 |
A:将慢速存储读取与解密移出鉴权缓存锁 |
| 客户端取消请求后,服务端仍继续向 KES 请求解密 | 中间层辅助函数换用了 context.Background(),导致取消信号在到达 KES 前中断 |
B:在受影响的完整调用链中透传请求 context.Context |
原本能成功的慢查询(> 30 秒)在第一版修复候选中于 30 秒左右返回 503 |
复用的 withIAMStorage 包装函数以及新贯通的 etcd 30 秒读取子 Context,将单步超时强加给了整批解密过程 |
R1:让每个超时预算严格局限于其原本约束的存储操作 |
缩小锁粒度(A)与贯通取消信号(B)互为补充:释放缓存锁保证正常 S3 流量在慢速管理查询期间畅通无阻;贯通取消信号则确保客户端断开后立即停止无效的 KMS 解密请求。
锁耦合与取消断点的形成原因
ListPolicies 和 ListPolicyDocs 会从后端存储重新加载全部持久化策略,并替换内存中的策略映射表。原实现从调用 store.lock()(即 IAMCache 写锁)开始,一直持锁直到所有策略文档读取并解密完毕。在对象存储 IAM 后端上,每个加密策略文档都可能触发一次独立的 KES 解密请求。由于所有普通 S3 请求在鉴权阶段都需要获取同一个 IAMCache 的读锁,管理端列举策略时的数秒 KES 延迟会直接阻塞数据面的全部对象读写。
与此同时,请求取消链路在三个位置发生了断裂:
- IAM 配置解密在调用 KMS 时使用了与 HTTP 请求无关的后台 Context;
- SSE-S3 列表路径(
UnsealObjectKeys)调用了不接收context.Context参数的单对象辅助函数UnsealObjectKey; - KES 客户端适配器最终使用
context.Background()调用Decrypt。
由于最底层被硬编码替换为 context.Background(),即便顶层 HTTP 处理器传入了请求 Context,出站 KES 请求也无法感知客户端断开。
A:释放鉴权缓存锁,保留写入串行顺序
IAM 子系统中的两把锁承担着不同的职责:
- 鉴权缓存锁(
store.lock()/store.rlock()):保护普通 S3 鉴权在每个请求上读取的内存映射表。 - IAM 写串行锁(
writeMu):串行化管理端 IAM 写入与整表缓存替换,防止并发修改交错覆盖。
修复方案 A 在加载策略期间继续持有 writeMu 以保证写入次序,但在执行慢速后端读取与 KMS 解密时释放鉴权缓存锁:
在私有 map 加载和解密期间,普通 S3 鉴权可以继续并发读取已提交的缓存快照,只有其他管理端 IAM 写操作会在 writeMu 上排队。之所以不在加载期间同时释放 writeMu,是因为若完全放开并发,较早启动但较晚结束的慢速列表可能用旧快照覆盖期间刚提交的策略更新或删除。
缓存替换仅在后端加载成功、且重新获取缓存锁后再次确认 ctx.Err() == nil 时才会发布。若仅在重新加锁前检查取消,就会漏掉等待缓存锁期间发生的取消事件。一旦发布前遇到后端读取错误、部分加载或请求取消,原有的内存策略映射表及其更新时间均保持不变。
后台定期刷新同样遵循这一合并与撤销规则。回归测试直接驱动具体的 IAMObjectStore 刷新路径,与策略更新、策略删除以及持久化 IAM 撤销并发执行验证。
B:将取消信号贯通至 KMS 与 KES
受影响的两条解密调用链现在全程保留调用方的 context.Context:
为了避免改动其他不具备请求 Context 的调用点,原有的单对象入口 UnsealObjectKey 保持原函数签名不变,列表路径改调内部新增的带 Context 辅助函数。
在列表循环迭代、历史回退分支以及最终缓存发布边界上均增加了取消检查。特别是当 IAM 解密因请求取消而中断时,加载器会立即返回,不再启动针对旧版密文的附加认证数据(AAD)兼容回退重试;而对于未取消的正常请求,旧密文 AAD 回退、密文格式、密钥顺序以及非取消类的 KES 错误映射均保持不变。
R1:将超时预算严格限制在原存储操作范围内
第一版修复候选在实现 A 时直接复用了 withIAMStorage 辅助函数来释放缓存锁。withIAMStorage 最初在 9 月 15 日的撤销持久化提交(709d50a916)中引入,用于为单条记录的存储读写、分布式锁等待和清理操作施加 30 秒超时保护。将其直接套用到策略全量列举上,相当于给原本没有总时长限制的策略列表额外加上了 30 秒整批硬上限。
边界测试直接暴露了这一回归反例:
- 在修复前的基线版本上,包含 61 个加密策略的慢查询在 32.74 秒后正常返回
HTTP 200; - 在第一版修复候选上,同一查询在 31.22 秒后因 30 秒超时到期而返回
HTTP 503。
然而,仅移除外层的 withIAMStorage 包装对于 etcd 后端仍然不够。etcd 存储实现在调用 Get RPC 时会派生一个 30 秒的子 Context。旧代码的解密逻辑忽略了传入的 Context;当修复 B 将 Context 真正贯通到解密调用后,如果直接把这个 30 秒的 etcd 读取子 Context 传给后续解密,就会让原本只约束 Get RPC 的截止时间继续约束读取完成后的 KMS 解密。一旦遇到“快速 etcd 读取 + 32 秒慢速 KES 解密”的场景,请求就会在解密阶段因超时失败。
修正方案 R1 在策略文档与策略映射加载路径上,将调用方的请求总预算与单次 etcd 读取预算明确分离:
| 时间预算类型 | 作用范围与行为 |
|---|---|
| 调用方的 Deadline 或主动取消 | 约束整个 HTTP 请求及其触发的全部 KMS/KES 解密操作 |
既有的 etcd 读取预算(默认 30 秒) |
仅约束 etcd 的 Get RPC;读取返回后,后续解密切回调用方的父级请求 Context |
| 既有的版本写入、撤销、分布式锁与清理预算 | 保留 withIAMStorage 原有包装函数与超时行为 |
| 第一版候选误加的整批策略列表 30 秒上限 | 从 ListPolicies 与 ListPolicyDocs 两个入口彻底移除 |
由于 etcd 读取 Context 是调用方请求 Context 的子级,若调用方本身设置了更短的超时,读取依然会立即响应取消;而在 etcd 读取完成后,解密操作继续受调用方剩余预算约束——当调用方允许更长等待时间时,超过 30 秒的慢查询依然能够成功返回。
备选方案与取舍
| 备选方案 | 否决原因 |
|---|---|
| 继续持有缓存锁,仅缩短列表超时 | 正常 S3 请求在超时前仍被阻塞,且合法的慢速策略查询会直接失败 |
加载期间同时释放 writeMu 与缓存锁 |
较早启动的慢列表可能在并发 IAM 更新之后完成,用旧快照覆盖新权限 |
| 直接返回内存缓存策略,不再从后端重载 | 破坏管理端查询的强新鲜度语义,且掩盖底层存储或 KMS 故障 |
| 将整批列表超时调大为另一个固定常数 | 只是把兼容性故障推迟到更大的策略数量或更高的 KES 延迟下触发 |
仅移除两个外层 withIAMStorage 超时 |
无法解决 etcd 读取完成后子 Context 超时传递导致的解密失败回归 |
| 删除全部后端存储超时 | 削弱单记录读写、分布式锁等待和清理操作的既有超时保护 |
| 并行解密策略或新增第二层密文缓存 | 引入额外的 KES 并发洪峰与缓存一致性复杂度,修复锁与取消问题无需依赖这些机制 |
| 顺带修改 KES SDK 内部的重试退避逻辑 | 超出服务端缺陷范围,侵入第三方 SDK 行为边界 |
相对于受测基线 a07d655dc,最终生产代码改动涉及 8 个文件(新增 83 行、删除 29 行)及 5 个测试文件,不涉及线协议、密文格式或权限模型变更。
实测对比与回归测试契约
在相同进程级实验夹具下,基线版本 a07d655dc、第一版候选与最终实现 1b5477f3d 的实测对比如下:
| 测试场景 | 修复前 / 第一版被否决候选 | 最终实现(1b5477f3d) |
|---|---|---|
| 5.23 秒慢速策略列表期间并发发起 4 个普通用户对象请求 | 平均耗时 5,284 ms(被策略列表阻塞) | 平均耗时 2.25 ms(策略列表本身并行耗时 5.23 秒) |
| 相同普通对象请求设置 200 ms 客户端超时 | 4 个请求全部超时失败 | 4 个请求均在 1.11–1.82 ms 内返回 200 OK |
| SSE-S3 列表请求在 200 ms 处被客户端主动断开 | 客户端退出后服务端又继续启动 21 次解密 | 客户端退出后新增 0 次解密(取消前共启动 4 次) |
| 对象后端慢速策略查询超过 30 秒(61 个策略) | 基线:32.74 秒返回 200;第一版候选:31.22 秒返回 503 |
32.75 秒正常返回 200 OK,完整返回 61 个策略 |
etcd 快速读取 + 32 秒解密(调用方预算 60 秒) |
第一版候选在读取后的解密阶段超时失败 | 两个策略列表入口与策略映射加载均成功完成 |
| 51 组正常与拒绝访问的 HTTP 对照用例 | 基线状态码、错误码与返回条数 | 51 组全部一致(含分页、KMS 拒绝与签名校验) |
在构造回归测试时,还处理了 KES 客户端的两处实现细节:
- KES SDK 不可取消的退避休眠:KES SDK 在重试时可能执行最多两次、每次最长
999 ms且不监听ctx.Done()的休眠。2 秒的测试观察窗口不足以覆盖300 ms请求超时加上两次退避休眠;将观察窗口调整为 3 秒后,可稳定验证取消后绝不发起任何新的解密请求。 - 周期性
DescribeKey探针:在持续 32 秒的真实长耗时测试中,后台会触发合法的周期性DescribeKey请求,模拟 KES 夹具需同时响应该接口。
持久化回归测试入口位于:
cmd/iam-policy-reload_test.gocmd/iam-policy-refresh_test.gocmd/iam-policy-budget_test.gocmd/kms-cancellation_test.go
运维边界说明
- 并发 IAM 管理写入仍按序排队:由于策略重载期间保留了
writeMu以维护写入顺序,其他管理端 IAM 写操作(如创建或删除策略)仍需等待当前正在执行的策略列表完成或取消;普通 S3 数据面鉴权则完全不受阻塞。 - KES SDK 退避期间的本地返回延迟:取消信号会立即终止正在进行的出站 KES 请求并阻止后续密钥解密或 AAD 回退启动;但如果取消发生时 KES SDK 恰好处于不可打断的退避休眠(最长
999 ms)中,本地函数会在该次休眠结束后返回。
工程复盘要点
- 复用辅助函数时必须审视其全部副作用:为复用锁释放逻辑而直接调用
withIAMStorage,会连带引入原本不存在的 30 秒整批超时上限。 - 修复 Context 传播时须逐层核对子 Context 的生命周期:当原先忽略 Context 的底层函数开始真正遵守 Context 时,上层为单步 RPC 创建的短生命周期子 Context(如
etcd读取超时)如果未及时切回父 Context,就会误伤后续步骤。 - “能取消”与“慢查询能成功”必须成对验证:取消测试只能证明请求能提前终止,必须配合超过单步后端预算(> 30 秒)的正向慢查询测试,才能防止超时范围扩大造成的回归。
相关设计文档:
4.13 - LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界
Server PR #250(合入提交 d43adc0f2,已随 Server 20261026 发布)修复了 LDAP 用户在规范化可分辨名称(Distinguished Name,DN)超过对象存储 IAM 后端单路径分量 255 字节上限时无法登录的问题,同时严格保留了对真实存储故障与持久化 IAM 撤销记录的 Fail-Closed 错误传播。
原始问题报告、初版补丁(9faeb7a11)及长 DN 回归测试由社区贡献者 @910patin 提交。在合并前的设计复核中,维护者进一步收窄了错误分类守卫条件,确保完整路径超限(PATH_MAX)或多字节 Unicode 文件名读取失败绝不会掩盖磁盘上已有的撤销墓碑记录。
问题背景与故障影响
在企业目录服务中,合法的 LDAP 账号常带有较长的 DN,尤其是当姓名与多级组织单元(ou=)包含中文、俄文等非 ASCII 字符时。即使某个用户完全通过 LDAP 用户组(Group)获取权限、既不需要直接绑定个人策略也没有本地父身份记录,SILO 的登录与鉴权流程仍会尝试读取这些可选的个人 IAM 记录。
在对象存储 IAM 后端上,规范化后的 DN 会直接作为目录名或文件名分量拼接到存储路径中。一旦该分量超过文件系统单分量上限(255 字节),底层读取就会抛出文件名过长错误(ENAMETOOLONG / ObjectNameInvalid)并向上传播,导致原本完全合法的 LDAP 登录、STS 临时凭证签发以及服务账号加载直接失败。
为什么 DN 规范化会让非 ASCII 名称膨胀三倍
DN 标识完整的目录条目(例如 cn=张三,ou=研发,dc=example,dc=com)。根据 RFC 4514 §2.4,特定语法字符必须转义,其余字节允许使用 \xx(反斜杠加两位十六进制数字表示一个字节)格式转义。虽然 RFC 4514 允许直接保留普通 UTF-8 字符,但上游 go-ldap 库的 DN.String() 序列化器选择将所有非 ASCII 的 UTF-8 字节逐字节转义为 \xx。
SILO 的 NormalizeDN 先解析 LDAP 服务端返回的 DN,再调用 DN.String() 生成统一的规范化字符串:
| 表示形式 | 字节长度 |
|---|---|
单个 UTF-8 汉字 张 |
3 字节 |
经 DN.String() 序列化后的 \e5\bc\a0 |
9 个 ASCII 字节 |
cn= + 40 个 张 + ,dc=example,dc=com(原始 UTF-8) |
141 字节 |
同一完整 DN 经 NormalizeDN 序列化后 |
381 个 ASCII 字节 |
由于每个 3 字节汉字都会展开为 9 个 ASCII 字节,原本仅 141 字节的 UTF-8 DN 在序列化后膨胀到了 381 字节。
这条查询结果规范化逻辑由上游 MinIO 于 2024 年 3 月 27 日在 minio/pkg efb73fa5(配套 MinIO PR #19358,作者 Aditya Manthramurthy / @donatello)中引入,目的是强制使用 LDAP 目录返回的权威 DN 作为身份标识,防止用户输入的拼写变体(例如将普通句点输入为 Unicode 变体句点)被当成不同的本地身份。
登录回归是如何形成的
对象存储 IAM 后端使用如下路径存放用户级记录:
主流 POSIX 文件系统对**单个斜杠分隔的路径分量(NAME_MAX)**限制为 255 字节。在 sts-users/<normalized-DN>.json 中,5 字节的 .json 后缀与规范化 DN 共用同一个文件名分量预算:250 个 ASCII 字节加 .json 刚好 255 字节,251 个字节加后缀即达到 256 字节并触发超限。
两处历史实现细节进一步加剧了症状的隐蔽性:
checkPathLength在 Unix 上漏计反斜杠:SILO 的路径长度预检查函数checkPathLength在 Unix 系统上会跳过反斜杠(\)字符。复现样本中 271 字节的转义 DN 被checkPathLength算成了 197 字节,从而绕过预检查,直到真正访问操作系统文件系统时才报错。- 结果随祖先目录是否存在而变化:如果
config/iam/users/前缀目录尚未在磁盘上创建,操作系统内核在解析父目录时就会直接返回ENOENT(文件不存在),根本不会走到后面的超长<normalized-DN>分量——此时登录反而返回HTTP 200成功。一旦集群中曾经写入过任何用户记录、使config/iam/users/目录落盘存在,内核就会继续解析超长子分量并返回ENAMETOOLONG,导致同一用户的登录立刻变成HTTP 500。
| 历史提交 | 在故障链中的作用 |
|---|---|
MinIO ee9077db(2020-05) |
在 checkPathLength 预检查中引入 Unix 下忽略反斜杠的计数逻辑。 |
MinIO 280526ca(2024-03) |
策略缓存未命中时不再忽略磁盘回读错误,而是将读取错误向上传播。 |
MinIO efb73fa5 与 7e45d84a(2024-03) |
将身份与策略映射统一改为规范化 DN,导致非 ASCII 字符按 \xx 展开。 |
SILO 709d50a9(Server 20260916) |
为支持持久化撤销与防重放,在登录流程中新增父身份版本读取(config/iam/users/<DN>/identity.json),使每次 LDAP 登录都会必经该读取路径。 |
修复方案:仅将确定无法落盘的纯 ASCII 超长分量归类为不存在
修复集中在对象存储 IAM 的共享底层读取函数 loadIAMConfigBytesWithMetadata 中。函数首先按原流程尝试读取;仅当读取返回错误,且同时满足以下三个条件时,才将该错误归类为 errConfigNotFound(记录不存在):
- 底层错误为
errFileNameTooLong或ObjectNameInvalid(含标准包装错误); - 完整对象键通过常规的桶名与对象名合法性校验(
isValidBucketName与isValidObjectName); - 至少存在一个按
/分隔的路径分量完全由 ASCII 字符构成(utf8.RuneCountInString(component) == len(component)),且字节长度超过 255(len(component) > 255)。
由于超过 255 字节的纯 ASCII 路径分量在对象存储后端从一开始就不可能写入成功,磁盘上绝不可能存在对应的历史记录。将其转换为 errConfigNotFound 后,上层调用者即可安全进入既有的“无此可选记录”分支——例如继续执行 LDAP 认证并通过用户所属的 LDAP 组策略完成授权——既不会凭空授予任何默认权限,也不会跳过对可存储身份的父版本撤销检查。
写入、删除、etcd IAM 后端以及全局对象路径长度检查均保持不变。核心生产代码仅在 cmd/iam-object-store.go 中新增 25 行(配套新增 318 行回归测试),正常读取路径零额外开销。
为什么要收窄初版补丁的错误分类范围
初版 PR 将任何语法合法键上的 ObjectNameInvalid / errFileNameTooLong 统统归类为 errConfigNotFound。在合并前的设计复核中,我们构造了两个具体反例,证明“读取报长度错误”并不总能推出“该记录从未在磁盘上存在过”:
- 完整路径超限(
PATH_MAX)掩盖已落盘的真实撤销记录:假设管理员先撤销了一个各路径分量均不超过 255 字节的合法身份(撤销墓碑已成功落盘),随后在 macOS 等环境上将数据目录挂载或迁移到了层级更深的父目录之下。此时相对路径下的各个分量依然合法,但完整绝对路径触发了系统的总路径长度上限而导致读取失败。如果把这类错误也吞成errConfigNotFound,就会直接掩盖磁盘上真实存在的撤销墓碑,让已被撤销的身份重新通过检查。限定检查单个/分量len(component) > 255后,完整路径超限仍会按 Fail-Closed 向上报错。 - 按字符数计长的多字节 Unicode 文件名(如 macOS APFS/HFS+):不同文件系统对 255 上限的定义并不相同。在 macOS 实验探针中,由 100 个汉字
界组成的单个路径分量虽然占用 300 个 UTF-8 字节(len(component) == 300,但字符数仅为100),却能够在 macOS 文件系统上成功创建并落盘。如果仅凭len(component) > 255就断定记录不可能存在,就会把这类已经落盘的多字节 Unicode 记录误判为不存在。加上utf8.RuneCountInString(component) == len(component)限制后,该规则仅对纯 ASCII 分量(天然涵盖所有\xx转义后的 DN)生效,而在任何平台上 256 个以上 ASCII 字符都无法存入单分量上限为 255 的文件系统中。
由此确立了严格的安全边界:只有被证明在物理上绝无可能写入成功的路径分量,才允许走 errConfigNotFound 分支;任何可能已经存在但当前无法读取的撤销记录,一律保持报错拒绝。
备选方案与取舍
| 候选方案 | 决策与原因 |
|---|---|
在 loadIAMConfigBytesWithMetadata 中对 >255 字节的纯 ASCII 分量做窄范围缺失分类 |
采用。 一次性覆盖所有共享该读取器的 IAM/STS 调用点,同时对完整路径超限和可落盘的 Unicode 名称保持 Fail-Closed。 |
| 将合法键上的所有名称长度错误一律视作不存在 | 否决。 当数据目录迁移到深层路径触发完整路径上限时,会掩盖磁盘上已有的真实撤销记录。 |
按解码后的字符数计长,或扣除 \xx 转义开销 |
否决。 操作系统文件系统实际存储的就是展开后的字面量字节,修改软件侧计长无法改变内核限制。 |
仅修复 checkPathLength 在 Unix 上漏计反斜杠的问题 |
不充分。 修补预检查只会让同一个长 DN 更早在入口被 ObjectNameInvalid 拒绝,无法恢复登录。 |
修改 NormalizeDN 保留原始 UTF-8,不再做 \xx 转义 |
暂不采用。 会改变存量集群已落盘的身份与策略映射键名,引发兼容性断裂,且依然无法解决更长 DN 的分量超限。 |
对 LDAP 跳过父身份版本检查或回退 709d50a9 |
否决。 会丧失持久化撤销与防重放能力,且依然会在更早的 sts-users/<DN>.json 策略映射读取处失败。 |
| 使用固定长度哈希键存储,将完整 DN 存入文件内容 | 属独立特性范围。 若需支持超长 DN 的持久化写入,需统一设计查找、列举、导入导出、跨站点复制与滚动升级方案。 |
测试验证矩阵
| 验证层级 | 覆盖范围与结果 |
|---|---|
单元与边界回归测试(cmd/iam-revision_test.go) |
覆盖转义后 271/384 字节 DN、255/256 字节临界值、.json 后缀计入、多字节 Unicode 分量、包装错误、非法对象名,以及迁移至深层路径下的真实撤销记录保留报错。 |
端到端 LDAP STS 登录测试(cmd/sts-handlers_test.go) |
验证规范化后长达 384 字节、通过组策略授权的 LDAP 用户在对象后端与 etcd 后端共 8 种配置下的 STS 登录、S3 签名访问与服务账号操作。 |
| 跨平台物理文件系统边界探针 | 在 macOS(可创建 49 组)与 Linux tmpfs(可创建 32 组)上实测 72 组物理文件名:所有能落盘的名称均未被误判为“不可能存在”;并在每个平台上验证了 105 项错误传播断言(5 类父身份名称 $\times$ 7 类存储错误 $\times$ 3 个调用入口)。 |
| CI 与工具链漏洞检查 | PR 提交 3df51549bbbd6e57a6a5f11ac77535390517db65(合并为 a07d655dc69cf04b130c2634d9fa5301078af546)及合并提交 d43adc0f2 通过全部 Go CI 与 VulnCheck 任务(同时将最低 Go 工具链提升至 Go 1.27.3 以修复标准库证书校验漏洞 GO-2026-6809)。 |
运维边界与配置建议
- 读取与写入的能力边界:本次修复恢复了长 DN 用户的登录与只读查询路径,但对象存储 IAM 后端依然不支持写入单路径分量超过 255 字节的文件。直接向规范化后超过 250 字节的个人 DN 绑定策略(写
sts-users/<DN>.json)或写入父身份版本墓碑(写users/<DN>/identity.json)仍会因文件名超限而失败。 - 推荐使用 LDAP 组授权:在使用对象存储 IAM 后端的集群中,对于包含中文或长路径的 LDAP 用户,应通过绑定到 LDAP 组(Group)的策略进行授权(确保组 DN 规范化后不超过 250 字节),避免直接向超长个人 DN 绑定独立策略。
- 无需数据迁移:升级无需执行任何磁盘数据迁移。切勿通过手动删除
.minio.sys/config/iam/下的目录来规避路径报错;多站点升级请遵循 IAM 升级与恢复手册。
4.14 - 复制标签排序:修订时间戳、墓碑与复活
随 SILO Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布的 PR #193(修复提交 03027727d)与 PR #196(修复提交 680eac66e),修复了对象标签在跨桶复制中保持顺序的两项缺陷:SSE-KMS 目的端复制静默丢弃标签修订时间戳(R4),以及空标签值(删除操作)缺失修订时间戳导致延迟到达的复制事件将已删除标签“复活”(R5)。
共同模型:标签值与其修订时间戳共同构成单一状态
对象标签在复制时通过请求头 X-Minio-Source-Tagging-Timestamp 传递内部修订时间戳(在 xl.meta 中存储为 x-minio-internal-tagging-timestamp)。接收端在对象写锁保护下通过 reconcileStoredObjectTags 执行排序规则:入站复制的标签状态仅在其修订时间戳严格新于目标端已存时间戳时才胜出。
本组两项缺陷均以“丢失修订时间戳而非丢失标签值”的方式破坏了这一规则:
- R4(PR #193): 在写入 SSE-KMS 加密目标端时丢弃了入站时间戳,导致较新的标签更新在存储层对账中输给陈旧的已存状态。
- R5(PR #196): 使空标签值(即删除标签)完全不携带修订时间戳,导致复制协议无法表达“在时刻 $T$ 已删除标签”——网络中迟到的旧复制事件因此能够重新恢复客户端早已删除的标签。
R4:SSE-KMS 目的端复制丢弃标签修订时间戳
故障形态: 发往 SSE-KMS 加密目的端的可信复制 COPY 请求返回 HTTP 200 OK,对象密文加密正确,明文 GET 读取也完全正常——但目的端对象的标签及其时间戳却停留在旧值。由于 HTTP 请求返回成功,不会触发任何告警或 MRF 重试。存储层对账函数 reconcileStoredObjectTags 在对象写锁内比较修订时间戳时,因入站时间戳丢失而直接判定存量旧状态获胜。
触发范围: 不仅限于携带显式 SSE-KMS 请求头的调用;当目的端桶配置了桶级默认 KMS 加密或集群开启了全局自动加密时,均会进入同一代码分支,因此即便请求中不含任何 KMS 请求头也会触发该缺陷。
根因追溯: PUT 类请求的选项构造函数在解析出可信来源标签时间戳并存入 ObjectOptions 后,SSE-KMS 分支重新构造并返回了另一个 ObjectOptions 结构体字面量——该字面量复制了 MTime、ETag、复制信任标志与两个 Object Lock 时间戳,唯独漏掉了 ReplicationSourceTaggingTimestamp。这一遗漏最早可追溯至上游提交 c4373ef290(2021 年);2026 年初的一次 Object Lock 修复向该结构体字面量补齐了两个保留期/法务锁定时间戳,但依然漏掉了标签时间戳。
修复(03027727d): 在 SSE-KMS ObjectOptions 字面量中补齐 ReplicationSourceTaggingTimestamp 字段传递。回归测试覆盖了全部目的端加密方式(无加密、SSE-S3、带与不带 key context 的 SSE-KMS、SSE-C)$\times$ 可信/不可信来源 $\times$ 缺失/有效/畸形时间戳的完整组合矩阵,以及在单盘和 16 盘纠删码后端上以 1–3 纳秒间隔执行的 50 次有序签名 COPY+GET 循环验证。
相近代码观察: 同一个 SSE-KMS ObjectOptions 字面量还遗漏了 ProxyRequest 与 Speedtest 选项字段(全局自动加密下 speedtest PUT 会丢失该标志),该问题作为独立跟进项单独记录,不混入本次复制修复。
R5:空标签值无修订时间戳,导致已删标签被延迟事件复活
故障形态: 基线测试在真实存储与函数级测试中暴露出 9 项具体回归表现:
- 成功的
DeleteObjectTagging从不生成新的修订时间戳,因此迟到的可信元数据COPY只需携带旧标签快照即可将已删标签重新写回。 - 携带空标签状态的较新
COPY被接收端忽略——空字符串被解释为“未提供标签变更”而非“已清空标签”。 - 首次副本
PUT虽然解析了来源标签时间戳,却未将其持久化写入磁盘元数据。 - 当源端与目标端可见标签值相等(或均为空)时,发送端将其折叠为“无需复制”;由于
HEAD响应不暴露内部标签修订时间戳,更新的删除或重加操作不可见,无法恢复正确顺序。 - 排队中的复制事件在完成回调时会将快照中的旧标签写回本地元数据,直接覆盖已提交的删除操作——且在无时间戳保护下,复活的旧标签还会继承删除操作的较新修订状态。
根因: 标签值与其修订时间戳(包括空值的时间戳)共同构成一个完整状态。旧协议只能表达非空状态:DeleteObjectTagging 从不生成修订时间戳、发送端仅在非空分支附带时间戳、接收端也仅在非空分支执行对账判断。
修复(680eac66e):
- 每次标签变更均生成单调递增修订:
PutObjectTagging与DeleteObjectTagging处理器无条件生成单一的 UTCRFC3339Nano修订时间戳,无论该对象当前是否命中复制规则。存储层在写锁内强制保证单调性:若新生成的本地修订不严格新于已存修订,则将其推进至 $\text{stored} + 1\text{ ns}$;多池后端则跨所有存储池计算出严格大于全部池副本的统一修订值。 - 发送端传输删除墓碑(tombstone): 空标签值同样携带其已记录的修订时间戳;对于从未设置过标签的对象(空值且无修订记录)绝不伪造时间戳;若磁盘上已记录的时间戳格式畸形,则按失败关闭(fail-closed)处理,不作静默修补。
- 接收端对账墓碑状态: 复制
COPY在重建元数据前先捕获存量标签与时间戳对,使带时间戳的空值作为可胜出的有效状态进入reconcileStoredObjectTags。内部重复版本抑制(412 PreconditionFailed)仅对严格更新的可信来源标签修订放宽(客户端显式指定的If-Match与If-None-Match依然严格执行)。多段上传完成(CompleteMultipartUpload)从已持久化的 upload 元数据读取标签顺序;复制删除确认回调不再将快照标签写回本地;普通 SSE-C 密钥轮换则会从复制的加密元数据中剥离旧标签时间戳,防止其覆盖新生成的本地修订。
运维可见变化与升级要求
- 时间戳相等时统一判定存量胜出: 在不带版本(unqualified)及显式
null版本的COPY请求上,当入站时间戳与已存时间戳相等时统一保留存量状态(旧逻辑在未限定版本路径上曾让入站请求胜出平局)。 - 携带
X-Amz-Tagging-Directive: REPLACE且无标签的普通COPY: 现在会按 S3 规范真正清空目的端标签,而不再沿用旧默认路径将源对象标签复制过去。 - 复制两端必须协同升级: 旧版本对端仍会丢弃空值修订头,新发送端发出的删除墓碑对旧接收端不可见(关于同步包含的 IAM 变更,请参阅 IAM 与复制升级指南)。
- 显式 resync/heal 期间每个带修订记录的对象增加一次元数据
COPY: 当目的端配置了桶级默认 KMS 或全局自动 KMS 加密时,该元数据COPY会实际重写对象数据,执行大批量重同步时需据此评估 I/O 预算。 - 固有边界: 历史已丢失或被覆盖的删除时间戳无法自动重建,需从权威源端发起一次新的显式标签写入以重新收敛;带标签过滤条件的复制规则仍按删除后的(空)标签状态评估目标资格;跨站点逐跳排序依赖站点间的时钟同步(NTP),畸形的已记录时间戳会使发送端构造持续失败并经由 MRF 重试,直至被新的合法标签变更替换。
评估并否决的候选方案
- 为空值且无修订记录的对象从
ModTime合成墓碑: 会使每个从未打过标签的普通对象都获得标签修订,叠加显式同步时的强制元数据复制后,会导致每个对象在每一跳都触发额外的元数据COPY。 - 仅在标签值为空时传输修订时间戳: 提案者在代码评审中撤回了该方案——在“时刻 $T_1$ 设为
X$\to$ 时刻 $T_2$ 删除 $\to$ 时刻 $T_3$ 重新设为相同值X”的序列下,若非空时省略时间戳会丢失 $T_3$ 的重加修订(已由回归测试TestTaggingRepeatedValueNeedsRevisionDelivery固定该反例)。 - 扩展
HEAD协议返回内部标签修订: 当前固定的minio-go元数据提取器会丢弃内部响应头,修改协议契约收益有限,且当修订不同时最坏情况仍需执行一次元数据COPY。 - 引入分布式因果时钟或在底层提交时重新生成时间戳: 墙钟时间戳配合写锁内的 $+1\text{ ns}$ 单调护栏已是无需修改存储格式的最小正确修复。
- 仅做单池内的单调护栏: 普通读取仅返回单池对象信息,若只在单池内维护单调性,发送端可能发出落后于其他存储池的旧修订;因此必须跨池计算统一的最高修订值。
验证
R4 回归测试覆盖了加密模式 $\times$ 信任状态 $\times$ 时间戳矩阵,以及单盘与 16 盘纠删码后端上的有序序列;R5 额外包含多存储池标签删除回归测试,PR #196 的全部 11 项 CI 检查均已通过。
升级摘要见组件版本矩阵;阻止归一化副本元数据被重新注入的姊妹修复见副本元数据归一化。相关设计记录:复制标签排序 · 副本元数据归一化 · 请求头超时防护 · 副本元数据审计手册。
4.15 - SILO Server 20260903 发布前复审
本文记录 SILO 20260903(RELEASE.2026-09-03T13-18-01Z,对应提交 9b11dc9469e650815b775cb47b039610644f5da4,候选复审提交 6e112d1856d4f3655f30fc81ee47e9f43d50d8f3,对比基线 RELEASE.2026-08-06T00-00-00Z 即 3be10fcc1a44f6620ded0bd303461f9d688cca23)的发布前工程复审:第二轮复审发现了哪些遗漏缺陷、修复边界如何收窄,以及发布门禁如何组织。后续九月与十月版本的修复见复制可靠性、Server 20260916 与 Server 20261018。
为什么需要第二轮审查
第一轮实现修复了大多数已知问题并通过了现有测试。第二轮复审进一步检查各安全不变量在所有合法协议形态与异常状态下是否严密:
- 同一条不变量能否被另一种合法的请求格式(wire representation)绕过?
- 鉴权前的快路径是否仍会触发磁盘 I/O 或修改内存状态?
- 元数据存在但加载失败时会发生什么?
- 两条各自正确的“读-改-写”路径是否共享同一个串行化边界?
- 请求头清洗是否保留了完整的 SigV4 流式签名状态?
- 验收记录对应的是最终代码树,还是此前的中间提交?
- 复杂的防御逻辑对应的是已复现的故障,还是仅停留在假想场景?
第二轮复审在首轮实现之上定位出多处真实缺陷,并将每项修复与验收结论严格收窄到具体的不变量与代码提交。
分领域复审结果
| 领域 | 第二轮复审发现 | 最终解决方案 | 状态 |
|---|---|---|---|
| 桶元数据 | 各自独立的配置锁会丢失共享 .metadata.bin 记录中的并发更新(#102) |
用一把有界的 metadata.lock 覆盖所有整记录写入、迁移、导入、纳管与自愈路径;站点复制仅合并变更字段,避免旧快照覆盖整条记录 |
候选已关闭 |
| 桶创建 | ForceCreate 与站点纳管可能用默认值覆盖已有桶配置 |
保留已有记录,仅更新创建与纳管状态;补充防覆盖回归测试 | 候选已关闭 |
| Object Lock | 直接将配置字节与单一标准 XML 比对,会漏判包含默认保留规则(Default Retention)的合法配置 | 先解析 Object Lock 配置,再从启用状态推导版本控制不变量;验证更新、读回与磁盘重载 | 候选已关闭 |
| 预鉴权 CORS | 任意 URL 路径段都会触发元数据读取与缓存膨胀 | CORS 查询仅读取常驻内存的元数据,不触发对象层 I/O | 候选已关闭 |
| CORS 启动期 | 元数据尚未初始化时,非常驻桶名可能回退到全局 CORS | 保持显式的启动期默认拒绝(fail-closed)状态 | 候选已关闭 |
| CORS 加载失败 | 真实桶一旦丢失加载失败记录,就会与不存在的桶混淆,导致预签名请求回退到全局 CORS 配置 | 维护有界的失败桶集合,在成功加载、删除与刷新路径上清除记录;集合内的桶保持默认拒绝 | 候选已关闭 |
| CORS 恢复 | 按需调用 GetConfig 成功重载后,最初未清除加载失败标记 |
提交 84e1580a4 补齐单行状态清除,并增加定向并发竞态测试 |
候选已关闭 |
| 复制信任 | 多个处理函数仅凭客户端可控的内部请求头就放行特权分支 | 先完成鉴权,要求精确标记并具备 s3:ReplicateObject 或 s3:ReplicateDelete 权限,将决策写入私有上下文后再清洗请求头 |
候选已关闭 |
| 流式上传 | 清洗请求头时克隆出的请求对象起初未共享原请求的 Trailer 映射 |
保留 Trailer 映射,确保流式上传末尾到达的校验和可见 |
候选已关闭 |
| Snowball | 请求级的信任标记可能在归档解压条目之间串用 | 按归档条目独立推导信任状态,并在并发工作协程间保留请求默认值 | 候选已关闭 |
| SSE-C | 零字节读取与 GetObjectAttributes 可能跳过客户端密钥认证 |
强制要求成功解封客户端密钥;仅对已授权的副本请求保留独立豁免分支 | 候选已关闭 |
| 删除授权 | 指定版本删除仅检查普通删除权限,未要求 s3:DeleteObjectVersion |
统一单对象与批量删除的权限检查,复制删除继续检查 s3:ReplicateDelete,并保留鉴权与审计上下文 |
候选已关闭 |
| 管理授权 | 用户与组状态变更始终检查启用权限 | 按请求的目标状态检查对应的启用或禁用权限 | 候选已关闭 |
| Checksum | 分片上传与复制路径遗漏校验和字段、接受非法组合,或在压缩/加密后的字节流上计算校验和 | 补齐算法与类型校验、服务端分片校验和计算、联邦透传、AWS 错误码及 CopyObject 流变换顺序 |
候选已关闭 |
| 发布证据 | 全量验收记录最初对应的是后续仍有改动的中间提交 | 分别记录 ebac0ca73 的全量验收结果与最终候选提交的定向门禁 |
证据缺陷已关闭 |
定义候选的四条不变量
信任只在鉴权后推导一次
看似内部专用的请求头本质上仍是客户端输入。请求必须先以原始签名形态通过既有鉴权路径,随后处理函数才能综合以下四项条件推导复制特权:
- 唯一且精确匹配的复制标记;
- 非匿名的已认证身份;
- 目标资源上的
s3:ReplicateObject或s3:ReplicateDelete权限; - 仅限副本(replica-only)语义所要求的副本状态。
推导结果存入私有请求上下文;后续剥离内部请求头仅作为面向旧调用链的纵深防御,而非权限来源。
这一顺序至关重要:SigV4 签名可能覆盖这些请求头,若在鉴权前剥离,合法的复制请求就会因 SignatureDoesNotMatch 被拒绝。此外,清洗后的克隆请求必须与原请求共享 Trailer 映射,因为流式上传的校验和会在请求体读取完毕后通过 trailer 到达。
完整的接收端信任模型见鉴权前不做 I/O,Header 不产生权限。
共享记录只有一个写边界
桶策略、生命周期、服务端加密(SSE)、标签、配额、复制、Object Lock、版本控制与 CORS 在逻辑上各自独立,但在存储层同属一条桶元数据记录。按字段拆分的互斥锁无法保护整条记录的“读-改-写”周期。
修复方案没有引入重型的事务层,而是统一收敛到单一的元数据写锁:
该锁不涉及对象数据 I/O,生命周期严格限制在单次桶元数据操作内。元数据迁移与自愈同样会替换整条记录,因此也必须受该锁保护。站点复制的接收端仅合并发生变更的字段,防止远端较旧的快照覆盖本地其他配置。
区分 CORS 加载失败与桶不存在
CORS 热路径必须明确区分以下四种状态:
| 状态 | 处理结果 |
|---|---|
| 元数据子系统尚未初始化 | 不返回 CORS 响应头 |
| 已知存在的真实桶,但元数据加载失败 | 不返回 CORS 响应头 |
| 内存常驻桶且配有桶级 CORS 规则 | 按该桶的 CORS 规则求值 |
| 无常驻元数据,且无已知的加载失败记录 | 回退至服务端全局 CORS 配置 |
表中第二行是保留失败桶集合的原因。预签名 URL 由签名本身完成授权,可在不经过桶策略求值的情况下访问私有对象;此时桶级 CORS 配置就是浏览器跨源访问的唯一边界。如果丢弃加载失败状态并回退到宽松的全局 CORS 配置,这一隔离边界就会失效。
该集合仅在真实桶加载失败时记录,规模受实际桶数量约束,并通过 noteLoadFailure 与 clearLoadFailure 两个辅助函数维护。成功加载、删桶、陈旧桶清理、刷新、重置与并发加载均已补齐测试覆盖。
按解析后的 XML 语义归一化 Object Lock
任何合法且处于启用状态的 Object Lock 配置都要求开启版本控制。XML 空白字符、元素顺序以及是否包含默认保留规则(Default Retention)都不会改变这一语义。因此,状态归一化必须在解析 XML 之后执行,而不能通过与单一标准 XML 进行字节比对来判断。
开启 Object Lock 后的版本控制状态固定为纯粹的 Enabled;暂停状态(Suspended)与前缀排除扩展均与 Object Lock 不变量冲突,会在配置更新、读回和磁盘重载时自动剥离。
复杂度审计
发布前复审专门排查了过度设计、重复逻辑、缺乏威胁模型的防御性代码,以及陈旧的兼容机制。
因保护已复现故障而保留
- 单一元数据锁: 确定性的跨配置类型并发测试已复现更新丢失。
- CORS 删除墓碑(tombstone): 缺少墓碑记录时,站点复制无法区分“配置已删除”与“从未配置”。
- CORS 加载失败状态: 预签名 URL 提供了已通过认证但绕过桶策略的具体反例。
- 两级复制信任判定: 普通复制与副本密文/SSE 透传具有不同的请求特征与权限要求。
- 鉴权后清洗请求头: 在 SigV4 验签前剥离请求头会破坏合法的签名请求。
- 多存储池与
null版本边界测试: 单池常规路径无法覆盖它们捕获到的跨池状态选择缺陷。
删除或收窄的复杂度
- CORS 失败集合的修改统一收口到
noteLoadFailure与clearLoadFailure。 - 站点复制导入路径改为仅应用变更字段,不再整条覆盖可能过期的记录。
- 删除废弃的加密辅助函数、无调用的事件目标函数与死代码分支。
- 兼容性守卫不再逐一枚举所有导出的源码符号,改为保护实际对外暴露的路由及冻结的协议/配置表面。
- 移除旧的
wait_pipe静态检查豁免,并以gomodguard_v2替代已弃用的配置。 - 动态超时测试不再从并行测试包中调用全局
rand.Seed。 - 服务端依赖从临时
silo-go分叉切回经审查的上游兼容minio-go版本。
刻意没有引入
- 不引入通用的元数据事务框架;
- 不引入第二套 CORS 缓存或无界的负缓存;
- 不新增公开的“可信复制”请求头;
- 不设置让服务端发布受制于后续 Console 或文档站发布的跨仓库门禁;
- 不在候选版本中塞入半成品的条件删除契约;
- 不在缺少专项收敛测试的前提下重写继承的站点复制状态寄存器。
延期事项,以及为什么严重度不同
| 事项 | 分类 | 20260903 发布决定(及后续状态) |
|---|---|---|
| 条件删除 #10 | 继承的 S3 功能缺口;影响假定服务端会强制校验 If-Match / 单对象 ETag 的调用方 |
0903 发布说明中明确标注;单对象条件删除已随 20260916(#145)交付,批量 ETag 条件删除仍待实现 |
| 多站点配置删除 #77 | 桶策略、SSE、标签与配额的跨站点删除收敛缺陷;CORS 已使用独立的修复寄存器 | 0903 作为多站点删除配置时的部署注意事项;已随 20260916 修复 |
ListMultipartUploads #79 |
继承的列表格式一致性缺口 | 作为已知问题记录,不影响常规分片上传的数据完整性 |
联邦 CopyObject #99、#100 |
旧版联邦后端的校验和与内联小对象兼容缺口 | 0903 仅影响特定联邦特性;已随 20260916 修复 |
| ILM 迁移 PR #60 与通用 SSE 议题 #61 | 新功能需求 | 不属于本次发布的安全与稳定性范围 |
证据
完整验收树
全量本地验收基于提交 ebac0ca73bbf251b070bb6df4d8005015841f901:
- 完整的
cmd与internal测试套件通过; - 完整的
cmd并发竞态套件(-race)耗时 365.448 秒,全部通过; - 静态检查(lint):0 告警;
- 品牌重命名/兼容性守卫与生成文件检查通过;
govulncheck扫描无可达漏洞;- 六种
make verify部署拓扑验收:174 PASS / 0 FAIL。
前两次运行 make verify 在拉取 mcli 时因网络代理与环境准备中断,并非测试用例失败。最终通过的运行使用了本地固定校验和的 mcli 二进制,保留访问 GitHub 的出站代理、对 localhost 绕过代理,并将 GNU 工具链置于 PATH 首位。
验收后的候选
全量验收之后唯一的代码改动是 84e1580a4:在按需重载桶元数据成功后清除对应的 CORS 加载失败标记。随后的候选合并未改动业务代码,6e112d185 仅更新了 Helm Chart 发布元数据与文档。最终候选提交通过了以下门禁:
git diff --check;- CORS 与 Object Lock 定向
go test -race; - 品牌重命名与兼容性守卫(
rebrand-guard); - 生成文件一致性检查;
- 静态检查(lint)0 告警;
- Helm Chart 语法检查、默认与可选模板渲染、Chart 打包,以及七类资源的旧版升级身份守卫。
Go、No-Go 与发布门禁
代码结论:GO
两轮复审确认的代码缺陷在候选版本中均已闭合。各项修复直接作用于对应不变量所在的模块边界,保留的防御机制均有可复现的故障反例支撑。
生产发布门禁
在正式发布 RELEASE.2026-09-03T13-18-01Z 前,候选版本依次通过了七道发布门禁:
- 候选提交推送至远端并完成代码审查;
- 远端 CI 与测试发布流水线在最新提交上全部通过;
- 预发布标签精确指向经审查的 Chart 7.0.2、Server 0903 与 Client 0903 代码树;
- 构建产物、校验和、SBOM、构建证明(attestation)与签名 RPM 包全部通过校验;
- 正式发布与容器镜像流水线完成 classic 与 distroless 两种镜像的构建与推送;
- 匿名下载与镜像拉取验证通过;
- 基于正式标签更新发布说明并完成文档站部署。
20260903 部署注意事项
升级至 20260903 时需满足以下部署前提:
- 在同一维护窗口内完成分布式集群所有节点的同步升级;
- 在启用桶级 CORS 配置前,先升级站点复制组内的全部成员;
- 核对并更新涉及
s3:DeleteObjectVersion及用户/组状态启停权限分离的 IAM 策略。
重启与读回验证(2026-09-11,#116)
后续的 #116 专项验收(2026-09-11 执行)测量了集群在有界重启窗口内的写入确认与读回一致性,验证方法涵盖四个要点:
- 确认台账。 每次返回成功的 PUT 都写入带版本的唯一对象键,并在收到响应时立即记录
VersionId、字节数与 SHA-256。后续读回按精确的VersionId获取对象并同时校验三项指标,防止早期确认的写入被后续写入静默覆盖。 - 读回时机。 数据探针(canary)写入成功后,分别在 15 秒、30 秒和 60 秒经由所有对端节点周期性重读;最终检查同时覆盖单节点中断期间写入的对象及其重新加入集群后写入的对象。
- 禁用重试掩盖。 每次探针操作设置 60 秒硬超时(涵盖连接建立、请求发送、响应体读取与等待间隔),并关闭客户端 SDK 自动重试,防止恢复窗口内的短暂失败被客户端重试掩盖。
- 容器拓扑。 单机运行四个拥有独立网络标识的 Linux/arm64 容器,每节点挂载一块盘(EC 2+2);
tmpfs卷由占位容器在整个停机窗口内保持挂载;四个节点以 10 秒宽限期并行优雅停止,随后并行启动。
管理接口就绪不代表数据面就绪。 原始测试记录对比了 0806、0903 与当时的 main 修复构建。main 构建在重启后 2.461 秒通过管理接口检查,但随后又经过 14.489 秒才完成首次数据探针读写。在该启动窗口内,admin/health 探针无法反映数据面状态,滚动升级脚本必须验证实际的数据面读写,而不能依赖固定时长的等待。
后续各项修复的交付状态见组件与源码矩阵。
4.16 - 多池覆盖亲和:为何最终没有采纳 #245
2026 年 10 月 24 日决定:不合并本次测试的多池覆盖亲和补丁(PR #245)。 该方案解决了跨池覆盖遗留旧副本的空间占用问题,但无论完整策略还是仅限普通无版本覆盖的收窄策略,都未能在性能测试中达到可接受的权衡。两个实验分支(codex/healthy-pool-affinity 与 codex/unversioned-pool-affinity)已保留归档,服务端 main 分支保持原状。
Denis Babchuk 提出的思路是:覆盖已有对象时,优先写回已存放该对象的池。代码评审发现并修复了请求级写 quorum 判断缺陷,收紧了健康池判定规则,并通过了功能验证。但随后的性能实测改变了结论:完整策略使深版本历史的写入成本翻倍;第二版排除版本桶后,普通小对象覆盖写入仍有明显退化。
后续的多池删除修复(#257)已于 10 月 24 日独立合并:在保留按容量加权分配的前提下,先清理非权威池中的旧副本,再删除决定当前读取结果的权威副本,防止删除中途报错导致旧值暴露。
问题背景:跨池覆盖会在旧池留下过时副本
假设无版本对象 reports/latest.json 位于池 A,下一次 PUT 将新内容写入池 B。后续读取仍会选出池 B 中最新的逻辑对象,但池 A 中的旧物理副本会继续占用磁盘空间——普通覆盖写成功后,并不会跨池清理其他池里的旧副本。
基线代码在查找到可读的既有对象后,仍会再次进入按剩余容量加权的选池流程。视各池使用率不同,反复覆盖同一个 key 最终可能在两个池中都留下池级记录(xl.meta)。
这一行为源于如下历史演进:
- 上游 MinIO issue #21087 报告单池上传可能绕过最小剩余空间阈值;2025 年 7 月 19 日合入的 PR #21115(
83b2ad418b)为可读旧对象的覆盖写也强制执行了容量选池,从而丢掉了原有的原池亲和性。 - SILO issue #199 与 PR #207 修复了跨池当前逻辑对象的条件写校验,同时保留了按容量加权的分配策略。
- 池迁移通过独立机制防止旧内容覆盖新内容及删除标记丢失,详见多池对象一致性。
覆盖亲和的空间收益主要集中在多池部署下对同一批无版本 key 的高频覆盖。单池部署不存在跨池重复;新 key 仍需按容量跨池分配;版本桶则必须保留所有历史版本,集中到单个池并不会减少需保存的数据量。
原始 PR 的设计方案
提案复用了现有的对象查找和锁边界:若最新可读对象已存在,则在满足写 quorum 和硬空间阈值的前提下优先选择其所在池;新 key 继续按容量加权分配;当原对象所在的纠删码组无法接受写入时,先排除该池再从其余池中分配。
生产代码修改集中在 cmd/erasure-server-pool.go(新增 73 行、删除 3 行,附带 331 行放置测试),作用于进入共享选池路径的 PUT 和分片上传初始化。
选池过程涉及两条容量规则:
- 软分配预留(15% reserve):存在可用替代池时,分配器会过滤掉使用率超过约 85% 的池,优先填充较空的池。
- 硬空间检查(
hasSpaceFor):结合写入大小、单盘容量及diskFillFraction(0.99)执行硬性门槛检查。
当原池健康且有足够空间时,亲和策略会绕过软分配预留,同时保留硬空间检查。代价是在集群扩容后,既有热点 key 会继续留在较满的旧池中更新,而不会随覆盖写自动迁移到新池。
如果只是简单删掉“已有对象重新选池”的分支,就会丢失 83b2ad418b 引入的容量检查与故障回退。仅检查剩余空间同样不够:当某个纠删码组仍有足够在线盘读取旧对象、但在线盘数已不足以写入新对象时,选池必须提前识别;由于选池发生在消费上传流之前,一旦不可重放的请求体被消费,写入失败后无法无代价地换池重试。
正确性评审与实现改进
写 quorum 必须按本次请求计算
原始 helper 对所有写入一律按 STANDARD 存储类推算写 quorum。双池受控测试暴露了使用 REDUCED_REDUNDANCY(RRS)覆盖时的回归:
| 条件 | 测试设置 |
|---|---|
| 存储策略 | optimize=capacity;STANDARD=EC:4,REDUCED_REDUNDANCY=EC:2 |
| 已有对象 | 使用普通 STANDARD 奇偶校验创建 |
| 原对象所在组 | 16 盘中 13 盘在线,已用空间 90% |
| 另一池 | 16 盘全部在线,空间充足;RRS 探针写入成功 |
| 新请求 | 通过 PUT 或新分片上传初始化发起 RRS 覆盖写 |
由于校验片越少、数据片越多,RRS(EC:2)实际需要 14 块在线盘才能写入,而原始预检查按 EC:4 只要求 12 块写盘,误判原池可写,最终在写入阶段因盘数不足返回 InsufficientWriteQuorum(映射为 HTTP 503)。基线代码通过 85% reserve 过滤掉已用 90% 的原池,路由到健康的第二池并全部成功;原始 PR 在两个原池位置 × 两种入口的 4 个场景(重复 3 轮共 12 次尝试)中全部失败。
PUT 的内部 MaxParity 选项还会触发反方向的误判:实际允许使用 EC:8 的写入被按 EC:4 评估,导致不必要的跨池迁移。
仅优先选择完全健康的原池,并明确回退规则
改进后的实现将请求的存储类和 PUT MaxParity 一并传入选池逻辑,严格对齐写入路径的 availability / capacity 校验片计算规则(包括数据片与校验片相等时额外要求的写盘数),并保持分片初始化的既有 parity 语义:
| 原对象所在的目标纠删码组 | 处理方式 |
|---|---|
| 完全健康、满足本次请求的写要求且空间充足 | 优先留在原池 |
| 已降级,但仍可写且空间充足 | 走原有容量分配器,原池仍作为候选池参与筛选 |
| 已知不可写或空间不足 | 在 reserve 过滤前排除原池,再从其余池中选择 |
| 其他池均无空间,原组有空间但不足写 quorum | 交由原组返回真实的 InsufficientWriteQuorum,避免误报 StorageFull |
| 所有池均无可用空间 | 保留 StorageFull 错误 |
健康度与容量检查共享同一次组级 DiskInfo 快照:只有在线、查询成功且总容量为正的磁盘才计入可用写盘;DiskInfo 调用失败的磁盘一律按不健康处理。
回归测试同步覆盖了请求级 parity、已知坏盘、降级组、扫描次数、异构组大小、最新对象选取以及既有分片上传会话,并为分片上传回退测试配置了独立的初始对象(避免前置 PUT 先一步把最新副本迁走而削弱测试前置条件)。
第二版设计:仅对普通无版本覆盖启用
第一轮性能测试暴露出深版本历史的元数据重写放大后,第二分支(codex/unversioned-pool-affinity)将亲和范围收窄至普通无版本覆盖写:
| 请求或桶状态 | 收窄版行为 |
|---|---|
替换既有可读对象的普通无版本 PUT |
启用上述健康原池优先规则 |
| 覆盖普通无版本对象的新分片上传 | 仅在初始化进入选池时启用;保留已有上传绑定 |
| 已启用或已暂停版本控制 | 保持基线分配策略 |
指定显式版本 ID(含 null) |
不启用亲和 |
| 复制、复制锁协调或数据迁移 | 不启用亲和 |
| 内部元数据桶 | 不启用亲和 |
| 新 key 或单池部署 | 保持原有路径与容量行为 |
| 既有分片会话、普通 COPY 路由、最新删除标记、读 quorum 错误路由 | 保持各自原有路径 |
资格判定同时检查桶的版本控制配置与请求选项:启用了版本控制但配置了排除前缀的桶,或处于版本暂停(Suspended)状态、带有混合历史版本的桶,均继续使用基线分配器。
各阶段功能验证
| 阶段 | 验证结果 | 覆盖范围 |
|---|---|---|
| 原始 PR 初审 | base/head 对照复现正常亲和、STANDARD 回退、RRS 写入失败及 MaxParity 偏差 |
macOS 对象层单元测试 |
| 改进后的完整实现 | Go 全量测试:52 个包通过,1,823 个顶层测试(含子测试共 8,233 个)通过,12 个跳过;定向 -race:35 个顶层测试(含子测试 201 个)通过 |
隔离 APFS 测试卷上的完整亲和分支 |
完整版 mcli 验证 |
使用 mcli 20261018 验证普通覆盖读回、4 个不同内容的历史版本读取、分片覆盖及校验和 |
单机本地目录部署 |
| 收窄版实现 | Linux 定向测试:37 个顶层测试(含子测试 209 个)通过,覆盖放置、parity、健康/容量回退、版本状态排除、PUT/MPU 及条件写 |
放置与覆盖定向测试集 |
| 构建与静态检查 | Linux amd64 编译、make verifiers、lint(0 告警)与空白字符检查全部通过 |
仓库静态检查 |
定向回归测试筛选正则为:
性能测试环境与指标定义
两轮基准测试均在 m3 主机上执行(AMD Ryzen AI MAX+ 395,32 逻辑 CPU,61 GiB 内存,Linux 7.0.0-38,单块 CT2000P310SSD8 NVMe SSD,XFS 文件系统)。两个存储池各使用该 XFS 文件系统上的 16 个目录(共 32 个目录,单池单纠删码组,默认 EC 12+4)。
基线与候选分支均使用 Go 1.27.3(linux/amd64,CGO_ENABLED=0)编译。服务端绑定 CPU 4–11(GOMAXPROCS=8),客户端绑定 CPU 12–15(GOMAXPROCS=4),通过 minio-go v7.4.1 走本机回环 HTTP 压测。
- 吞吐(operations/s):每秒完成的对象操作数(分片上传每次操作含初始化、2 个分片上传及完成请求)。
- P50 / P99:取各轮运行 50 / 99 分位延迟的中位数。
- CPU 成本:每完成一次操作消耗的服务端 CPU 时间。
- 块 I/O:取自
/proc/PID/io的进程级读写字节数。 - 活动桶空间:对象数据文件、元数据与目录占用之和(不含临时目录)。
第一轮结果:完整策略导致深版本历史写入显著退化
第一轮对比完整候选分支(0aa870115d7d)与基线(789a2327ba8c),共执行 54 次测量运行(完成 243,718 次操作,0 失败)。基础矩阵每项运行 3 对 15 秒测试,新 key 与 4 KiB 覆盖追加 2 对 60 秒长测,单对象版本测试固定写入 4,000 个新版本,另在存量双副本数据上执行 4 轮 ABBA 二进制切换测试:
| 场景 | 基线 → 完整版,次/秒 | 变化 | P50,ms | P99,ms | 样本 |
|---|---|---|---|---|---|
| 双池 4 KiB 覆盖,256 key、16 并发 | 236.9 → 174.5 | −26.3% | 30.4 → 74.4 | 277.9 → 307.4 | 每版 2 × 60 秒 |
| 双池 1 MiB 覆盖,64 key、8 并发 | 140.9 → 132.2 | −6.2% | 34.2 → 41.3 | 169.3 → 176.2 | 3 × 15 秒 |
| 双池 16 MiB 分片覆盖,16 key、4 并发 | 25.8 → 26.4 | +2.1% | 144.0 → 141.7 | 265.3 → 272.7 | 3 × 15 秒 |
| 双池单个 4 KiB key 覆盖,1 并发 | 135.2 → 136.4 | +1.0% | 6.9 → 6.8 | 12.0 → 10.6 | 3 × 15 秒 |
| 双池 4 KiB 新 key,16 并发 | 290.7 → 296.8 | +2.1% | 46.9 → 48.7 | 170.2 → 165.9 | 2 × 60 秒 |
| 单池 4 KiB 覆盖,16 并发 | 166.2 → 159.2 | −4.2% | 83.9 → 89.4 | 296.8 → 293.2 | 3 × 15 秒 |
| 双池单个 4 KiB key,新增 4,000 版 | 76.6 → 53.0 | −30.8% | 11.4 → 17.8 | 47.7 → 59.5 | 3 次固定操作数 |
| 已有双份副本,切换程序 ABBA | 198.6 → 202.0 | +1.7% | — | 291.2 → 290.9 | 2 × 20 秒 |
普通 4 KiB 覆盖的两对 60 秒长测分别下降 30.7% 和 21.8%,此前三对 15 秒短测也全部下降;但每次操作的 CPU 时间反而从 5.85 ms 降至 5.33 ms,单次块写入保持在约 128 KiB。
单对象 4,000 版本测试下降 30.8% 的原因则非常直接:池内 xl.meta 保存了该池内所有版本的元数据(以及小对象的内联数据),每次新增版本都要重新序列化并整文件重写 xl.meta。基线按容量分配将 4,000 个版本大致均分到两个池(每池约 2,000 个版本),而亲和策略将全部 4,001 个版本集中写入同一个池,导致每次 PUT 重写的 xl.meta 体积翻倍:
- 单次写入 CPU 耗时:26.61 ms → 39.29 ms
- 单次写入块读取:12.20 MiB → 24.42 MiB
- 单次写入块写入:12.27 MiB → 24.49 MiB(4,000 次写入上传正文共约 15.6 MiB,累计块写入从 47.9 GiB 增至 95.7 GiB)
- 4,001 版本最终活动数据量:两版均为约 48.90 MiB
- 最后 1,000 次写入平均延迟:18.4 ms → 28.5 ms
集中历史版本既不能节省空间,又让更新成本随版本深度成倍放大,因此第二版直接排除了版本桶写入。
第二轮结果:排除了版本放大,但无版本小对象覆盖依然退化
第二轮对比收窄版(codex/unversioned-pool-affinity)与同一基线(789a2327ba8c)。测试前设定的准入线为:普通覆盖吞吐损失不超过 5%,P99 延迟增长不超过 10%,且 P50 与排除范围场景无实质退化。
四个场景每版各运行 5 次(AB/BA 交替,常规场景预热 5 秒、测量 60 秒,版本对照场景固定写入 4,000 次,每轮重建服务与数据目录,关闭性能分析器插桩):
| 场景 | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms |
|---|---|---|---|---|
| 新建数据集,4 KiB 覆盖,256 key、16 并发 | 167.9 → 158.2 | −5.7% | 66.2 → 75.8 | 470.2 → 428.7 |
| 新建数据集,1 MiB 覆盖,64 key、8 并发 | 79.45 → 79.58 | +0.2% | 94.0 → 95.6 | 521.5 → 483.2 |
| 单个 4 KiB key 新增 4,000 版,对照项 | 48.5 → 59.6 | +22.8% | 12.7 → 12.4 | 180.9 → 135.4 |
| 4 KiB 新 key、16 并发,对照项 | 289.8 → 243.1 | −16.1% | 40.4 → 49.2 | 314.9 → 170.0 |
在核心的 4 KiB 普通覆盖场景中,五对运行的吞吐变化分别为 +11.9%、−14.9%、−1.4%、−10.3%、−12.8%(配对中位数变化 −10.3%,两版中位数比值 −5.7%,P50 上升 14.4%)。基线自身在 141.4–202.6 次/秒之间波动(变异系数 13.0%),收窄版为 146.3–176.6 次/秒,未能满足“吞吐损失 ≤ 5%”的准入线。
收窄范围确实消除了版本历史重写放大:单版本块写入为 12.272 → 12.268 MiB,块读取为 12.201 → 12.197 MiB,CPU 为 26.98 → 27.26 ms(版本对照项中五对均为后运行的二进制更快,而未改逻辑的新 key 对照项逐对波动达 −44.3% 至 +15.3%,反映出共享单盘测试环境的顺序波动)。
固定初始数据布局的恢复对照测试
新建数据集测试中,两种策略会随运行逐步写出不同的磁盘目录布局。为排除布局演化差异,追加的 8 轮夹具测试在每轮运行前恢复完全相同的初始数据快照——“每 key 单池副本(共 256 条记录)”或“每 key 双池副本(共 512 条记录)”——每次恢复后静置 30 秒,再预热 5 秒、测量 60 秒:
| 初态,256 个 key | 基线 → 收窄版,次/秒 | 变化 | P50,ms | P99,ms | 结束时池内记录数,基线 → 收窄版 |
|---|---|---|---|---|---|
| 每个 key 一份副本 | 188.0 → 160.1 | −14.9% | 51.9 → 68.0 | 521.0 → 466.4 | 512 → 256 |
| 每个 key 两份副本 | 185.7 → 140.1 | −24.5% | 37.6 → 79.7 | 460.0 → 629.9 | 512 → 512 |
单份副本初态的两对运行分别为 −0.9% 和 −23.5%;而双份副本初态的两对运行分别下降 −22.6% 和 −26.5%,P50 暴涨 +111.9%,P99 上升 +36.9%,且磁盘上原有的 512 份记录一份也不会减少。对于已经积累了跨池旧副本的存量集群,这意味承担显著的写入性能退化却无法立即回收任何空间。
根因定位与 I/O 路径实验
阶段计时显示,新增的健康检查每次仅耗时 0.014–0.016 ms,远小于对象查找(毫秒级)和纠删码写入(数十毫秒级)。第二轮 4 KiB 覆盖的单次 CPU 时间从 5.75 ms 降至 5.36 ms,吞吐却反而下降,说明瓶颈来自存储路径上的 I/O 与调度等待。
Go 执行跟踪(execution trace)显示系统调用等待集中在 RenameData → writeAllInternal → write 以及读取和 goroutine 调度等待。
随后我们做了一项局部 I/O 实验:将小元数据写入(约 791 字节,小于 4 KiB 对齐粒度,CopyAligned 会自动关闭 O_DIRECT)从 Linux O_DSYNC 路径改走 writeAllDirect(实际等价于缓冲写加独立 fdatasync)。“收窄亲和 + I/O 修改”五轮正式运行均值达到 179.4 次/秒(对比基线 167.9 次/秒,+6.9%,P99 −1.5%,逐对波动 −19.2% 至 +35.9%)。但在两对同时给基线和候选分支都加上该 I/O 修改的对照实验中,“基线 + I/O 修改”达到 212.5 次/秒,“收窄亲和 + I/O 修改”为 185.2 次/秒(中位数差值 −12.8%,逐对 +37.7% 与 −49.1%)。由于修改小元数据同步写路径会影响全局存储引擎,且开启后亲和分支依然落后于同条件基线,该构建覆盖补丁未被采纳。
收益与代价汇总
在双池新建数据集测试中,256 个普通 4 KiB key 结束时的池内记录数从 512 份降至 256 份,活动数据逻辑体积从 6.18 MiB 降至 3.09 MiB;1 MiB 场景记录数从 128 份降至 64 份,体积从 171.56 MiB 降至 85.78 MiB。
从集群稳态看,若逻辑数据中有比例为 $f$ 的对象会在双池间产生第二份副本,消除额外副本可节省的总体空间比例为 $f / (1 + f)$(例如当 $f = 10%$ 时约节省 9.1%)。单次请求的写入量则不会减半:两版在 4 KiB 覆盖下的单次进程块写入均为 128 KiB 左右。
| 维度 | 收益 | 代价或局限 |
|---|---|---|
| 容量空间 | 避免符合条件的无版本覆盖写产生新的跨池旧副本 | 不清理存量旧副本;故障回退时仍会产生跨池副本 |
| 写入性能 | 原池健康时可省去一次全池容量筛选 | 改变落盘分布与 I/O 争用;4 KiB 小对象覆盖吞吐退化(新建数据 −5.7%,双副本初态 −24.5%) |
| 扩容与热点 | 既有对象池位置固定 | 扩容后旧池继续承接热点 key 更新,新池分担的覆盖写流量减少 |
| 协议与格式兼容 | 不改 S3 协议、配置项、依赖或磁盘格式 | 池间容量分布与原有运维预期不同 |
| 可用性与正确性 | 请求级写 quorum 与坏盘检查避免误选不可写原池 | 预检查与实际写入之间仍存在状态变化窗口 |
| 维护复杂度 | 收窄至无版本覆盖后避免了版本 xl.meta 重写放大 |
需持续同步 parity、桶版本状态、内部写、reserve 规则及 PUT/MPU 选池入口 |
| 版本回滚 | 退回基线二进制无需转换磁盘格式 | 已产生的布局变化与跨池旧副本不会自动消除 |
最终决定与后续重启条件
- 完整亲和方案(
codex/healthy-pool-affinity):因集中版本历史导致xl.meta重写 I/O 翻倍、4,000 版本写入吞吐下降 30.8% 且无空间收益而否决。 - 无版本收窄方案(
codex/unversioned-pool-affinity):虽消除了版本历史放大,但在普通 4 KiB 覆盖上仍存在 −5.7%(配对中位数 −10.3%)的新数据吞吐退化,且在存量双副本恢复测试中出现 −24.5% 的吞吐退化(P50 +111.9%)而否决。
因此,我们决定保持主线分配器行为不变,归档两轮基准测试证据(第一轮 54 次运行 / 243,718 次操作;第二轮 53 次运行 / 495,745 次操作)。后续若重新评估该问题,应先采集生产环境的真实旧副本空间占比并在多块独立物理盘拓扑上压测,再权衡是采用后台旧副本清理还是显式可选的放置策略。
源码与证据索引
公开参考资料:PR #245、上游容量修复 PR #21115、多池对象一致性,以及基线存储实现 cmd/erasure-server-pool.go、cmd/xl-storage-format-v2.go 与 cmd/xl-storage.go。
| 制品 | 精确标识 |
|---|---|
| 初次独立评审的 base / PR head | 856f8f4c5580d1c5adacbd8ef2aedf3a4fbc507c / 3a9cdc731528e31bc4f9e61256025a1baae863e8 |
| 撰文时观察到的 rebase 后 PR head | 0a6cad318409451b52dfcef18efb476c3927b6f8 |
| 两轮 m3 共同基线 | 789a2327ba8c2283cda7192eb65f8374a7f919ba |
| 完整本地分支 | codex/healthy-pool-affinity(0aa870115d7d0904791104b25ad66f3430579de4) |
| 收窄本地分支 | codex/unversioned-pool-affinity(76db3626eb9068b29e4565e78ad22f7720012e65) |
| 收窄版性能二进制对应的生产语义 | bb4b987825985481e5047f94c60f560be85b9c19 |
第二轮 results/analysis.json SHA-256 |
68eec465749b625c691abcace90fc71f103b5826ccab4c40a120f39e29ded368 |
| 完整候选补丁 SHA-256 | af833ec4b7bbbc22fca8539a6da40f4fe5fa93d61febabbd5b08a63ae1ecbe1c |
4.17 - 副本元数据归一化:可信复制不得重新注入什么
随 SILO Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布的 PR #194(修复提交 4fcdf37ce,合并提交 9f3037e941,采纳并保留 Mikhail Khadarenka 在 PR #187 中的实现),修复了可信复制接收端在恢复副本内部元数据时重新调用宽松请求头提取器、导致仅用于传输的 aws-chunked Content-Encoding 标记与 GHSA 已脱敏的用户元数据重新回流至落盘对象中的回归缺陷。
问题根因
普通 S3 PUT 路径在落盘前会对请求元数据执行归一化处理:从 Content-Encoding 中剥离仅用于流式传输分帧的 aws-chunked 标记,并删除 GHSA-76wf-9vgp-pj7w 缓解措施要求剔除的 X-Amz-Meta-X-Amz-Unencrypted-Content-Length 与 X-Amz-Meta-X-Amz-Unencrypted-Content-Md5 用户元数据键。
然而,当可信复制接收端为副本写入恢复内部复制元数据时,却以开启“允许复制(allowReplication)”标志的方式重新运行了同一个通用宽松提取器——把原始 HTTP 请求中的全部标准请求头与所有 X-Amz-Meta-* 用户元数据重新覆盖回已归一化的元数据映射表中。结果在可信副本写入路径上,服务端可能落盘并在后续 GET/HEAD 响应中返回:
Content-Encoding: aws-chunked(按 AWS SigV4 流式传输规范绝不能作为对象元数据持久化的纯传输编码),或未经拆分的aws-chunked,gzip整串(正确落盘值应为gzip);- 上述两个在普通写入中已被 GHSA 缓解措施剔除的
X-Amz-Meta-X-Amz-Unencrypted-Content-*用户元数据键; - 对于未携带独立 PAX 头的 Snowball 归档条目:意外继承外层归档请求的
Content-Type、Cache-Control、Expires与用户元数据。
底层对象数据字节本身并未受损,但持久化的 xl.meta 元数据被污染。该回归由 56fa63bfd(2026-04-15,针对 CVE-2026-34204 的复制请求头信任边界加固)引入——该提交的认证与信任边界保护本身完全正确并予以保留。
修复方案
提交 4fcdf37ce 仅修改 cmd/handler-utils.go,删除了布尔双模式辅助函数,将普通请求头提取与副本内部字段恢复彻底拆离:
- 普通提取器无条件跳过所有复制专用内部键;
- 新增专用副本提取器(
extractReplicationMetadata),仅遍历复制头到内部头的映射表,且仅恢复 6 个复制域专用字段:- SSE-C 密封密钥材料(
x-minio-internal-server-side-encryption-ssec-key), - SSE-C 密封算法(
x-minio-internal-server-side-encryption-ssec-seal-algorithm), - SSE-C 初始向量 IV(
x-minio-internal-server-side-encryption-ssec-iv), - 加密多段上传标记(
x-minio-internal-encrypted-multipart,空值标记按键存在性生效), - 实际逻辑对象大小(
x-minio-internal-actual-size), - SSE-C 校验和恒等映射;
- SSE-C 密封密钥材料(
- 副本提取器绝不重新读取标准受支持请求头(如
Content-Encoding、Content-Type等)或X-Amz-Meta-*用户元数据。
修复后各请求编码对应的落盘结果如下:
请求 Content-Encoding |
落盘存储的 Content-Encoding |
|---|---|
aws-chunked |
(无) |
aws-chunked,gzip |
gzip |
gzip |
gzip |
注:aws-chunked, gzip(逗号后带空格)仍存储带前导空格的 gzip,gzip, aws-chunked 仍存储整串;这两者属于普通 PUT 路径既有的解析现状,由测试按现状固定断言。
运维可见变化
- 可信 Snowball 归档条目: 可信 Snowball 条目不再继承外层归档请求的普通元数据。当条目不含 PAX 记录时,不再继承外层
Content-Type、Cache-Control、Expires与用户元数据;当含有 PAX 记录时,也不再继承条目自身未声明的外层字段。条目自身的minio.metadata.*依然生效,6 个复制域专用字段仍作用于已授权条目,归档的存储类型(Storage Class)仍正常继承。(仓库内置批量生产端调用PutObjectsSnowball时虽发送自动解压标记,但外层请求并不标记为可信副本,因此未受该继承路径影响。) - GHSA 脱敏键: 在副本元数据恢复时不再写回两个未加密长度/MD5 元数据键,与普通
PUT行为完全一致。 - 不变边界: 身份认证、权限门禁与复制信任语义保持不变;普通
PUT提取路径逐字节等价。
为什么升级服务端不会自动修复存量污染对象
升级至 SILO Server 20260916 会立即阻断新写入对象的元数据污染,但不会后台扫描或改写磁盘上的存量对象。运维中需注意两点影响:
- 权威源端仍被污染时的反复同步: 若权威源对象仍带有被污染的元数据(例如来自早期的双向复制回流),在与已归一化的目标副本比对时会持续检测到元数据差异,导致在自愈(heal)或重新同步(resync)中反复触发元数据复制。务必先修复权威源端的对象版本,再让下游副本收敛。
- 普通 S3 自
COPY的局限性: 普通 S3 向自身发起CopyObject并非通用的存量修复 API,因为它在版本化桶中会生成新版本或更新时间戳,而不会原地改写指定历史versionId的元数据。
只读副本元数据审计手册提供了可执行的存量清单扫描工具与分类规则,用于在改动前盘点受影响的对象版本。
存量元数据修复方案设计原则
针对审计手册识别出的历史受污染对象,制定修复流程时应遵循以下安全边界:
- 构建包含所有非当前历史版本的完整清单(不能仅扫描最新版本);
- 通过比对可信源版本或独立校验和来核验真实内容编码——绝不凭被污染的响应头猜测,也绝不因为头部写有
gzip就盲目执行解压重传; - 优先处理权威源端的精确版本,再让副本自动收敛;保留不可变清单与元数据备份,在小批量验证并演练回滚后再推广;
- 修复操作必须保持版本 ID 与当前版本关系、Object Lock 保留期与法务锁定、对象标签、复制状态及加密上下文不变;对缺乏受支持修复路径的对象保持原状,严禁直接手动编辑磁盘上的
xl.meta文件。
已知范围边界
- 浏览器
POST表单上传路径(cmd/bucket-handlers.go)直接调用底层提取器,原本即未对aws-chunked执行编码归一化;该路径不属于复制接收端,作为独立跟进项记录。
验证
回归测试(TestExtractReplicationMetadata*、TestAPIReplicaContentEncoding、TestAPISnowballReplicaContentEncoding 以及开启 -race 的复制信任与 SSE-C 往返测试)在真实单盘与 16 盘纠删码后端上覆盖了映射表、6 个复制恢复字段与普通路径等价性(共 64 个 HTTP 叶级用例:44 个对照通过,20 个缺陷用例在修复前失败、修复后通过,外加 16 个辅助函数单元测试)。
升级摘要见组件版本矩阵;姊妹修复见复制标签排序。相关设计记录:复制标签排序 · 副本元数据归一化 · 请求头超时防护 · 副本元数据审计手册。
4.18 - 请求头截止时间:绝对头部上限与滚动的正文空闲超时
本文记录 Server HTTP 读取截止时间的修复设计,该修复通过 PR #196 合入主干(提交 055030ea5),随 Server 20260916(40220bd836cbd066ca424fa4dc5dbb90057fb55a)发布。
排查源于慢速 HTTP DoS 扫描器报告(#183),并通过两组实验验证:一是直接 TCP 对照实验(请求头限制设为 100 ms、实际分批发送耗时 400 ms,标准 Go 服务拒绝该请求而旧版服务返回 204);二是单盘进程探针验证(通过命令行参数与环境变量两种入口配置后,服务端均拒绝 400 ms 慢速请求头,正常请求不受影响)。
一条连接上的两类超时
- 请求头绝对截止时间。
ReadHeaderTimeout限制从开始读取请求头到头部解析完成的总时长,慢速逐字节发送无法顺延该上限。在 HTTP/1 keep-alive 连接上,Go 先按IdleTimeout等待下一个请求的首字节,随后开启新的请求头截止时间。ReadHeaderTimeout同时参与独立的 TLS 握手超时计算。 - 正文滚动空闲超时。 请求头解析完成、连接进入活跃阶段后,切换回滚动超时:每次成功读取都会顺延截止时间,仅当两次读取之间的停顿超过配置的空闲超时(idle timeout)时才关闭连接;持续传输数据的长上传或长下载不会因头部截止时间中断。
修复前,第一类超时在实际运行中并未生效:连接层包装器在每次部分读取前都将 socket 截止时间重置为 now + idle + 250 ms,直接覆盖了 net/http 设置的绝对截止时间。慢速客户端只需在每个空闲窗口内发送一个字节,即可无限期占用连接。
两个独立缺陷
- 连接层包装器覆盖了绝对截止时间。
DeadlineConn包装器的读取路径在每次部分读取前重置 socket 截止时间,令 Gonet/http服务端设置的读头截止时间失效。直接 TCP 对照实验单独验证了这一点:将请求头上限设为 100 ms、空闲窗口设为 2 s 时,耗时 400 ms 才发完的请求头仍被接受。 - 命令行配置未传入服务器上下文。 CLI 定义并解析了
--read-header-timeout与MINIO_READ_HEADER_TIMEOUT(含默认值),但服务器上下文构建器只复制了IdleTimeout而漏掉了ReadHeaderTimeout,导致运行中的服务器拿到的值始终为0。仅修复缺陷 1 时,真实服务进程依然接受慢速请求头;第二处修复是在IdleTimeout赋值旁补齐ReadHeaderTimeout传递。
该问题长期未被发现,是因为 --read-header-timeout 的默认值(30 s)与 IdleTimeout 默认值相同;当该参数未传入服务器时,net/http 回退使用的读超时恰好也是同一个 30 s,使默认配置下的表面行为与预期一致。
配置
- 命令行参数:
--read-header-timeout(Hidden: true,不在常规 CLI 帮助中显示) - 环境变量:
MINIO_READ_HEADER_TIMEOUT - 默认值: 30 s(与 idle timeout 默认值一致)
- 两项超时均无 YAML 配置项,仅在启动时按“命令行参数 > 环境变量 > 默认值”的优先级绑定一次。
| 取值 | 效果 |
|---|---|
header > 0 |
作为 HTTP/1 请求头阶段的绝对时长上限,并参与 Go 的 TLS 握手读取超时计算(含 HTTP/2 握手) |
header = 0(显式设置) |
回退至 Go 标准库规则:使用读超时(即 idle timeout);CLI 默认值为 30 s |
header < 0 |
关闭请求头专用上限;正值的读/写超时仍约束 TLS 握手读取,正值 IdleTimeout 仍约束 keep-alive 空闲等待 |
调小 idle、未设置 header |
请求头阶段仍使用自身的 30 s 默认值——这是唯一比直觉预期宽松的参数组合,但仍具备确定的绝对上限 |
将参数设为负值会关闭请求头绝对上限,使连接重新面临慢速请求头占用风险,不建议在生产环境使用。因超过截止时间而被截断的未完成请求头通常会直接触发连接关闭,而非返回 HTTP 错误状态码。该问题由 @AEGEGE 在 #183 中报告,初始修复 PR #195 经整理后随 PR #196 合入。
各协议的超时行为
- HTTP/1:请求头读取与 keep-alive 等待使用绝对截止时间,请求正文保持滚动空闲超时。连接状态钩子与调用方已有的钩子组合执行,互不覆盖。
- TLS:握手读取阶段取正值 header 截止时间与既有读/写超时的较小值,握手完成后重新计时新的请求头上限;握手写入阶段保持滚动超时语义。
- HTTP/2:保持不变。协商为
h2时跳过连接阶段切换,沿用h2原生的单流(per-stream)绝对读超时,ReadHeaderTimeout不注入h2配置。 - 内部节点通信:Linux 节点间连接独立维护滚动超时语义;由 grid hijack 接管的连接在进入上述阶段切换前即解包还原为底层裸 TCP 连接。
被否决的方案
- 全局钳制所有未来截止时间。 Go 1.27 在部分路径上会设置整请求截止时间;当读超时等于 idle timeout 时,全局钳制会把整个 HTTP/1 请求(请求头加正文)的总时长硬性限制在该窗口内,导致大文件上传中途断开。
- 移除读超时并将零值解释为滚动空闲超时。 在
net/http中,零值截止时间代表后台读取与 hijack 连接“永不超时”;改写这一语义会破坏长耗时 handler,同时使h2失去单流超时保护。 - 包装请求正文读取器或使用
http.ResponseController。 需要完整处理 chunked 编码、连接排空(drain)、EOF 状态记账及h2特例,改动面远超修复请求头超时所需。 - 在连接状态钩子中重算标准库的截止时间。 这会复制随 Go 版本演进而变化的
net/http内部算术逻辑;直接记录并保留标准库下发的目标截止时间更加稳健。 - 通过比较参数大小自动推导超时模式。 三个相关超时参数的默认值均为 30 s,在生产默认配置下无法通过“是否小于空闲窗口”区分所处阶段,该方式仅在人为调小参数的测试配置下有效。
验证与限制
单元测试覆盖了连接包装器跨越三个连续刷新周期的行为(验证绝对截止时间不随读取向后顺延)、HTTP/1 keep-alive / TLS / 纯 HTTP/2 协商下的阶段切换,以及真实 CLI 上下文中的命令行参数与环境变量绑定;进程探针验证了配置 100 ms 请求头上限的运行实例能够拒绝耗时 400 ms 的慢速请求头。边界说明:TLS 握手写入阶段仍采用滚动超时;业务 handler 的纯 CPU 或存储等待时间不受连接读取截止时间约束;请求头绝对上限不加额外宽限,而滚动空闲超时保留约 250 ms 的刷新宽限期。
升级注意事项(调小请求头超时会同步收窄 TLS 握手读取窗口)见组件版本矩阵。
4.19 - 条件 DELETE:一个条件对应一个逻辑对象
单对象条件删除(DeleteObject 上的 If-Match)由 PR #145 引入,随后 PR #178 修复了多池串行化与跨池清理。这两项改动均已随 Server 20260916 发布。
八月围绕 PR #12 的早期方案曾设想更宽的范围——包括批量 DeleteObjects 拒绝保护、额外的 s3:GetObject 读取授权检查,以及“仅比较当前版本”规则——但实际合入的实现并未采纳这些扩展。本文记录 f99ed829b 中已交付的契约,并阐述为什么前置条件判断必须位于单池存储层之上。
已实现的契约
| 请求 | 已交付行为 |
|---|---|
单个 DeleteObject,非空 If-Match: <ETag> |
在外层删除锁内比较客户端可见 ETag;不匹配时先返回 412 PreconditionFailed,不执行任何删除 |
If-Match: * |
要求选中的对象表示存在且不是删除标记(Delete Marker) |
显式 versionId |
比较所指定的版本,而非无关的当前版本 |
未携带 If-Match 或请求头值为空 |
不安装条件回调,按普通删除处理 |
批量 DeleteObjects 的逐项 <ETag> |
ObjectToDelete 结构体无 ETag 字段,该 XML 元素不提供删除保护 |
内部递归 x-minio-force-delete |
前缀删除先于单对象条件路径返回,不能用作条件删除 |
条件判断不会额外增加 s3:GetObject 授权检查。普通删除授权仍然生效,包括显式版本对应的 s3:DeleteObjectVersion、Object Lock 保护检查,以及独立的可信复制路径。
为什么条件判断属于跨池协调层
在多存储池部署中,不同存储池可能保留同一对象在不同时期的副本。请求前置条件针对的是本次操作选中的单一逻辑对象。如果让每个存储池独立判断条件并直接修改磁盘状态,就可能先在一个池中删掉副本,随后在另一个池中返回 412 PreconditionFailed;或者删掉最新副本后,反而让旧池中的陈旧副本重新暴露为当前版本。
因此,多池协调层(erasureServerPools.DeleteObject)会先获取共享命名空间写锁,收集各池状态,对选中的逻辑表示只执行一次条件判断,随后清空向下传递的回调函数,并跨池协调删除清理。并发写入与元数据更新共用同一把外层命名空间锁。
早期设计中的反例
假设存储池 1 保留旧副本(ETag 为 A),存储池 2 保留当前最新副本(ETag 为 B):
If-Match: A(返回412却发生部分删除): 若逐池独立判断,存储池 1 会先匹配并删除副本A,随后在存储池 2 因与B不匹配而返回412 PreconditionFailed,导致被拒绝的请求依然修改了底层存储。If-Match: B(成功删除后旧副本复活): 若逐池独立判断,存储池 2 删除了最新副本B,而存储池 1 因不匹配或跳过而保留了旧副本A,导致陈旧数据重新成为客户端可见的当前对象。
此外,在逐池并发工作协程中直接调用 HTTP 条件回调,还可能引发对同一个 http.ResponseWriter 的并发写入竞争。在跨池协调层集中消费一次请求条件,能够同时消除这两类隐患。
失败边界
跨池协调路径在判断条件前会先读取各池状态,并向上报告读仲裁失败,而不会把不可达的存储池误判为空池。不过,物理清理失败仍可能发生在部分底层修改之后:单次请求失败并不等价于分布式多池回滚事务。存储层报错后,调用方仍需通过重试与状态核对来确认最终结果;详见多池对象一致性。
选择与判断
只判断一次
erasureServerPools.DeleteObject 持有外层命名空间锁。多池部署调用 deleteObjectReconciled;单池部署先读取选中表示,并在删除标记快捷返回分支之前调用 CheckPrecondFn。下层存储引擎不再针对每份物理副本重复解释条件。
通配符与删除标记
DELETE 专用前置条件辅助函数将 If-Match: * 解释为“目标必须存在有效的非删除标记对象表示”;删除标记不能满足 *。缺失对象键或缺失指定版本会进入常规的未找到路径,不会被伪造成空 ETag 匹配。
授权
处理器依据实际生效的目标版本检查删除权限(s3:DeleteObject 或 s3:DeleteObjectVersion),不会在 If-Match 携带具体 ETag 时额外要求 s3:GetObject 权限。
SSE-C ETag
条件删除直接比较元数据中已保存的客户端可见 ETag 投影,不会读取或解密对象数据载荷。SSE-C 读取密钥认证与对象删除属于相互独立的契约。
显式版本
当请求提供显式 versionId 时,系统会选中该特定版本执行 ETag 比对与删除。因此,即使当前最新版本拥有不同的 ETag,只要所指定的历史版本 ETag 匹配,该历史版本仍可被正常删除。这与早期方案中“只允许比较当前版本”的设想不同。
尚未支持的边界
空 If-Match 请求头不会安装条件。内部递归前缀删除扩展(x-minio-force-delete)会绕过单对象条件路径。ObjectToDelete 不会解析批量 DeleteObjects XML 中的 <ETag> 元素,也没有整请求级别的 NotImplemented 拒绝保护。需要“比较后删除”语义的调用方必须使用带非空 If-Match 请求头的单对象 DeleteObject 接口。
被拒绝的替代方案与范围边界
- 仅修改底层共享 ETag 比较函数: 改动字符串比较辅助函数无法解决多池胜出版本选择,也无法阻止在条件检查前先修改其他池的数据。
- 逐池执行回调后再汇总错误: 当某个池已经物理删除了副本后,即使外层汇总返回
412也无法撤销已发生的删除。 - 引入新的分布式事务框架: 在既有外层命名空间锁内消费一次条件回调已足以满足单对象
DeleteObject;批量条件删除与 IAM 策略键跨越了其他接口边界,应作为独立工作推进。
源码证据
核心实现位于 cmd/object-handlers.go、cmd/erasure-server-pool.go 与 cmd/api-datatypes.go,回归测试覆盖了 ETag 不匹配、通配符与删除标记、显式 versionId、读仲裁失败以及多池状态协调。
后续工作
批量条件删除
在 DeleteObjects 中支持逐项 <ETag> 条件需要解析该 XML 字段、在对应外层锁内逐一判断每个逻辑对象、兼容 Quiet 模式,并在逐项响应结果中报告条件失败。在此之前,批量请求中的 <ETag> 会被忽略,切勿将其用作并发控制手段。
策略强制键
IAM 策略包目前尚未定义 s3:if-match 条件键。支持通过策略强制要求客户端提供 If-Match 需要先在 silo-pkg 中新增该条件键、在服务端处理器中填充请求条件上下文,并补充策略评估测试。
设计权衡
单对象条件 DELETE 直接复用了既有的多池对象选择与外层命名空间锁边界,无需引入新的事务层。完整的批量条件删除与 IAM 策略条件键涉及更多 API 与外部包边界,仍作为独立特性管理。核心不变量非常明确:当受支持的单对象 If-Match 条件不成立时,必须在任何存储池删除数据之前,针对选中的逻辑表示一次性完成判定。
4.20 - 只预览文本,绝不执行:SILO Console 文本预览设计
SILO Console 支持预览图片、PDF、音频和视频,但在 v2.1.1 中却无法直接查看日常运维最常检查的小型日志、纯文本、JSON 与 XML 对象。即使对象保存了准确的 Content-Type,前端也会在选择预览渲染器之前将其判定为不支持。
恢复旧版浏览器原生 <iframe> 预览虽然简单,却是错误的修法。存储桶中的对象内容完全由上传者控制;一旦将其作为同源 HTML 或 XML 文档加载,一个便利的预览功能就会退化为同源脚本执行边界。
随 Console 2.2.0 交付的设计(跟踪议题 #17)确立了一条严格的安全不变量:
SILO Console 仅将符合条件的对象作为有界 UTF-8 纯文本预览,绝不让浏览器将其中的标记、MIME 类型或文件内容解析为可执行文档。
核心决策
Console 新增独立的 text 预览类型与 PreviewText 组件,并遵循十项契约规则:
- 完整保留现有的
image、pdf、audio与video媒体分类逻辑。 - 仅当既有媒体分类器返回
none时,才进入文本回退(fallback)判定。 - 允许四种目标扩展名(
.log、.txt、.json、.xml)或四种精确被动文本 MIME 类型(text/plain、application/json、application/xml、text/xml)。 - 通过常规鉴权下载路径读取对象字节,不传递
preview=true。 - 在应用层强制执行 1 MiB(
1,048,576字节)读取硬上限。 - 仅执行严格 UTF-8 解码(
fatal: true),并拒绝包含 NUL 字节的内容。 - 在可滚动的
<pre>容器中仅渲染单一 React 文本节点。 - 绝不使用
<iframe>、HTML 解析器、XML 解析器或任何 HTML 注入 API。 - 要么展示完整对象,要么完全不展示;绝不渲染被截断的 JSON 或 XML 半截片段。
- 当文件超出大小上限、编码非法或无法加载时,始终保留 Download(下载)出口。
本设计不新增 S3 操作或后端预览路由,也不扩大后端的 inline MIME 白名单。交付过程中同步收敛了 Console 下载端点的字节范围语义:零字节对象的 Range 请求返回空 200 OK,不可满足的范围请求返回 416 Range Not Satisfiable 及 Content-Range: bytes */N,且对象 JSON 元数据始终输出 size 字段(详见 Console 2.2.0)。
Console 2.1.1 的原有行为
在 SILO Console v2.1.1 中,前端预览联合类型仅包含:
其扩展名映射表只包含多媒体格式,未收录 .log、.txt、.json 或 .xml;MIME 分类器同样忽略 text/plain、application/json、application/xml 与 text/xml。运行时验证显示前端分类与后端响应头之间存在如下分裂:
| 对象 | 前端分类结果 | Console 下载响应头 |
|---|---|---|
.log / text/plain |
none |
inline,X-Frame-Options: SAMEORIGIN |
.txt / text/plain |
none |
inline,X-Frame-Options: SAMEORIGIN |
.json / application/json |
“Preview unavailable” | inline,X-Frame-Options: SAMEORIGIN |
.xml / application/xml |
none |
attachment,X-Frame-Options: DENY |
此外,对象详情抽屉在判断 Preview 按钮是否禁用时使用了错误的布尔逻辑连接词:有读取权限的用户可以对不支持预览的对象点击 Preview 并看到不可用提示,而在另一些权限组合下界面会放行实际上会被服务端拒绝的操作。
更关键的是,预览容器组件末尾仍残留着一条通用的同源 <iframe> 回退分支。由于封闭的五元联合类型无法落入该分支,v2.1.1 并未构成可利用的文本预览 XSS;但如果简单地把 text 加入联合类型并任其落入旧的 <iframe> 分支,就会直接激活同源文档执行风险。
根因分析
该缺陷源于三个独立演进层之间的契约漂移:
分类契约漂移
浏览器前端依据文件名与对象的 Content-Type 元数据判断预览资格,但封闭的联合类型中根本没有 text 分支。再准确的元数据也无法选中一个不存在的渲染器。
响应策略漂移
Console 后端独立判断下载响应是否允许 inline:后端仍将纯文本与 JSON 视为安全的被动内联类型,同时将 XML 与 HTML 强制设为 attachment。这一服务端策略并未反映在前端分类器中。
渲染器漂移
在可达预览类型收缩为纯媒体类型后,旧版通用 <iframe> 回退分支仍残留在组件树中。修复方案必须引入专用的惰性文本渲染器并彻底删除不可达的 <iframe> 分支,而不能把 MIME 元数据当作安全边界。
为什么拒绝同源 Iframe 预览
X-Frame-Options: SAMEORIGIN 不是沙箱。它只限制哪些父级源可以嵌入该响应,并不限制同源 frame 内部的代码能执行什么操作。
一旦上传者可控的 HTML、XHTML、SVG 或活动 XML 被作为内联同源文档加载进 <iframe>,其中的脚本就会直接在 Console 的源(Origin)下运行。即便 HttpOnly 会话 Cookie 能阻止脚本直接读取 document.cookie,同源脚本仍然可以携带当前会话凭据向 Console 后端发起任意状态修改请求。
X-Content-Type-Options: nosniff、内容安全策略(CSP)与 Content-Disposition 都是有价值的纵深防御,但核心安全不变量必须建立在数据流本身之上:
产品契约
本功能是一个只读源码文本查看器,既不是网页文档渲染器,也不是在线代码编辑器。
用户可以:
- 从存储桶对象列表或对象详情抽屉打开符合条件的小型文本对象;
- 在现有预览弹窗中查看保留空白与缩进的原始文本;
- 使用浏览器原生选择与复制功能;
- 清晰区分预览受阻是因为文件过大、非 UTF-8/二进制编码、权限不足、对象已被替换还是网络错误;
- 随时点击下载获取原始字节。
系统绝不会通过静默转换误导用户:
- 绝不对 JSON 执行解析重排(否则会篡改超出双精度范围的大整数、折叠重复键或改变空白格式);
- 绝不将超限的 XML 或 JSON 静默截断为结构残缺的前缀片段;
- 绝不用
U+FFFD替换字符掩盖非法的 UTF-8 字节序列。
目标与非目标
目标
- 无需下载到本地即可直接预览小型
.log、.txt、.json与.xml对象。 - 无论扩展名、
Content-Type或载荷内容如何,始终保持对象内容完全惰性(inert)。 - 将保留的响应字节与渲染文本严格限制在 1 MiB(
1,048,576字节)以内。 - 原样保留存储的 UTF-8 文本,不做静默重排。
- 统一对象列表与详情抽屉的预览资格及权限判断逻辑。
- 同时支持当前对象版本与显式选中的历史
versionId。 - 保持匿名访问(
X-Anonymous: 1)与子路径托管(subpath hosting)行为不变。
非目标
- HTML、XHTML 或 Markdown 渲染;
- XML 解析、XSLT 转换、外部实体解析或 Schema 校验;
- JSON 自动美化缩进(
JSON.parse/JSON.stringify); - 在线编辑、语法高亮、行号栏、代码折叠或 ANSI 转义序列渲染;
- 针对超过 1 MiB 大文件的首尾截取或截断预览;
- 针对 GBK、UTF-16、Latin-1 等非 UTF-8 编码的有损解码或自动编码探测。
当 notes.md 等文件的精确 MIME 类型为 text/plain 时,它可以作为原始 UTF-8 纯文本预览,但不会触发 Markdown 渲染。
准入资格契约
准入资格采用两阶段判定,以确保既有媒体预览行为零回归。
第一阶段:保留既有媒体判定
首先原样运行现有的 image、pdf、audio 与 video 分类器。只要返回值不是 none,即直接采用该媒体类型。
第二阶段:应用文本回退判定
仅当第一阶段返回 none 时:
- 显式拒绝最终扩展名为
.html、.htm与.xhtml的对象。 - 将最终扩展名(忽略大小写)与
.log、.txt、.json、.xml进行匹配。 - 规范化
Content-Type:剥离参数(如; charset=...)、去除首尾空白并转为小写。 - 将规范化后的 MIME 类型与
text/plain、application/json、application/xml、text/xml进行精确匹配。
只要命中允许的扩展名(且未被规则 1 排除)或命中允许的精确 MIME 类型,即判定为 text:
| 文件名与 MIME | 判定结果 | 原因 |
|---|---|---|
report.txt + image/png |
image |
既有媒体分类优先 |
report.json + application/pdf |
pdf |
既有媒体分类优先 |
server.LOG + application/octet-stream |
text |
命中允许的扩展名(大小写不敏感) |
无扩展名 + application/json; charset=utf-8 |
text |
命中规范化后的精确 MIME |
page.html + text/plain |
none |
活动文档扩展名被显式排除 |
page.txt + text/html |
text |
扩展名准入;HTML 源码仅作为惰性纯文本展示 |
notes.md + text/plain |
text |
精确 MIME 准入原始纯文本 |
image.svg + image/svg+xml |
image |
走既有图片预览路径 |
资源上限契约
二进制读取硬上限定义为:
恰好 1,048,576 字节(1 MiB)的对象允许预览;1,048,577 字节则作为超限拒绝。
已知对象大小
- 若选中版本的已知大小超过
1,048,576字节,打开弹窗时直接进入 Too Large(文件过大)状态,不发起正文下载请求。点击 Retry(重试)会使用有界Range请求重新探测,以应对列表元数据过期的情况。 - 已知大小为
0字节的对象仍会发起有界请求以验证权限与对象存在性,收到空200 OK后展示明确的 File is empty(文件为空)状态。 - 缺失大小元数据(
undefined)必须保留为未知状态,不能通过布尔回退强制转换为0,并进入有界流式读取路径。
有界请求与哨兵字节
对于已知不超过 1 MiB 或大小未知的对象,客户端发送:
请求 1,048,576 + 1 字节是为了把第 1,048,577 个字节作为超限哨兵(sentinel byte)。客户端执行以下流程:
- 响应到达后立即检查
Content-Range与Content-Length响应头。 - 通过
ReadableStream流式读取正文,绝不直接调用response.text()或构造无界Blob。 - 内存中最多保留
1,048,577字节。 - 一旦读到第
1,048,577个哨兵字节(包括反向代理忽略Range并返回完整200 OK的情况),立即中止流读取。 - 仅当流正常结束且证明完整对象不超过
1,048,576字节时,才执行解码与渲染。
请求标识与取消生命周期
每个预览请求由 bucket + object name + versionId 唯一标识,并保留同源凭据、Console 子路径前缀、version_id 查询参数以及匿名模式下的 X-Anonymous: 1 请求头。关闭弹窗、切换对象或版本、切换存储桶或组件卸载时,会同时触发 AbortController 中止信号与代次令牌(generation token)失效检查,防止已读完但较晚完成解码的旧响应覆盖新对象的预览。主动中止的请求静默清理,不弹出错误提示。
编码与保真契约
文本解码仅支持严格模式的 UTF-8:
- 自动剥离前导 UTF-8 BOM(
EF BB BF),不在界面上渲染零宽字符; - 原样保留多字节 Unicode 字符、emoji、制表符、
LF与CRLF换行; - 遇到非法 UTF-8 字节序列时直接抛出解码错误并进入 Invalid UTF-8 / Binary 状态,绝不插入
U+FFFD替换字符; - 解码结果中一旦包含
NUL(\0)字符,立即作为二进制内容拒绝。
安全渲染器与界面状态
成功状态仅在 <pre>{content}</pre> 中渲染单一 React 文本节点,使用等宽字体、保留空白字符、默认禁用自动换行(white-space: pre,以保持对齐日志的可读性,并避免超长单行 JSON 引发昂贵的浏览器排版计算)、双向滚动条以及原生键盘焦点支持。预览路由中残留的通用 <iframe> 回退分支被彻底移除。
界面状态与权限控制
在对象列表与对象详情抽屉中,当且仅当同时满足以下条件时才启用 Preview 按钮:
预览弹窗明确区分 Loading(加载中)、Success(成功展示源码与下载按钮)、Empty(0 字节空文件提示)、Too Large(超过 1 MiB,展示对象大小、上限说明、Download 与有界 Retry)、Invalid UTF-8 / Binary(编码非法或二进制说明与 Download)、Forbidden(403)、Not Found(404) 以及 Network / Server Error。任何 HTTP 错误响应体都绝不会被解码展示为预览内容。
功能与安全需求
功能需求
- FR1–FR3: 保留现有媒体与 PDF 分类优先级,按两阶段扩展名/MIME 矩阵判定文本预览资格,并将不超过 1 MiB 的完整对象渲染为严格 UTF-8 源码。
- FR4–FR6: 超限对象不渲染半截内容,空文件展示独立状态,且元数据、大小与正文请求严格绑定同一
versionId。 - FR7–FR9: 保持匿名访问、Console 子路径、列表与详情页一致的按钮准入判定,以及既有下载/分享行为不变。
安全需求
- SR1–SR4: 对象字节仅通过
<pre>文本节点进入 DOM(不含任何 HTML/XML/SVG/Markdown 解析器),最多保留1,048,577字节,并在关闭弹窗或切换对象时使旧请求失效。 - SR5–SR8: 拒绝非法 UTF-8 与
NUL字节,不在 Redux、本地存储、日志或错误体中留存预览文本,依赖服务端 S3 鉴权,且不放宽 CSP 或后端safeMimeTypes。
实现范围
- 重构预览分类器,保留既有媒体判定并将
text作为显式回退分支。 - 新增
PreviewText组件,内置流式字节上限、严格 UTF-8 解码、AbortController取消与显式状态机。 - 移除不可达的通用
<iframe>回退分支,统一对象列表与详情抽屉的 Preview 启用条件,并保留undefined未知大小状态。
被拒绝的替代方案
继续禁用文本预览
被拒原因:查看小型日志、状态文件与 JSON/XML 配置是高频日常运维操作,每次检查都强制下载到本地会造成不必要的操作摩擦。
复用旧版同源 Iframe
被拒原因:在同源 <iframe> 中加载上传者可控的内容会把可变的 MIME 元数据变成脚本执行边界,且无法限制浏览器内存占用。
新增专用后端预览 API
被拒原因:现有鉴权下载端点已经完整执行了 GetObject / GetObjectVersion 权限检查并支持 HTTP Range 请求(bytes=0-1048576)。另起炉灶只会复制权限与版本逻辑,而不会建立新的数据访问边界。
截断展示大文件的前 1 MiB
被拒原因:展示 50 MiB JSON 或 XML 文件的前 1 MiB 会呈现结构残缺的半截文档,且需要额外处理多字节 UTF-8 截断边界。“要么完整展示、要么不展示”使预览语义保持严密。
使用替换字符有损解码非法 UTF-8
被拒原因:静默插入 U+FFFD 会污染运维人员从预览框中复制出的文本内容。传统单字节编码或 UTF-16 文件应通过 Download 下载后使用专用工具查看。
自动解析并格式化 JSON
被拒原因:JSON.parse 再 JSON.stringify 会静默篡改超出 Number.MAX_SAFE_INTEGER 的 64 位整数、折叠重复键并改变原始空白格式。
引入 Monaco 等重型代码编辑器
被拒原因:对于有界 1 MiB 的只读预览而言,Web Worker、语法分析器与体积开销得不偿失;原生 <pre> 单文本节点更加轻量且易于安全审计。
验收矩阵与测试计划
分类矩阵测试
锁定两阶段扩展名与 MIME 判定矩阵的每一行、大小写不敏感、参数剥离、.html/.htm/.xhtml 排除以及媒体类型优先规则。
资源边界测试
覆盖 0 字节、1 字节、1,048,576 字节、1,048,577 字节、已知超限跳过首次请求、有界 Retry、206 Partial Content、服务端忽略 Range 返回 200 OK 以及关闭弹窗时的流取消。
编码与保真测试
覆盖 UTF-8 中文与 emoji、BOM 剥离、非法 UTF-8 序列、内嵌 NUL 字节、JSON 不安全大整数、重复键、原始空白以及 XML DOCTYPE/CDATA/stylesheet 指令。
安全载荷测试
验证包含 <script>、内联事件属性、<iframe>、SVG 事件处理器、XML 外部实体(XXE)与 XSLT 的载荷逐字出现在 <pre>.textContent 中,不创建任何子 DOM 节点,不触发任何脚本或外部网络请求。
权限与版本竞态测试
确认 401/403/404/416/5xx 响应体绝不进入预览面板,且旧请求的迟到响应无法覆盖新选中的对象或 versionId。
真实浏览器回归
在真实 SILO + Console 实例上通过 Playwright 验证中英文路由、亮色与暗色主题、桌面与窄屏视口,以及媒体、PDF、下载、分享与多版本工作流。
交付与完成门禁
该特性已在 pgsty/silo-console v2.2.0(PR #30)中发布,并随 SILO Server 20260903 内嵌交付,关闭 pgsty/silo#17。
利弊权衡
本设计选择明确的准入范围而非通用浏览器查看器、选择完整小文件而非截断片段、选择原文保真而非自动格式化、选择严格 UTF-8 而非静默有损解码、选择单一惰性文本节点而非重型代码编辑器。超过 1 MiB 的大型日志与非 UTF-8 编码文件仍需点击 Download 下载查看——这一刻意的边界取舍,使得 Console 文本预览足够精简以便安全审计,也足够坚固以供生产环境信赖。
设计评审记录
本方案在合入 silo-console#30 前完成了产品范围、前端安全与 CSP 隔离以及 S3 Range/多版本语义三轮评审。
4.21 - 数据库通知统一连接串:#53 的兼容性边界
随 SILO Server 20260903 发布的提交 f1ba68358 修复了 SILO #53,明确了 PostgreSQL 与 MySQL 桶通知目标的兼容性边界、旧配置迁移行为与启动期错误处理契约。
最终决策
SILO 保留 PostgreSQL 与 MySQL 通知目标(notification target),但每种数据库仅支持单一的规范配置键:
- PostgreSQL 必须提供完整的
connection_string; - MySQL 必须提供完整的
dsn_string。
2020 年之前的五字段形式——host、port、username、password、database——继续作为当前 KV 配置系统不支持的格式。SILO 既不重新注册这些键,也不在旧 JSON 配置迁移时自动将它们拼接合成为 DSN。
旧配置迁移契约按三类输入状态处理:
| 旧 target 状态 | 迁移结果 |
|---|---|
未启用(Enable == false) |
忽略,不生成任何 KV 配置项。 |
已启用,且含有非空 connection_string 或 dsn_string |
仅迁移规范连接串和其他已注册的 target 设置。 |
| 已启用,但仅有离散连接字段 | 在新配置生效前拒绝迁移并中止服务器启动;错误信息明确指出子系统与 target 名称及修复方法,且绝不打印任何凭据内容。 |
背景
SILO 从上游继承了两代数据库通知配置方式。在引入 KV 配置体系之前,旧版 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:
当前 KV 配置体系则仅暴露驱动原生的连接串键:
上游早在 RELEASE.2020-04-10T03-34-42Z 中就已废弃这五个离散字段,并要求运维人员迁移到 connection_string 或 dsn_string。SILO 当前的命令行帮助表、环境变量文档、示例以及 Console 界面也均将完整连接串作为唯一正式接口。
问题本质
在 f1ba68358 之前,旧配置迁移辅助函数 SetNotifyPostgres 与 SetNotifyMySQL 会把两种形式同时写入新 KV 配置。即使旧 JSON target 中已经配置了完整的 connection_string 或 dsn_string,迁移器仍会一并写出全部五个离散键(通常为空字符串)。
新解析器随后会拒绝这些键,因为 DefaultPostgresKVS 与 DefaultMySQLKVS 均未注册它们,而 CheckValidKeys 仅检查键名是否存在,不区分值是否为空。因此新旧两种来源格式在迁移后都会失败:
通知子系统的初始化逻辑进一步放大了这一故障。FetchEnabledTargets 对所有通知子系统采用快速失败(fail-fast)策略:第一个校验失败的子系统会直接返回错误和空的 target 列表。上层调用方仅记录一行错误日志便继续启动对象存储服务,导致同一集群上原本健康的 Webhook、Kafka、NATS 等其他通知目标也全部被连带禁用。
仅仅让两个迁移函数返回错误还不足以彻底修复该行为。错误虽会沿 readConfigWithoutMigrate 与 initConfig 向上传播,但 initConfigSubsystem 原先会将不可重试的配置错误降级记录为 "some features may be missing" 日志并返回成功。随后服务端会在未设置 globalServerConfig 的状态下继续启动——不仅桶通知全部失效,区域(region)、存储类型、压缩、身份认证及其他持久化配置也可能全部缺失。反之,若将该错误标记为可重试,服务端又会在静态配置错误上陷入死循环。因此,必须将数据库迁移失败定义为不可重试的类型化致命错误(LegacyDatabaseTargetError),一路传递至启动边界并立即终止进程。
修复前的降级行为尤为危险:由于普通 S3 对象读写依然正常,运维人员看到的是表面健康的 S3 服务,而所有已配置的事件通知管道均已静默中断。此外,未注册的 password 键缺少敏感字段元数据,可能被明文复制进健康检查或诊断包中;而正式注册的 connection_string 与 dsn_string 则均按敏感值进行脱敏处理。
为什么第一版修复被回滚
20260804 中的第一版尝试(SN-2026-019)曾在 DefaultPostgresKVS 与 DefaultMySQLKVS 中重新注册了这五个离散键,并让解析器读取它们。虽然这使迁移后的配置能够通过 CheckValidKeys,但却直接破坏了文档正式支持的完整连接串路径。
共享的 mcli admin config set 分词器通过扫描已注册的键名来识别字段边界,并不具备完整的引号语法感知能力。一旦 port 成为 notify_postgres 的已注册顶层键,下面这条合法的引号内连接串就会被误识别为包含第二个顶层 KV 字段:
分词器会在引号内部的 port= 处直接切分,将 connection_string 截断为 "host=db,再把剩余部分交给整型的 port 解析器,最终报出 invalid port 错误。
在当前共享分词器语法下,注册 host、port、password 等常见词会导致连接串内部参数语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被彻底回滚,改为严格执行单一连接串边界。
产品判断
数据库通知目标无需额外部署外部消息总线即可在数据库中维护对象命名空间视图或访问审计流水,对于小型部署及已运行 PostgreSQL / MySQL 的环境依然具有实用价值。
相比之下,五字段连接参数模型的表达能力十分受限,无法表达常见的数据库驱动选项:TLS 模式与证书路径、连接超时、应用名称、Unix Socket、PostgreSQL 多主机容灾以及 MySQL 驱动参数。同时支持两种格式还会制造优先级、配置合并、密码脱敏与测试矩阵的歧义。由 SILO 负责通知投递语义、由数据库驱动负责原生连接串语法,才是最清晰的抽象边界。
目标
- 将
connection_string与dsn_string确立为数据库通知唯一受支持的在线配置键。 - 允许已包含非空规范连接串的旧 JSON target 无损跨过迁移边界。
- 在仅含离散字段的已启用旧 target 生成半成品或非法 KV 配置之前明确拒绝。
- 将 #53“服务看似健康、全部通知静默失效”的运行时故障模式,替换为必须先修正才能启动的显式启动期失败(
LegacyDatabaseTargetError)。 - 确保迁移错误、日志、健康报告与诊断包均不会泄露数据库密码。
- 从未注册配置键写入审计(
knownUnregisteredWrites)中彻底移除 Postgres/MySQL 的十条例外。 - 在发布说明与迁移文档中明确兼容性边界与运维修复步骤。
非目标
- 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
- 在旧配置迁移时自动从离散字段拼接合成 DSN;
- 重写共享 KV 分词器;
- 在本补丁中改变
FetchEnabledTargets的跨子系统 fail-fast 语义; - 静默跳过已启用的非法数据库 target 并以残缺的通知覆盖继续运行;
- 移除 PostgreSQL 或 MySQL 通知功能;
- 删除用于解码旧 JSON 并识别不受支持离散输入的旧结构体字段;
- 修改除 PostgreSQL 和 MySQL 之外其他八个旧通知 setter 的既有错误处理方式。
功能需求
当前配置
notify_postgres仅接受connection_string;notify_mysql仅接受dsn_string。- 五个离散键(
host、port、username、password、database)保持未注册状态,并被当前配置命令拒绝。 - 完整连接串继续支持数据库驱动原生语法,包括引号内出现
host=、port=、user=、password=、database=等子串的情形。 - 旧环境变量
MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE及其 MySQL 对应变量保持未接线和不受支持状态。
旧配置迁移
- 当旧 target 未启用(
Enable == false)时,SetNotifyPostgres与SetNotifyMySQL直接返回nil,不生成任何 KV target。 - 对已启用的 target,
SetNotifyPostgres要求非空ConnectionString且只写出已注册的 Postgres 键;若规范连接串与离散字段同时存在,则以规范连接串为准并丢弃所有离散值。 SetNotifyMySQL对DSN执行相同规则。- 两个迁移函数均不得写出
host、port、username、password或database。 - 当已启用 target 缺少规范连接串时,返回类型化的
LegacyDatabaseTargetError,指明子系统与 target 名称但不包含任何字段值:
cmd/config-migrate.go检查并向上传播两个数据库迁移函数的错误,拒绝启用或持久化半迁移配置。initConfigSubsystem将LegacyDatabaseTargetError作为不可重试的致命错误直接返回给serverMain。
操作者修复路径
由于旧 JSON 配置只有在迁移成功后才会持久化为新 KV 配置,任何仍在读取旧格式 JSON 来源文件的部署在每次启动时都会重新进入迁移逻辑。遇到该启动错误的运维人员可按以下四条路径之一处理:
- 切换到 SILO 前转换: 在兼容的过渡版本上将旧离散字段替换为
connection_string或dsn_string,验证通知正常后再升级至 SILO; - 先禁用再重建: 禁用或删除旧数据库 target,完成服务端升级后,使用规范连接串重新创建 target;
- 全新安装: 直接使用
connection_string或dsn_string创建 target,不涉及旧配置迁移; - 仍在读取旧 JSON 的存量部署: 先备份来源配置,在升级至 SILO Server 20260903 及更新版本前转换、禁用或移除该数据库 target。
可用性权衡
本设计有意将不受支持的旧数据库通知配置从“降级启动”改为“启动硬失败”。虽然一台此前仍能处理对象读写(但通知已全部静默死亡)的服务器在升级后会拒绝启动,但对象存储表面健康、已配置的事件出口却全部消失,会导致静默且不可逆的下游事件丢失。用明确、可操作的启动前置检查替代隐患重重的静默降级更加安全。
安全要求
LegacyDatabaseTargetError绝不格式化输出旧参数结构体或其中任何字段值。- 回归测试注入哨兵密码(sentinel password),并断言返回错误和捕获日志中均不包含该哨兵值。
- 迁移输出仅包含已注册的敏感连接串键(
connection_string与dsn_string),不生成独立的password键。 - 若受影响部署曾在修复前导出并对外提供过诊断包,建议轮换对应的数据库密码。
评估与否决的备选方案
注册并解析五个离散字段
优点: 保留 2020 年前的旧 JSON 字段形式,并复用现存结构体字段。
否决原因: 注册 host、port、password 等常见词会被共享分词器识别为顶层键,直接截断引号内包含 port= 等子串的合法 connection_string。
迁移时自动拼接生成规范连接串
优点: 自动兼容仅配置了离散字段的早期安装。
否决原因: 需要为早在 2020 年即已废弃的输入格式永久承担 PostgreSQL 转义引用、MySQL DSN 编码、Unix Socket、IPv6 地址括号及驱动默认值的代码与测试维护负担。
仅跳过不支持的数据库 target
优点: 让对象存储服务与其他通知 target 继续运行。
否决原因: 静默丢弃用户已启用的事件通知出口会造成不可见的事件丢失。
修改全局 FetchEnabledTargets 的 fail-fast 行为
优点: 隔离不同通知子系统之间的故障半径。
否决原因: 既无法修复数据库迁移函数本身,也无法关闭未脱敏 password 键的暴露路径,且会改变全系统的通知启动错误语义。
彻底删除数据库通知 target
优点: 移除全部数据库通知专用代码维护面。
否决原因: PostgreSQL 与 MySQL 通知目标本身功能自洽且实用,缺陷仅在于过时的五字段配置形式。
实现范围
服务端提交 f1ba68358 仅修改 internal/config/notify/legacy.go、cmd/config-migrate.go 与 cmd/server-main.go 等 6 个文件:
- 两个数据库旧配置 setter 仅输出规范已注册键,并在已启用 target 缺少规范连接串时返回不携带字段值的
LegacyDatabaseTargetError。 cmd/config-migrate.go捕获并传播这两个数据库迁移错误。cmd/server-main.go中的initConfigSubsystem将LegacyDatabaseTargetError作为不可重试的致命错误直接返回,由serverMain通过logger.FatalIf终止启动。- 从
knownUnregisteredWrites中移除全部 10 条 Postgres/MySQL 例外。
验收标准
实现通过了以下全部 12 项验收标准:
- 含完整
connection_string的旧 PostgreSQL target 可正常迁移、通过CheckValidKeys,并由GetNotifyPostgres原样返回。 - 含完整
dsn_string的旧 MySQL target 完成同等验证。 - 两类已启用的纯离散字段 target 均在初始化前失败,返回包含子系统与 target 名称的
LegacyDatabaseTargetError并中止启动。 - 缺失连接串和畸形连接串的错误均不包含哨兵主机名、用户名、密码、数据库名或 DSN 值。
- 未启用的离散旧 target 不生成配置项,也不阻塞启动。
- 迁移后的 KVS 不包含任何十个离散键(包括空值形式)。
- 旧 target 同时包含规范连接串与冲突离散值时,仅迁移规范连接串,输出中不含任何离散哨兵值。
- 使用真实
DefaultPostgresKVS和DefaultMySQLKVS键集的SetKVS回归测试,能够正确解析引号内包含port=、host=、password=的完整连接串。 - 同时包含健康 Webhook、Kafka、NATS target 与非法旧数据库 target 的配置在
readConfigWithoutMigrate阶段即返回错误,不持久化、也不激活任何半成品配置。 initConfigSubsystem直接返回类型化迁移错误,既不记录后继续,也不进入重试循环。knownUnregisteredWrites不再包含 Postgres/MySQL 例外。- 全部聚焦与常规测试套件通过:
实现结果与发布边界
提交 f1ba68358 已随 SILO Server 20260903 正式发布。跨仓库核对确认 pgsty/mc、pgsty/silo-pkg 与 pgsty/silo-console 均无需代码修改:客户端原样转发配置文本,silo-pkg 不包含通知配置 schema,而 Console 已将表单序列化为规范的 connection_string 或 dsn_string。
发布与兼容性声明
SILO 数据库通知要求 PostgreSQL 使用 connection_string、MySQL 使用 dsn_string。2020 年前的离散 host/port/username/password/database 形式不会被迁移;在升级至 SILO Server 20260903 及更新版本前,请先转换、禁用或移除这类旧数据库 target。
设计审查校准记录
初稿经设计审查校准后,补齐了穿过 initConfigSubsystem 至 ExitFunc(1) 的致命错误传播链路、覆盖了仍在读取旧 JSON 文件的存量部署、明确了规范连接串相对于残留离散字段的优先级,并通过了完整 cmd 测试套件与 -race 验证。
4.22 - 多池删除:失败时保住最新副本
2026 年 10 月 24 日决定:合并修正后的 PR #257(已随 Server 20261026 发布)。 提交 aafe4f8886 修复了 issue #256 报告的多池删除缺陷,并补齐请求开始之后的执行期故障保护。核心不变量是:先完成其他池的旧副本清理,再删除决定当前读取结果的权威副本。
Denis Babchuk 报告了该问题并提交了初始补丁。代码评审复现了故障,发现批量删除在通过在线盘数预检查后仍存在执行期故障窗口,随后补充分阶段执行、逐项响应身份保持、部分删除重试以及共享分层对象保护。
背景与前置工作
SILO 采用按剩余容量加权的选池策略。覆盖写入无版本对象时,新对象可能写入池 B,而池 A 中仍保留旧物理副本。读取路径会跨池选择最新的逻辑对象,因此旧副本平时不可见;但一旦后续删除中途遭遇故障,旧副本就可能重新暴露。
此前合入的多池一致性工作与本次修改的关系如下:
| 已有工作 | 保护范围 | 与本次修改的关系 |
|---|---|---|
| #178 | 条件删除和指定版本操作的共享对象锁与跨池协调 | 提供本次复用的锁与协调基础 |
| #199,由 #207 修复 | 条件 PUT 针对跨池当前逻辑对象判断条件 |
保留按容量加权的分配策略 |
| #248,随 Server 20261018 合入 | 跨池迁移不得用旧载荷覆盖新副本,也不得丢失删除标记 | 保护 rebalance 与 decommission 迁移路径 |
| #245,已关闭未合并 | 提议覆盖写优先留在原池 | 两轮实验均未达到性能目标;本次修复不依赖覆盖亲和 |
既有协调机制尚未覆盖普通无条件删除与批量删除。共享锁与迁移设计详见多池对象一致性,保留原分配器的评估过程详见多池覆盖亲和。
故障现象:删除失败后,已被覆盖的旧值重新可见
假设初始状态如下:
若两个池并行执行删除,池 B 可能先完成删除,池 A 随后返回 I/O 错误。此时单对象 DELETE 虽然向客户端返回失败,但最新写入的 "latest" 已被物理移除,下一次 GET 会读回已被覆盖的 "old"。
Issue #256 报告了四类具体表现:
| 操作 | 原有故障行为 |
|---|---|
普通无版本 DELETE |
删除最新副本后返回失败,旧池中的旧值重新可见 |
批量 DeleteObjects |
某池删除成功掩盖了另一池的删除失败 |
| 批量显式版本删除 | 指定版本仍残留在另一池,却向客户端返回成功 |
版本化单个 DELETE |
在不满足写 quorum 的持有池尝试创建删除标记,未切换到另一可读可写的持有池 |
运行作者提交的 11 个回归子测试:基线 main 有 10 项失败,初始 PR 全部通过。不过,代码评审进一步发现批量删除在通过事前检查后仍存在执行期故障窗口。
为什么事前检查不够
初始补丁在批量删除开始前检查目标纠删集是否有足够的在线磁盘,能够提前拦截已知的离线 quorum 缺失,但无法应对预检查通过后在元数据读写或磁盘删除阶段才发生的 I/O 错误。
评审构造了 45 个故障注入场景:预检查时所有磁盘均在线,先放行最新副本所在池完成删除,再在旧副本所在池的 DeleteVersions 内注入故障(覆盖模拟 EIO、模拟存储断连与真实文件权限错误,遍历两池和三池下权威副本的每个位置,以及无版本、显式 null 与 UUID 版本对象):
- 基线
main(000e653038):在全部 45 个场景中吞掉了逐项删除错误。 - 初始 PR(
a30270d9ba):能返回错误,但在全部 30 个无版本与显式null场景中删掉了最新副本,导致旧值暴露;15 个 UUID 场景因同版本副本内容相同且仍有副本保留,未丢失最新值。
评审同时发现了另一处响应构造缺陷:提前失败的 DeletedObject 遗漏了 ReplicationState,而 HTTP handler 需要依赖该字段还原原始请求项身份。当签名批量请求包含两个受阻的复制版本清除项时,响应会错误地拆成一个错误项和一个空的删除成功项。仅保留 key 和版本 ID 不足以保证错误项准确对应。
删除一致性契约
修复在既有共享对象写锁下,逐项维护以下执行顺序:
- 解析本次操作指定的对象或版本,确定其权威副本所在池。
- 先完成所有非权威池的旧副本清理(阶段 0),再尝试删除权威副本(阶段 1)。
- 若非权威池清理失败,立即向对应的请求项返回错误,且不触碰该项的权威副本。
- 同一批次中互不相关的其他对象正常完成删除。
各边界行为说明如下:
- 非跨项事务回滚:若某对象在三个池中都有副本,阶段 0 可能先删掉其中一份旧副本,随后在清理第二份旧副本时失败。此时阶段 1 不会执行,健康的权威副本仍然完好;同一批次中已成功的其他对象不会回滚。
- 权威副本自身的执行期错误:当阶段 0 全部成功、进入阶段 1 删除权威副本时,若底层磁盘在阶段 1 部分写入后报错,调用方应在存储恢复后检查对象状态并重试。两阶段顺序解决的核心问题是绝不在已知其他池清理失败的情况下提前删掉权威副本。
- 显式历史版本删除:显式指定
versionId删除某个历史版本或删除标记后,更早的保留版本成为当前版本,这属于正常的 S3 版本控制语义。
最终实现
单个对象删除
普通无版本删除按先旧后新的顺序依次清理各池副本,遇到错误立即停止。条件删除与显式版本删除继续走已有的 deleteObjectReconciled。若选中的无版本对象已完成分层(tier)迁移,同样进入 deleteObjectReconciled,防止本地清理破坏共享的远端分层引用。
普通单对象创建删除标记时,若多个池持有该对象,协调层会跳过已知不满足删除 quorum 的池,优先选择通过检查的可读持有池写入删除标记。
批量数据删除分成两个阶段
新增的 deleteObjectsReconciled 直接复用既有命名空间写锁和 objectPoolInfos 解析器,不引入新的锁域。
对象元数据查询并发上限为 32。每项查询读取所有池中的指定版本并按 ModTime 排序;时间戳完全相同时以较小的池索引决胜,与读取路径保持一致。不可读元数据直接作为错误处理,绝不视为“该池无此对象”。
每个阶段内部仍跨池并行执行,继续调用现有的纠删集与磁盘层批量接口。由于同一批次内不同对象的权威副本可能分布在不同存储池,固定全局池顺序无法满足要求,必须按每项各自的权威池划分阶段 0 与阶段 1。
阶段 0 同样包含查询时报告对象不存在的非权威池:读 quorum 看到“不存在”并不能证明某块暂时离线的磁盘上没有残留旧物理副本。若该池无法完成删除,对应请求项会保守返回错误。
所有结果从一开始就完整保留原始 key、版本 ID 和 ReplicationState,过滤与重新分批均通过显式下标映射还原请求顺序。在线磁盘预检查继续用于提前拒绝已知的 quorum 缺失,运行期安全性则由两阶段顺序保障。
版本配置与删除标记顺序
批量入口沿用底层版本判断规则,区分物理删除与删除标记创建:
| 请求 | 执行动作 |
|---|---|
显式 versionId=null 或 UUID |
物理删除指定版本;多池批量请求执行两阶段清理 |
| 无版本 ID,普通无版本 key | 两阶段物理删除对象数据 |
| 无版本 ID,已启用版本控制 | 创建删除标记,保留历史版本 |
| 无版本 ID,已暂停版本控制 | 创建或替换 null 删除标记 |
| 配置了前缀级版本规则 | 对当前 key 计算 PrefixEnabledFn,并结合暂停状态判断 |
物理删除先于删除标记创建执行,从而保证同一批次内先显式清除 null 版本、再为同一 key 创建新标记时的先后顺序。纯标记批次继续走既有路径(降级场景下的已知边界见下文兼容性、重试与运行边界)。
部分磁盘清理后的重试
当某池仅部分磁盘完成了版本删除、其余磁盘失败时,客户端发起重试会遇到第二类问题:一部分磁盘返回“删除成功”,已删过该版本的磁盘则返回“文件或版本不存在”。若将两类响应分开计票,两边都可能达不到 quorum。
erasureObjects.DeleteObjects 的修正仅针对纯物理删除将成功移除与 errFileNotFound / errFileVersionNotFound 合并计入删除 quorum(未知 I/O 错误不计入)。对于创建删除标记或更新复制状态的操作,仍要求实际写入成功达到 quorum,避免把“目标不存在”误判为新标记或元数据状态已落盘。
单版本与批量删除路径(含此处的删除标记写入)的 quorum 为 floor(N/2) + 1(N 为纠删集磁盘数)。
共享分层对象
不同池的物理副本可能指向同一个远端分层对象(包括某份副本已临时恢复到本地的情况)。清理非权威本地副本时,只要保留的权威副本仍依赖该远端对象,就不能将其排入远端垃圾回收。
因此,批量请求中包含已完成分层迁移副本的项会转入既有的逐对象协调路径,完整保留 SkipFreeVersion、复制状态与前缀版本选项,并按对象串行执行(同时避免同一批次内的重复项并发改写元数据)。普通本地对象继续走两阶段批量路径。新增回归测试覆盖了共享与独立远端引用、已恢复副本、权威副本删除前失败以及显式 SkipFreeVersion 请求。
最终版本的验证
各演进提交与验证角色如下:
| 版本 | 角色 |
|---|---|
000e653038 |
对照基线 main(不含 #245 覆盖亲和) |
a30270d9ba |
初始 PR head,复现预检查后的执行期故障窗口 |
3c82748e14 |
补充分阶段批量清理、结果身份保持与部分删除重试 |
a2073d2514 |
最终 PR head,补齐共享分层对象保护 |
aafe4f8886 |
合并后的 main(源码树与 a2073d2514 一致) |
入库的故障测试在全盘在线状态下进入 DeleteVersions 并注入错误,同时断言返回正确的逐项错误且权威副本的删除尝试次数为零,并逐一验证故障期间的 GET、恢复后的 GET 与版本列表、重试成功及无关项正常删除:
| 覆盖范围 | 合并源码中的测试入口 |
|---|---|
预检查后 I/O 故障,两池/三池、全部权威位置、无版本/null/UUID |
TestPoolsBulkDeleteKeepsLatestOnIOFailure |
| 带复制状态的签名 HTTP 响应身份 | TestPoolsBulkDeleteReplicationIdentity |
| 混合 null 清除/标记、部分清理与重试、不同权威池 | 批量顺序与重试测试 |
相同时间戳、不可读元数据、共享锁/并发 PUT、对象层取消 |
选择与并发测试 |
| 物理删除 quorum 与标记/复制写入的区别 | TestBulkDeleteQuorumEvidence |
| 共享分层引用和已恢复对象 | TestPoolsRetiringCopyPreservesSharedTierObject |
| 原始单删、批量错误、已知 quorum 缺失与标记选池回归 | 原贡献的回归测试 |
新增回归测试在初始 PR 代码上可稳定复现失败;最终合入的删除、分层与 quorum 套件连续通过三轮 -race 测试,合并提交后再次通过复测。补充检查覆盖了单池对照组、前缀版本配置、目录与重复 key、Object Lock、版本清除及迁移回归。
最终 PR head 的 Go CI 与合并后 main 的 Go CI 均通过完整 cmd / internal 测试、race 测试、静态检查及 Linux、macOS、Windows 的 amd64 / arm64 交叉编译;DCO 与 VulnCheck 同步通过。
回归测试命令:
实机 mcli 验证使用单进程两池(每池四个目录盘):先用基线版本写入数据,重启切换至修复版验证读取、批量删除、UUID 版本删除、启用/暂停版本控制下的删除标记与新写入,最后切回旧服务端重新打开同一数据目录,升级与回退均正常。
性能:实测删除代价
性能基准直接对比基线 000e653038 与最终实现 a2073d2514(与合并后的 main 源码一致)。
测量方法
- 硬件环境:Apple M5 Max(18 物理核心、128 GiB 内存),单块 8 TB Apple SSD(APFS,测试时空间使用率约 95%,所有逻辑目录盘共享该物理 SSD)。
- 配置参数:每池 4 个目录盘(EC 2+2),4 KiB 对象,8 个并发 worker,
GOMAXPROCS=8,对象层直接调用(不含 HTTP、鉴权与跨节点 RPC)。 - 测试矩阵:5 种“池数/副本数”布局(此处的“副本”指一个逻辑对象在不同池中的物理副本数,而非纠删码分片),分别测试单对象
DELETE(每轮 1,024 次请求)与每批 64 项的批量删除(每轮 64 次请求、共 4,096 个对象)。 - 采样方式:每种布局和操作执行 3 对交替运行(基线→修复版、修复版→基线、基线→修复版),每轮重建相同初始布局(准备与清理不计入耗时)。共采集 60 个运行样本、30,720 次单删请求、1,920 次批删请求,累计删除 153,600 个逻辑对象,零请求错误。
结果与波动
吞吐量单位为“逻辑对象/秒”,表中给出三轮中位数及实测范围;配对变化为三对交替样本百分比变化的中位数。
| 池数/副本;操作 | 基线中位数 [范围] | 修复版中位数 [范围] | 配对变化 |
|---|---|---|---|
1 / 1;DELETE |
1,286 [1,193–1,292] | 1,347 [1,269–1,371] | +6.1% |
| 1 / 1;bulk 64 | 1,786 [1,739–1,809] | 1,818 [1,680–1,982] | +4.5% |
2 / 1;DELETE |
1,350 [1,272–1,363] | 1,304 [1,270–1,327] | −1.7% |
| 2 / 1;bulk 64 | 1,477 [1,468–1,740] | 1,453 [1,149–1,604] | −7.8% |
2 / 2;DELETE |
560 [546–641] | 534 [469–534] | −16.3% |
| 2 / 2;bulk 64 | 606 [528–695] | 620 [546–707] | +2.2% |
3 / 1;DELETE |
988 [722–994] | 1,053 [974–1,130] | +13.6% |
| 3 / 1;bulk 64 | 1,338 [1,269–1,383] | 1,120 [1,055–1,163] | −16.9% |
3 / 3;DELETE |
364 [323–371] | 354 [335–364] | −2.0% |
| 3 / 3;bulk 64 | 404 [367–450] | 365 [313–378] | −14.5% |
请求级延迟(单位:毫秒,bulk 64 为整批 64 个对象的单次请求耗时,方向为基线→修复版):
| 池数/副本;操作 | P50,ms | P99,ms |
|---|---|---|
1 / 1;DELETE |
5.69 → 5.50 | 15.06 → 14.37 |
| 1 / 1;bulk 64 | 277.28 → 280.00 | 406.23 → 385.23 |
2 / 1;DELETE |
5.46 → 5.47 | 14.09 → 14.88 |
| 2 / 1;bulk 64 | 317.10 → 346.26 | 635.16 → 463.78 |
2 / 2;DELETE |
12.91 → 13.76 | 35.86 → 45.54 |
| 2 / 2;bulk 64 | 823.19 → 752.78 | 1,315.81 → 1,297.48 |
3 / 1;DELETE |
7.23 → 6.42 | 22.09 → 20.20 |
| 3 / 1;bulk 64 | 361.73 → 444.34 | 669.84 → 732.27 |
3 / 3;DELETE |
20.35 → 21.00 | 50.75 → 48.92 |
| 3 / 3;bulk 64 | 1,236.42 → 1,392.63 | 1,697.38 → 2,079.27 |
P99 采用 nearest-rank 算法(每轮批量请求共 64 次,P99 接近单轮最大值)。单池控制组的调度路径未变,无性能回退;三池批量删除吞吐量下降约 15–17%,三池三副本的 P99 延迟增加约 22.5%;两池两副本的单对象删除吞吐下降 16.3%,同布局批量删除则在波动范围内。
代价来自哪里
单对象删除需要按先旧后新的顺序串行清理各池副本;批量删除则增加了跨池元数据解析以及阶段 0 到阶段 1 的先后等待(阶段内部仍保持跨池并行与磁盘批处理)。对于批次大小 $B$、池数 $P$、每纠删集磁盘数 $N$,元数据解析需读取 $B \times P$ 个池级对象状态,在线盘预检查至多检查 $B \times P \times N$ 个磁盘状态。由于共享 key 写锁贯穿上述流程,额外耗时也会相应延后针对同一 key 的并发写入。为守住删除失败不丢最新副本的一致性底线,这一多池删除开销是必要且可接受的。
兼容性、重试与运行边界
- 错误码与客户端重试:单对象删除的写 quorum 失败通常返回 HTTP 503;批量
DeleteObjects则在 HTTP 200 响应体内逐项返回SlowDownWrite等错误码。实测 S3 SDK 在MaxRetries=4下收到包含逐项SlowDownWrite的 HTTP 200 响应时不会自动重发整批请求,mcli rm则会直接报告失败项。调用方必须解析批量响应中的错误列表,在存储恢复后携带原versionId重试失败项。 - 并发与请求取消:既有共享对象写锁将多池删除与跨池写入串行化。HTTP 批量删除 handler 使用脱离客户端连接的后台 context,客户端中途断连不会中止已开始的批次。
- 暂停版本桶的降级批量标记边界:补充测试记录了一处在基线
main中既有的边界行为:在两池和三池布局下,仅含 UUID 历史版本的暂停版本桶在磁盘降级期间执行批量删除标记创建,待磁盘恢复后显式清除null标记仍可能返回 quorum 错误(一次运行暴露出保留的 UUID 历史版本,另一次运行保留了删除标记)。本次修复未改动纯标记批量路径的这一既有行为。 - Object Lock 与条件删除:Object Lock 与保留期权限检查保持不变;批量 XML
DeleteObjects的 ETag 条件仍不受支持,详见条件 DELETE。 - 升级与回退:本次修改不涉及 S3 参数、RPC 协议、磁盘格式、外部依赖、选池分配规则或配置开关变更。混合版本滚动升级期间,尚未升级的协调节点仍按旧逻辑处理其接收的删除请求;回退旧版本不会恢复此前已丢失的副本。
备选方案取舍
| 备选方案 | 不采纳原因 |
|---|---|
| 仅汇总各池错误 | 只能修复“删除失败却报成功”,无法挽回已被提前删掉的最新权威副本 |
| 仅做在线磁盘预检查 | 无法拦截预检查通过后在元数据读写或删除执行阶段才发生的磁盘 I/O 错误 |
| 按固定全局池顺序删除 | 同一批次内不同对象的权威副本可能位于不同存储池,固定池顺序无法兼顾 |
| 所有批量删除全部改为逐对象串行 | 不必要地放弃了普通本地对象的纠删集与磁盘层批处理吞吐;当前仅对含分层引用的对象走逐对象协调路径 |
| 依赖覆盖亲和(#245) | 覆盖亲和会带来常规小对象写入回退,且仍需处理存量跨池副本、迁移副本与中断清理,无法替代删除阶段的顺序保护 |
| 引入跨池回滚事务或配置开关 | 逐项两阶段清理已能保住权威副本,无需引入复杂的分布式回滚状态机或额外开关 |
4.23 - Go 1.27 TLS 默认值与 OIDC Discovery 故障诊断
SILO 工具链升级至 Go 1.27 后,Server TLS 修复 48e184652(“fix(tls): honor Go key exchange defaults across transports”,随 Server 20260916 发布)移除了 8 处 Server TLS 配置点上的显式椭圆曲线覆盖。本文记录 Go 1.27 在 TLS 层的变更细节、issue #154 排查总结的分阶段诊断方法,以及身份提供方(IdP)在启动阶段不可达时的健康检查端点行为。
关于发布后复测的深入机制分析——为什么 GODEBUG=tlsmlkem=0 只能关闭混合密钥交换而无法隐去新增的 12 字节 ML-DSA 签名算法扩展,以及应对 TLS 深度检测中间设备的完整配置方案——参见《写死 TLS 参数与握手兼容性》。
Go 1.27 在 TLS 层的变化
- 显式配置的
CurvePreferences会覆盖 ML-KEM 兼容开关。GODEBUG=tlsmlkem=0会从默认曲线集合中移除全部 ML-KEM 混合密钥交换方案;tlssecpmlkem=0则仅移除 Go 1.26 新增的 P-256/P-384 混合方案并保留X25519MLKEM768。但在 Go 1.27 中,只要应用显式设置了CurvePreferences,标准库就会原样保留该列表中的 ML-KEM 项,不再受上述GODEBUG开关控制。此前 SILO Server 有 8 处 TLS 配置显式设置了包含X25519MLKEM768的曲线列表;本次修复移除了这 8 处赋值并废弃相关辅助函数,使这些配置点直接沿用 Go 标准库默认曲线,恢复GODEBUG兼容开关的效果。跨仓库核查确认silo-pkg、mcli与 Console 客户端原本即使用标准库默认值;Console 的 HTTPS 服务端监听器则保留其独立的仅 P-256 策略。 ClientHello新增 ML-DSA 签名算法标识(0x0904–0x0906)。ML-DSA 属于签名算法而非密钥交换算法:关闭混合密钥交换不会移除ClientHello中的 ML-DSA 声明,因不识别 ML-DSA 标识而拦截握手的网关无法通过 ML-KEM 相关开关绕过。ClientHello报文体积增大。 基于同一套源码与依赖实测:Go 1.26.5 默认握手报文为 1497 字节;Go 1.27.1 默认握手报文为 1509 字节;配置tlsmlkem=0时,Go 1.26.5 配合旧显式曲线列表会生成不含 ML-KEM 的 275 字节ClientHello,而在 Go 1.27.1 下配合旧显式列表仍会生成含 ML-KEM 的 1509 字节报文。因此,仅升级编译器版本就会直接改变出站 TLS 握手载荷。- macOS 根证书信任库行为随主模块
go指令切换。 新启动的进程优先读取SSL_CERT_FILE/SSL_CERT_DIR还是系统 Keychain,取决于x509sslcertoverrideplatformGODEBUG 的默认值,而该默认值由顶层构建主模块的go指令决定:声明go 1.26的主模块在 macOS 上忽略这两个环境变量(始终优先使用系统 Keychain),声明go 1.27的主模块则会遵循这两个变量(不受底层依赖库模块的go版本影响)。在 macOS 上运维时需注意:一旦设置了其中任一环境变量,进程就会用指定文件或目录整体替换系统 Keychain 信任库;若变量指向过期或不完整的 CA 证书包,原本可通过 Keychain 验证的证书链就会失败,清除该环境变量即可恢复使用 Keychain。 - 未变更的 TLS 与平台基线。 TLS 协议版本、密码套件、证书链与主机名校验、代理环境变量解析及 HTTP/2 协商规则均保持不变。标准库响应体排空上限(256 KiB / 50 ms)等其余 Go 1.27 变更经审计均不影响 SILO。此外,Go 1.27 编译产物要求 macOS 13 或更高版本;Server、Console 与
mc的模块依赖图均要求 Go ≥ 1.27.1,不支持回退至旧版工具链编译。
为什么撤回了仅针对 OIDC 的局部补丁
排查初期曾拟定一个最小改动方案:仅在 OIDC discovery 使用的 HTTP transport 上清空 CurvePreferences。该方案最终被否决。原因在于同一个底层构造函数同时服务于身份插件、事件通知与 Lambda 连通性探测、审计 webhook 以及 S3 云存储后端分层——若只修改两处 OIDC 调用点,其余出站 TLS 客户端仍会停留在带硬编码曲线列表的状态。最终合入的提交统一移除了全部 8 个 Server 配置点的显式曲线覆盖,使兼容开关在所有出站与入站配置点一致生效,同时保持严格的证书校验,不引入协议降级或静默回退。
按连接阶段诊断 Discovery 故障
服务端 OIDC 启动链路依次为:进程启动 → 身份子系统初始化 → 请求 .well-known/openid-configuration(discovery)→ 请求 jwks_uri 拉取公钥集 → IAM 存储就绪 → Console 初始化。Console 界面上的 OIDC 配置校验同样复用这一服务端 transport。链路中任一环节失败都会导致 IAM 处于离线状态;即使 discovery 文档拉取成功,只要后续 JWKS 请求返回 503 或超时,IAM 依然无法就绪。
排查时可按连接中断所处的阶段快速定界:
- 发送 TLS
ClientHello后立即收到 TCP RST(tls_start后连接重置):重点排查入口网关或中间设备对ClientHello的处理——包括代理CONNECT策略、TLS 终止器,以及基于握手报文长度或扩展字段匹配的过滤规则。Go 1.27 的曲线与签名算法变化均集中在这一阶段。 - TLS 握手完成后收到重置或拒绝(
wrote_request后中断):说明 TLS 握手本身已通过,应排查 HTTP 层策略——例如 WAF 规则、User-Agent 白名单(服务端默认 UA 已随品牌重命名由MinIO改为Silo)以及七层路由配置。此时调整证书或密钥交换参数不会生效。 - x509 证书校验错误:核对对端实际返回的证书链、请求携带的 SNI,以及当前进程实际加载的根证书信任库(macOS 环境重点检查
SSL_CERT_FILE/SSL_CERT_DIR,见上文)。 - 诊断命令必须在与故障进程相同的网络命名空间与出口路径下执行——新启动的临时容器默认不共享故障容器的网络环境,应优先保证源 IP 与代理配置一致。
反映 IAM 真实状态的健康检查端点
当 IAM 处于离线状态时,/minio/health/live 与 /minio/health/ready 依然返回 HTTP 200,因为常规存活与就绪探针不检查身份子系统。真正检查身份系统初始化状态的端点是 /minio/health/cluster:当 IAM 未就绪时,该端点返回 503 Service Unavailable,并在响应头中附带 X-Minio-Server-Status: iam-offline。需要感知 IdP 连通性故障的监控告警应探测 /minio/health/cluster,并配合一次带认证的业务探针。
身份子系统具备自动恢复能力:初始化失败后会按 0–3 秒的随机间隔后台重试,一旦上游 IdP 恢复可达,IAM 无需重启进程即可自动上线(本地实测恢复耗时在亚秒级至 1.4 秒之间)。但后台重试无法绕过持续存在的握手兼容问题——被网关拒绝的 ClientHello 在每次重试时仍会被拒绝。
Discovery 与 JWKS 客户端的传输层参数
OIDC discovery 与 JWKS 客户端使用独立构造的 http.Transport,其关键参数如下:禁用 HTTP/2(不发送 ALPN h2,固定使用 HTTP/1.1);代理配置仅读取 HTTPS_PROXY 与 NO_PROXY(优先读取大写变量,忽略 ALL_PROXY);DNS 缓存刷新周期在 Kubernetes/Docker 环境下默认为 30 秒,其余环境默认为 10 分钟(可通过 DNS cache TTL 配置项覆盖);多 IP 拨号按解析顺序依次尝试而不做随机打乱;单次 TCP 拨号超时为 5 秒,TLS 握手超时为 10 秒,等待响应头超时为 1 分钟。需要注意,单次 discovery 或 JWKS 请求本身未设置整体请求超时(Request Timeout)——如果上游 IdP 持续缓慢响应,启动阶段的初始化会一直等待。
来源与验证说明
本文整理自 issue #154 的故障排查记录与九月份 Go 1.27 工具链全栈评审。文中所述的各项机制均通过合成实验(抓包比对 ClientHello、独立进程根证书探针及本地测试夹具复现)验证,并与 Go 1.27 官方发布说明及标准库 crypto/tls、crypto/x509 源码核对一致。
4.24 - 鉴权前不做 I/O,Header 不授予权限
本文记录通过 PR #101(提交 938603458 至 04b097fd9)合入 SILO、编号为 SN-2026-008 并随 Server 20260903 发布的 CORS 热路径与复制请求信任边界修复。
概览
两个表面上互不相关的缺陷,实际上源于同一个架构层面的设计失误:
- 任何携带
Origin请求头的 HTTP 请求都会促使最外层 CORS 中间件把 URL 第一段路径当成存储桶名称,并在身份认证之前同步从磁盘加载该桶的元数据。 - 任何携带
X-Minio-Source-Replication-Request请求头的请求,仅凭该请求头存在就会让下游处理器将其视为内部复制请求。
两者的共同根因在于:不可信的请求语法在跨越授权边界之前,就已经获得了高开销或高特权的内部语义。
本次修复确立了两条核心安全不变量:
对于 CORS,最外层中间件现在只读取已驻留在内存中的存储桶元数据。对于复制,处理器先对原始签名请求完成身份认证,再校验对应的复制权限(s3:ReplicateObject 或 s3:ReplicateDelete),随后将私有的信任结论写入请求上下文(context.Context)。不可信的内部请求头仅在 SigV4 验签完成后才被剥离。下游选项构造器、加密路径、Object Lock 检查、事件通知与元数据持久化统一读取上下文中的信任结论,而不再检查原始请求头。
缺陷 A:鉴权前的 CORS 元数据放大攻击
corsHandler 包裹了整个服务端路由器。任何携带 Origin 请求头的请求都会在进入 S3 签名认证、请求合法性检查和常规 API 并发限流器之前先经过该中间件。
在旧实现中,按桶 CORS 检查直接调用了通用的存储桶元数据加载器:
当磁盘上不存在 .metadata.bin 时,加载器会继续探测 10 个历史遗留配置路径;全部未命中后,它不会返回 NoSuchBucket,而是构造并返回一条合法的空 BucketMetadata 记录,随后由通用读取器插入内存映射表 metadataMap。
因此,未认证客户端只需不断变换语法合法但并不存在的存储桶名称,每个新名称都会触发双重放大效应:
- 在进入 API 限流器之前触发未受限的纠删码/存储层元数据读取(1 次
.metadata.bin加 10 次旧路径探测); - 导致内存中的
metadataMap持续膨胀(分布式部署每 15 分钟才清理一次过期项,而单节点部署不启动后台刷新循环,伪造条目会一直驻留到进程重启)。
缺陷 B:标记请求头被直接当作权限权威
SILO 与兼容 MinIO 的复制客户端在跨站复制时通过内部请求头传递源端状态,其中最核心的标记头是:
在本次修复前,多条代码路径只要看到该请求头存在(或其字符串值为 "true"),就会直接认定当前请求来自受信任的复制对端,影响范围遍及多个子系统:
- 仅具备普通
s3:GetObject权限的调用方在对 SSE-C 对象发起GetObject、HeadObject或GetObjectAttributes时,可利用该标记触发NoDecryption分支,无需提供客户端密钥即可直接读取原始密文; - 客户端提供的源 ETag 与修改时间(
MTime)可以覆盖服务端生成的值; - 源端标签、对象锁保留期与法律保留(Legal Hold)时间戳可渗入“最后写入者胜出”(LWW)冲突比较;
- 对象锁解析器通过检查原始标记头即可放行已经过期的
RetainUntilDate保留时间; - 调用方可自行指定删除标记(Delete Marker)的
VersionID与修改时间; - 成功的对象创建与访问通知事件可被静默抑制;
- 分片上传完成(
CompleteMultipartUpload)阶段可注入实际对象大小与加密校验和元数据; - 普通
PutObject、CopyObject、Snowball/PAX 归档解压或浏览器POST表单策略上传只要附带X-Amz-Replication-Status: REPLICA,就能将该状态直接持久化到对象元数据中。
此前的 CVE-2026-34204 修复已经阻止了普通 PutObject 与 CopyObject 导入会导致对象不可读的复制 SSE 元数据,但当时尚未为所有读取复制标记、源时间戳、事件状态、对象锁豁免与分片完成元数据的消费者建立统一的授权后信任源。
复制信任模型设计
精确标记解析与两级信任模型
仅当 X-Minio-Source-Replication-Request 恰好出现一次且其值严格等于小写 "true" 时,解析器才视其为有效标记;重复请求头、大小写混合或任何其他字符串一律视为不可信。
完成身份认证后,处理器派生出两个层次的信任决策:
| 信任级别 | 准入要求 | 允许启用的内部语义 |
|---|---|---|
trusted |
原始请求已通过非匿名身份认证 + 精确标记头 + 对目标资源具备 s3:ReplicateObject(或 s3:ReplicateDelete)权限 |
保留源端 ETag/MTime 及子系统时间戳;传递分片实际大小与加密校验和;抑制重复事件通知与二次复制;固定复制删除的目标版本与存储池 |
replicaTrusted |
满足 trusted,且当前请求携带原始状态 REPLICA(或分片上传初始化时已持久化 REPLICA 状态) |
持久化 REPLICA 状态;导入复制 SSE 密封密钥;启用 SSE-C 免解密原始密文读取路径(NoDecryption);启用副本专用的对象锁豁免规则 |
之所以必须区分 trusted 与 replicaTrusted,是由真实线缆协议决定的:合法的分片上传(UploadPart)、分片完成(CompleteMultipartUpload)以及批量复制(Batch Replication)请求只会携带 X-Minio-Source-Replication-Request: true,而不会在每个子请求上重复附带 X-Amz-Replication-Status: REPLICA。
接收端按以下矩阵处理入站请求:
| 入站请求形态 | 接收端行为 |
|---|---|
| 无标记头 | 按普通 S3 请求处理 |
| 携带标记头,但缺少复制权限 | 剥离并忽略内部复制请求头,按普通 S3 语义继续执行 |
携带 X-Amz-Replication-Status: REPLICA,但缺少复制权限 |
直接返回 403 AccessDenied |
精确标记头 + 具备复制权限,无 REPLICA 状态头 |
仅授予 trusted |
精确标记头 + 具备复制权限 + REPLICA 状态头 |
同时授予 trusted 与 replicaTrusted |
对未获授权却声称 REPLICA 的请求显式返回 403 AccessDenied,可以防止配置错误的副本写入被静默降级为普通新对象写入并触发反向复制循环。
先验签原始请求,再清洗内部请求头
由于 AWS SigV4 会将 x-amz-* 与 x-minio-* 请求头纳入签名计算,若在验签前删除内部请求头,就会破坏规范化请求(Canonical Request)并导致合法签名报 SignatureDoesNotMatch。因此处理顺序必须严格固定:
剥离请求头后的克隆请求会与原请求共享底层 http.Request.Trailer 映射,确保普通客户端的 aws-chunked 流式校验和上传不受影响。Snowball/PAX 批量归档路径则按归档条目独立隔离信任状态(ff44527a3),同时在后台工作协程之间保留合法的请求默认属性(ab3ae99ca)。
将副本状态移出通用用户元数据白名单
X-Amz-Replication-Status 已从通用的受支持请求元数据提取列表中移除。普通 PutObject、CopyObject、分片上传初始化、Snowball 归档提取以及浏览器 POST 表单策略均无法再通过提交同名字段来持久化 REPLICA 状态;服务端仅在通过验证的 replicaTrusted 分支中显式写入 REPLICA。
对象锁显式接收信任决策
对象锁解析包不再直接读取 X-Minio-Source-Replication-Request 请求头,而是由外层处理器根据 replicaTrusted 状态显式传入 allowPastRetainDate 参数;外层处理器在判断入站副本是否允许覆盖既有合规模式(Compliance)或法律保留版本时,同样使用同一个 replicaTrusted 决策。
真实复制线缆协议矩阵
| 操作类型 | 标记头 | 当前请求是否携带 REPLICA |
接收端信任决策 |
|---|---|---|---|
常规复制 PutObject |
是 | 是 | replicaTrusted |
复制 CreateMultipartUpload |
是 | 是 | 持久化受信任的分片上传 REPLICA 来源状态 |
复制 UploadPart |
是 | 否 | trusted;仅当已保存的分片上传状态为 REPLICA 时提升为 replicaTrusted |
复制 CompleteMultipartUpload |
是 | 否 | trusted;保留源端 ETag/MTime、实际大小与加密校验和 |
CopyObject 元数据复制 |
是 | 是 | replicaTrusted |
复制 DeleteObject |
是 | 是 | replicaTrusted(需具备 s3:ReplicateDelete 与 s3:DeleteObject,且 s3:DeleteObjectVersion 未被显式拒绝) |
批量复制 PUT / Complete |
是 | 否 | trusted(目标端凭据必须包含 s3:ReplicateObject) |
| 代理转发 / 就绪检查 / 有效性探针 | 独立探针头 | 否 | 保留探针头;有效性探针在规则前缀下校验复制权限(c9ad74673、5db7be4ee) |
CORS 纯内存驻留状态机
最外层 CORS 中间件现在调用专用的纯内存驻留(resident-only)访问器,仅获取一次内存读锁,绝不触发任何存储层 I/O:
| 存储桶元数据内存状态 | CORS 决策 | 存储层 I/O |
|---|---|---|
| 已驻留,且包含合法的桶级 CORS 配置 | 应用桶级 CORS 规则(后台刷新失败时保留上一次成功加载的配置) | 无 |
| 已驻留,但未配置桶级 CORS | 回退使用全局 CORS 配置 | 无 |
| 已驻留,但持久化的 CORS XML 损坏 | 失败关闭(Fail closed:不输出 CORS 响应头并记录一次日志) | 无 |
| 未驻留,且启动期初始加载尚未完成 | 失败关闭(Fail closed) | 无 |
| 启动完成后未驻留:磁盘上真实存在但元数据加载失败的桶 | 失败关闭(Fail closed) | 无 |
启动完成后未驻留:不存在、保留名、非法名或 .minio.sys 命名空间 |
回退使用全局 CORS 配置 | 无 |
启动加载期与已知加载失败状态均采用失败关闭(fail closed)策略:由于预签名 URL(Presigned URL)本身已在查询字符串中携带认证签名,存储桶自定义的 CORS 配置是浏览器端唯一能够约束其调用源(Origin)的边界;若在桶元数据加载失败时回退到宽松的全局 CORS 策略,泄露的预签名 URL 就可能被任意网页源跨域调用。
被拒绝的替代方案
| 被拒绝的替代方案 | 拒绝原因 |
|---|---|
| 在调用旧 CORS 加载器前先校验桶名语法 | 攻击者仍可构造无穷多个语法合法但并不存在的桶名,依然会触发鉴权前磁盘 I/O |
加载 CORS 前先调用 GetBucketInfo |
只是把 11 次元数据探测替换成每个伪造桶名至少 1 次不受限的后端存储探测 |
| 为不存在的桶增加带 TTL 的负缓存 | 只能限制单条缓存存活时间,无法限制攻击者构造海量不同桶名的基数(Cardinality)与初始 I/O 放大 |
| 在 SigV4 验签前直接剥离复制请求头 | 破坏 SigV4 规范化请求计算,导致合法请求报 SignatureDoesNotMatch |
| 拒绝所有携带内部复制标记头的请求 | 会使原本应被忽略的多余请求头引发大面积客户端报错,并打断合法的仅标记(marker-only)复制调用 |
要求所有受信任复制请求都携带 REPLICA |
破坏复制 UploadPart、CompleteMultipartUpload 与批量复制的真实线缆兼容性 |
在 ObjectOptions 中保存布尔值,但让事件与对象锁继续读请求头 |
留下两个可能互相矛盾的信任源 |
实现分层与验证矩阵
整体实现划分为七个清晰边界:
- 独立的请求信任模块负责精确解析标记头、评估复制 IAM 权限、将私有信任结构体写入
req.Context(),并生成验签后的清洗版请求克隆; putOpts、copyDstOpts、getOpts与delOpts构造器仅在调用方传入受信任状态时才解析源端复制字段;DecryptObjectInfo、通知事件参数构造、分片上传完成、删除选项与对象锁统一消费同一份上下文决策;- 各 S3 处理器在现有身份认证通过后立即计算复制信任结论;
- 分片上传(
PutObjectPart)将当前请求的trusted状态与分片上传初始化时持久化的REPLICA来源状态结合判定; - 通用用户元数据提取器不再接受
X-Amz-Replication-Status; corsHandler使用独立的纯内存驻留访问器,绝不调用缓存未命中即读盘的通用加载器。
验证矩阵
自动化回归测试与真实双站点 TLS 黑盒复制测试共同验证了以下场景:
- 数百个不同的合法未创建桶名在预检(preflight)与实际 CORS 请求下,磁盘元数据读取次数为零且
metadataMap大小不增长; - 最小权限的 SSE-C
GetObject、HeadObject与GetObjectAttributes调用方在缺失、大小写错误或未授权复制标记下均无法绕过密钥检查; - 仅携带标记头的批量风格
PutObject只有在具备s3:ReplicateObject权限时才保留源端 ETag 与MTime; - 未获授权的
REPLICAPutObject与DeleteObject稳定返回403 AccessDenied; - 浏览器
POST表单策略无法伪造REPLICA状态; - 对象锁过去保留时间在无
replicaTrusted时被拒绝、在受信任副本写入时正常放行; - 两个启用 TLS 的 SILO 节点通过真实站点复制同步 SSE-C 4 KiB 单对象、SSE-C 12 MiB 三分片对象、SSE-C
CopyObject以及复制删除标记,源端与目标端的 ETag、大小、VersionID、SSE-C 密钥 MD5 及解密后 SHA-256 完全一致。
兼容性与后续工作
- 普通客户端: 请求无需任何修改;不可信的内部复制请求头会被安全忽略,不再获得特权语义。
- 批量复制(Batch Replication): 目标端凭据必须包含
s3:ReplicateObject权限(详见批量复制配置文档);缺少该权限时,接收端会将仅带标记头的写入当作普通写入处理,不再保留源端 ETag 与MTime。 - 滚动升级: 线缆协议与磁盘格式完全不变。新节点立即执行纯内存 CORS 查询与复制信任边界。
后续演进
- 复制执行可靠性(2026-09-09): 复制可靠性:删除完成、MRF 可见性与重同步取消记录了 PR #162 对 #153、#152 与 #137 的修复,处理受信任复制请求进入执行流水线后的状态收敛问题。
- 限频诊断日志: 当携带复制标记头的请求缺少复制权限并回退为普通写入时,接收端会输出限频告警日志,帮助运维人员快速排查缺少
s3:ReplicateObject权限的批量复制任务,避免将其误判为底层 ETag/MTime 不一致。
结论
该修复于 0079723d3(PR #159)合入主分支并随 Server 20260916 发布,通过纯内存驻留 CORS 查询与鉴权后统一计算的复制信任边界,彻底消除了未认证桶元数据磁盘探测与未受信任复制请求头注入。
4.25 - 一个端点,两种权限:彻底分离用户与组状态
本文记录 上游议题 minio/minio#21478、SILO PR #73(提交 58735ee38 / 2e2377d1c)以及 set-group-status 组状态权限善后提交(229fe2b3c)的鉴权设计与验证记录。上述修复已随 Server 20260903 发布。
太长不看(TL;DR)
SILO 同时定义了 admin:EnableUser 与 admin:DisableUser 两项策略权限,但共用的 set-user-status 处理函数过去无论目标状态是什么,都固定检查 admin:EnableUser。结果,仅授予 admin:DisableUser 的策略反而无法禁用账户;要想执行禁用,就必须额外授予 admin:EnableUser,直接打破了这两项权限的最小权限边界。
修复方案在鉴权前根据请求的目标状态映射出唯一对应的权限动作:
| 请求状态 | 必须具备的权限 |
|---|---|
enabled |
admin:EnableUser |
disabled |
admin:DisableUser |
| 非法或未知值 | admin:EnableUser,保留原有的“先鉴权、后校验”默认边界 |
随后处理函数仅调用一次 validateAdminReq。四向 IAM 集成测试同时验证了两条正向放行路径与两条交叉拒绝路径。这一设计不再允许“仅含 admin:EnableUser 的策略也能禁用用户”的历史行为,使两项权限各自严格对应单向操作。
相同的规则也同步应用于组状态端点:
| 请求的组状态 | 必须具备的权限 |
|---|---|
enabled |
admin:EnableGroup |
disabled |
admin:DisableGroup |
| 非法或未知值 | admin:EnableGroup,保留原有的“先鉴权、后校验”默认边界 |
修复前,仅持有 admin:EnableGroup 的调用方可以禁用组,而仅持有 admin:DisableGroup 的调用方在执行禁用时却会收到 AccessDenied。组状态修复同样采用“单一选择函数、单次鉴权调用”的结构。
被报告的问题
管理 API 使用同一个路由处理启用与禁用两种状态变更:
修复前,处理函数在读取目标状态参数之前就硬编码检查了启用权限:
后续调用的 SetUserStatus 虽然能正确处理 enabled 或 disabled,但前置鉴权已经将两种操作一律视为启用。admin:DisableUser 虽存在于策略语法与公开文档中,却无法独立授权该端点。
议题 #21478 给出了典型的生产场景:安全运维人员在应急响应期间需要一份“只能禁用异常账户、不能恢复账户”的受限策略。然而仅配置 admin:DisableUser 时请求会返回 AccessDenied;补上 admin:EnableUser 后禁用才得以执行,但调用方也同时获得了本应受控的账户恢复权限:
为什么两个 action 必须代表两种能力
账户状态变更具有明确的方向性。禁用操作通常下放给应急响应值班人员、反欺诈系统或合规自动化流程;而重新启用会恢复访问权限,往往需要更高层级的审批。
因此,两项权限必须严格正交:
| 调用方策略 | 禁用目标 | 启用目标 |
|---|---|---|
仅 admin:DisableUser |
允许 | 拒绝 |
仅 admin:EnableUser |
拒绝 | 允许 |
| 两者都有 | 允许 | 允许 |
| 两者都没有 | 拒绝 | 拒绝 |
内置的 consoleAdmin 策略授予 admin:*,完整管理员仍可执行双向操作;受影响的仅是曾依赖旧缺陷的自定义受限策略。公开的 PBAC 参考文档同样为 admin:EnableUser 与 admin:DisableUser 记录了这一契约。
设计目标与非目标
设计目标
- 让现有的两项管理权限严格按各自名称生效;
- 在启用与禁用两个方向上同时满足最小权限原则;
- 每个请求只执行一次鉴权判定,最多写出一次鉴权错误响应;
- 保持路由、查询参数、响应格式、禁止修改自身状态保护、IAM 存储调用与站点复制钩子不变;
- 用自动化测试锁定四向权限矩阵,防止两项权限再次被合并或错位。
非目标
- 将端点拆分为独立的启用路由与禁用路由;
- 新增合并权限名称或修改 IAM 策略语法;
- 修改用户状态的持久化格式或站点复制机制;
- 重新设计 Console 前端权限模型。
讨论过但没有采用的方案
两种状态继续只检查 admin:EnableUser
这会保留缺陷原状,使 admin:DisableUser 持续失效并迫使策略过度授权。
任一状态都同时要求两个 action
这会让两项权限失去独立意义,无法下放“仅允许禁用”的最小权限。
Enable 鉴权失败后,再尝试 Disable 鉴权
上游 PR #21482 对禁用请求采用了串联调用方案:先以 EnableUser 调用 validateAdminReq,若返回 nil 再以 DisableUser 调用第二次。
然而 validateAdminReq 有一项关键副作用:当它返回 nil 对象层时,已经向 http.ResponseWriter 写出了错误响应。对仅持有 admin:DisableUser 的调用方而言,第一次调用已经向客户端提交了 HTTP 403,第二次调用随后通过鉴权并继续修改账户状态。鉴权回退分支绝不能在错误响应已经写出后继续修改状态。
禁用请求接受 Enable 或 Disable 任一个 action
validateAdminReq 支持传入可变参数(variadic)权限列表,只要其中任一权限匹配即放行。若想兼容旧行为,只需向单次 validateAdminReq 调用同时传入两项权限,即可在放行 admin:DisableUser 的同时,保留“仅含 admin:EnableUser 也能禁用账户”的历史行为。
SILO 否决了该方案,因为“仅持启用权限即可禁用账户”正是需要修复的越权缺陷。需要管理完整账户生命周期的角色应当显式配置两项权限。
先校验 status,再进行鉴权
若在鉴权前先拦截非法 status 参数,就会改变错误响应优先级:原本未获授权的调用方在鉴权前就能探测参数格式校验结果。
因此,对于非法或未知的 status 值,选择函数继续返回默认的 admin:EnableUser;仅当 status 精确为 disabled 时才选择 admin:DisableUser。通过鉴权后,仍由既有的 IAM 逻辑拒绝非法状态值。
最终实现
实现新增了一个无副作用的纯选择函数:
处理函数先读取路由变量,选定所需权限,然后仅执行一次鉴权:
鉴权通过后的业务逻辑完全保持不变:
- 调用方仍不能启用或禁用自身账户;
globalIAMSys.SetUserStatus继续校验并持久化目标状态;- 站点复制继续同步状态与更新时间;
- 响应格式与审计日志仍走原有路径。
选择函数仅依赖请求传入的目标状态字面量,不在鉴权前读取目标用户当前状态,也不依赖目标账户是否存在,从而保持鉴权判定确定且无前置存储读取。
为什么这个修复是安全的
该实现满足五条安全不变量:
- 每个合法的目标状态严格映射到唯一一项管理权限;
validateAdminReq仅调用一次,鉴权失败后立即返回,绝不执行状态修改;- 状态修改调用仅在目标状态对应的权限通过校验后可达;
- 非法状态值保留原有的启用权限鉴权门槛,鉴权通过后仍由既有校验分支拒绝;
- 存储格式、站点复制、网络协议与客户端 SDK 接口均无变化。
测试设计
纯 action 映射
单元测试覆盖选择函数的三类输入:
| 输入 | 期望权限 |
|---|---|
enabled |
EnableUser |
disabled |
DisableUser |
| 非法值 | 保持默认的 EnableUser |
四向 IAM 鉴权矩阵
集成测试创建彼此独立的管理员账户与受限策略,通过真实管理 API 验证完整四向矩阵:
- 仅持有禁用权限的客户端可以成功禁用目标用户;
- 同一客户端尝试启用目标用户时收到
AccessDenied; - 仅持有启用权限的客户端可以成功启用目标用户;
- 同一客户端尝试禁用目标用户时收到
AccessDenied。
两个交叉拒绝断言确保任一策略都不会意外放行反方向操作。测试在既有 IAM 服务端测试套件中运行,完整覆盖请求签名、策略绑定、处理函数鉴权、状态持久化与管理客户端错误解析,并在执行结束后清理所有临时用户与策略。
修复与验证记录
本地验证命令:
带 DCO 签名的提交 58735ee38 推送至 PR #73 后,全部八项远端 CI 检查(DCO 签名、代码格式/编译/go vet、静态检查与生成文件、cmd/ 单元测试、internal/ 单元测试、并发竞态与 S3 Select、交叉编译、漏洞扫描)均一次通过,并以 2e2377d1c 合入主干。
最小权限策略示例
只能禁用的操作员
持有该策略的调用方可以查看并禁用其他用户,但不能重新启用账户。
只能启用的操作员
持有该策略的调用方可以查看并启用其他用户,但不能禁用账户。负责完整账户生命周期管理的角色应同时授予这两项权限。
组状态权限善后
组状态管理端点与用户状态端点结构完全对称:
该端点同样定义了 admin:EnableGroup 与 admin:DisableGroup,但原有处理函数也在读取 status 参数前固定检查了 EnableGroup,导致两方向的最小权限同时错位。
善后提交(229fe2b3c)新增了与 setUserStatusAdminAction 同构的 setGroupStatusAdminAction:
配套集成测试创建了仅持 EnableGroup、仅持 DisableGroup 的独立管理员账户及目标用户组,验证:
- 仅持
DisableGroup可以禁用目标组; - 仅持
DisableGroup不能启用目标组; - 仅持
EnableGroup可以启用目标组; - 仅持
EnableGroup不能禁用目标组。
非法 status 参数仍先按默认启用权限完成鉴权,再由既有逻辑返回参数校验错误,不会在鉴权前暴露参数校验差异。请求通过后的站点复制钩子保持不变,被拒绝的请求不会触发复制。
兼容性与迁移
客户端与 API 无需任何改动:路由地址、查询参数、状态字符串、响应格式与管理客户端方法完全保持不变。
自定义受限管理员策略需按以下规则核对:
- 仅负责禁用用户的角色需授予
admin:DisableUser; - 仅负责启用用户的角色需授予
admin:EnableUser; - 需要执行双向操作的角色必须同时授予这两项权限;
consoleAdmin及其他包含admin:*的策略不受影响;- 过去仅配置
admin:EnableUser的自定义策略将无法再禁用用户;若业务确需双向操作,请补充admin:DisableUser。
组管理角色遵循完全对称的规则:
- 仅负责禁用组的角色需授予
admin:DisableGroup; - 仅负责启用组的角色需授予
admin:EnableGroup; - 需要执行双向操作的角色必须同时授予这两项权限;
- 仅配置
admin:EnableGroup的旧策略将无法再禁用组。
上游处置与交付
上游议题 #21478 与 PR #21482 保留在已归档为只读的 minio/minio 仓库中作为历史记录。
交付状态
| 里程碑 | 用户状态修复 | 组状态善后 |
|---|---|---|
| 带 DCO 签名的提交 | 58735ee38(合并为 2e2377d1c) |
229fe2b3c |
| 发布版本 | Server 20260903 | Server 20260903 |
结论
启用与禁用用户或组具有截然相反的安全风险方向。处理函数根据请求的目标状态选择对应的策略权限,并在修改状态前执行单次鉴权,使权限检查与策略定义严格对齐。
本文记录的启停权限修复与后续 admin:ChangeMyPassword 权限拆分相互独立;跨九月版本升级时请一并参考密码迁移指南。
4.26 - IAM Admin API 授权边界修复
Server 20261018 包含了 PR #242(基于基线 00a375c4e 的修复提交 2616d43fb,合并提交 4e55fc7db)以及批量自查询后续修复 995439260,用于修复 SN-2026-017。本文记录问题范围、Handler 级授权契约、空策略与 claims 归属处理、损坏密钥清理语义、性能开销、被否决的候选方案以及回归验证结果。
问题与范围
此前部分 Admin Handler 在授权拒绝后仍以“目标属于调用者”为由放行,列出自身服务账号时甚至可能直接跳过授权检查。这些路径会绕过用户或组策略的显式 Deny、内联会话策略限制以及外部授权插件的拒绝结果。
凭据创建和更新还存在另一类越权路径:获准执行相应 Admin Action 的受限派生凭据,可以创建继承父账号更大权限的新密钥,或通过更新操作移除服务账号原有的限制;拥有广泛 Admin 权限的非 root 管理员也能操作 root 的派生凭据。在内置授权模式下,仅凭单个 Action 许可不足以放行这些操作。
本次修复覆盖服务账号与访问密钥的查询、清理、创建和更新 Handler(含批量密钥查询和 LDAP 创建入口),同时保持策略引擎全局语义、现有 Admin 路由与 Action、请求响应格式及 IAM 存储格式不变,无需调整依赖版本。
文中的内置授权指未配置外部授权插件的模式,包括由本地 IAM 评估 LDAP/OIDC 身份权限的场景。相关策略模型见访问管理与外部授权。
授权契约
传给 IAM 的操作主体始终是已认证的调用者 AccessKey。比对归属关系时,若存在 ParentUser 则取父账号,否则取 AccessKey;归属归一化不会在策略评估阶段将派生凭据替换为父账号身份。
自查询和自清理在调用授权前设置 DenyOnly: true:普通用户无需额外 Allow 策略即可执行既有的自管理操作,但显式 Deny 依然生效;派生凭据继续执行完整 IAM 评估,其有效会话策略必须明确允许目标操作;外部插件的拒绝结果始终生效。任何 Handler 都不会再因目标属于调用者而覆盖拒绝决定。
| Handler | 现有 Admin Action | 自管理语义与附加边界 |
|---|---|---|
InfoServiceAccount |
admin:ListServiceAccounts |
比对有效用户与服务账号存储的父账号,授权通过后返回详情。 |
ListServiceAccounts |
admin:ListServiceAccounts |
空用户参数、调用者 AccessKey、调用者父账号均视为自身别名;每次请求均执行授权,并列出父账号名下的密钥。 |
ListAccessKeysBulk |
admin:ListServiceAccounts |
自身别名统一按父身份归一化并复用同一自管理授权辅助函数;跨用户查询和 all=true 保持原有权限要求。 |
InfoAccessKey |
admin:ListServiceAccounts |
仅当目标存在、属于派生凭据且父账号相同时才视为自身;不存在的目标不触发自管理分支。 |
DeleteServiceAccount |
admin:RemoveServiceAccount |
自清理先执行授权再检查 root 边界;目标 token 损坏时直接读取存储的身份元数据。 |
RevokeTokens |
admin:RemoveServiceAccount |
省略用户参数或解析后等于调用者父账号时视为自身;先执行授权并保护 root 目标。 |
AddServiceAccount、AddServiceAccountLDAP |
admin:CreateServiceAccount |
完成既有自身或跨用户 IAM 授权后,拒绝受有效会话策略限制的调用者及无权管理 root 的调用者。 |
UpdateServiceAccount |
admin:UpdateServiceAccount |
完成既有 Action 授权后,拒绝受有效会话策略限制的调用者及无权管理 root 的调用者;不引入自更新特权。 |
即使会话策略允许创建或更新 Action,受该策略限制的调用者也不能执行这些操作。更新守卫同样拦截仅修改名称等元数据的请求,因为现有更新实现会在请求未提供新策略时清除原有内联策略。
三个局部辅助函数
生产代码改动集中在 cmd/admin-handlers-users.go 以及 cmd/admin-handlers-idp-ldap.go 的 claims 复制循环,通过三个局部辅助函数收敛边界,IAMSys.IsAllowed 保持不变。
hasActiveSessionPolicy 复用现有会话策略评估函数,按 IAM 派发顺序先检查临时凭据,再检查服务账号。设置了过期时间的服务账号同样满足 IsTemp(),会走 STS 评估路径。因此,带有 inherited-policy 标记且含过期时间的历史凭据仍可能携带有效的内联限制;若先检查继承标记,会误放行此类凭据去创建或更新权限更大的密钥。非临时服务账号则沿用现有服务账号评估器对继承标记与空策略的处理逻辑。
canManageRootCredentials 作为内置授权通过后的附加守卫,仅放行 root 自身或不受会话策略限制的 root 派生凭据(排除内部站点复制服务账号)。签名验证阶段的 owner 标记在原始会话策略 claim 存在时就会被清除,即便服务账号评估器判定该策略为空。直接检查实际限制可保留历史上使用 null、{} 或 {"Statement":null} 创建的 root 服务账号的管理能力。
isAllowedServiceAccountAction 保留普通用户在解绑最后一个策略后既有的自查询与自清理能力。仅当调用满足内置授权、非 owner、非派生凭据且设置 DenyOnly 时,该函数直接解析用户与组策略并评估合并结果(允许合并结果为空,显式 Deny 仍生效);其余调用继续走 IAMSys.IsAllowed,从而避免修改全局“无绑定策略”行为而波及无关的 S3 与 Admin 操作。
创建、更新、删除和吊销操作上的 root 守卫仅在内置授权下生效。配置外部授权插件时,包括 root 目标在内的所有请求均交由插件裁决,此前被自身分支跳过的请求现在同样经过插件检查。
空策略与 claims 的归属
代码复核还发现创建路径上一处可用性缺陷:将父服务账号的空内联策略复制给新的继承型子密钥时,会残留旧的内部策略 claim。一旦新密钥设置了过期时间,STS 评估器会因空策略缺少 Version 字段而拒绝访问,导致密钥创建成功却无法使用。
在创建请求中显式传入语义空策略并同时设置过期时间,也会触发相同故障。两类问题统一在 Admin 签发入口处理:
- 两个创建 Handler 在复制调用者 claims 时跳过
exp、policy.SessionPolicyName、sessionPolicyNameExtracted和iamPolicyClaimNameSA()。新密钥使用自身的过期时间和请求策略,其余身份信息(含 LDAP/OIDC 上下文)原样保留。 commonAddServiceAccount仅当解析后的策略满足Version == "" && Statements == nil && ID == ""时将其归一化为继承策略,与现有服务账号评估器的空策略定义保持一致;同时清空本地策略选项和返回请求中的 Policy 字段,确保站点复制发出的创建消息与本地落盘密钥一致。
| 新密钥请求中的策略 | 结果 |
|---|---|
省略、null、{}、{"Statement":null} |
继承父账号权限(含设置过期时间的密钥)。 |
| 仅允许特定 Action 的策略 | 保留所请求的限制,未授权操作继续拒绝。 |
{"Version":"2012-10-17"} 或 {"Statement":[]} |
不归一化为继承策略;带过期时间的回归用例继续拒绝访问。 |
归一化判断条件使用 Statements == nil 而非 len(Statements) == 0,以区分 Console 特有的空策略占位形态与显式传入空语句列表的策略。
IAMSys.NewServiceAccount 同时供 IAM 导入与站点复制调用。若在该公共构造函数中清洗 claims,会抹掉历史过期凭据仅保存在 claims 中的有效限制。因此公共构造函数与历史记录重放逻辑保持不变,仅在 Admin 签发入口规范化新凭据。
删除与错误语义
DeleteServiceAccount 直接通过 globalIAMSys.store.GetUser 读取存储的身份元数据,校验服务账号类型并保留存储的父账号,无需解码目标 session token。底层删除操作使用同一查找原语,既避开了 token 解析,又绕开了公开用户查询对禁用或过期凭据的过滤,同时保留 store 原有的父账号吊销检查。
具备权限的管理员可正常清理 token 损坏、已禁用或已过期的非 root 服务账号,而非 root 管理员无法利用 token 损坏掩盖目标的 root 归属。保留下来的父账号也保证了站点复制钩子能准确判断是否同步删除消息。
无权删除他人密钥、被 root 守卫拦截或目标服务账号不存在时,统一返回 ErrAdminServiceAccountNotFound(HTTP 404);自身操作被策略拒绝时返回 ErrAccessDenied(HTTP 403)。其余端点保持原有的查询与报错顺序。
兼容性与边界
行为收紧集中在三处:自管理操作必须遵守显式拒绝策略、受会话限制的凭据不得创建或更新密钥、内置授权下保护 root 凭据免受非 root 修改。依赖旧旁路逻辑的自动化任务需改用具备相应权限且不受会话策略限制的身份。普通用户(含解绑策略后)原有的自查询与自清理能力不变,历史 root 空策略服务账号的管理能力不变。
新建空策略密钥的元数据统一保存为 inherited-policy,不再写入内嵌空策略文档。这消除了带有效期密钥在两条评估路径上的行为分歧;策略内部保留字段也不再从调用者复制给新密钥,仅反映新凭据自身的属性。
2026-10-18 的后续提交 995439260 将 ListAccessKeysBulk 接入同一自管理授权辅助函数。无绑定策略的普通用户可正常自查询,显式 Deny、派生凭据会话限制与跨用户授权照常生效。省略 users 参数,或唯一 users 参数为调用者自身 AccessKey/父账号时,统一查询解析后的父账号密钥列表;all=true 仍需 admin:ListUsers 权限。回归测试会解密响应负载并校验普通用户、服务账号与 STS 别名下的身份内容。
此前已生成且无法使用的过期历史凭据不会自动改写,应由父账号或管理员重新签发新密钥并在验证后删除旧密钥。切勿通过批量清除导入或复制凭据的策略 claims 来做迁移,以免破坏存量凭据上的有效会话限制。
关于分布式吊销顺序与并发管理变更边界,参见持久化 IAM 撤销。
成本与被否决的候选方案
PR #242 在两个生产 Handler 文件中净增 59 行代码,10 月 18 日的批量查询修复直接复用同一辅助函数。改动未引入新缓存、后台任务、锁协议或存储迁移,也未触及 S3 数据路径;claims 过滤在既有复制循环中完成,空策略归一化直接复用创建请求已解析的策略对象。
Admin 创建与更新检查可能需要额外解析一次调用者的内联策略(服务账号策略上限为 4 KiB),复用现有评估器避免维护第二套“有效策略”判定逻辑。原先跳过授权的部分自查询请求现在会执行必要的策略评估,通常直接命中 IAM 内存缓存;仅当合并策略加载器发现文档缺失时才会访问底层存储。在启用外部授权插件的部署中,补齐的授权检查会向插件发起一次请求,耗时取决于外部插件服务。
在 Darwin/arm64(Apple M5 Max,Go 1.27.2,三次 200 ms 采样)上的基准测试结果如下:
- 普通或继承型会话检查:约 11–16 ns,0 次内存分配;
- 空服务账号策略检查:约 0.30–0.32 µs;
- 约 3 KiB 的内联策略检查:约 10 µs;
- 缓存命中的普通用户自管理授权:与现有 IAM 路径同处亚微秒量级(800 B / op,15 allocs / op)。
上述开销无需在 Admin 路径上额外引入专用缓存或策略表示。
设计复核评估并否决了以下五种方案:
- 全局修改
IAMSys.IsAllowed:会改变全局“无绑定策略”或自归属语义,波及无关的 S3 与 Admin 操作。 - 仅凭原始会话策略 claim 键存在即判定受限:历史上使用
null、{}或{"Statement":null}创建的 root 服务账号同样带有原始 claim 键,按存在性拦截会破坏其管理能力。 - 在
IAMSys.NewServiceAccount公共构造函数中统一剔除策略字段:该函数同时被 IAM 导入和站点复制调用,在公共构造函数中清洗 claims 会丢失历史过期凭据仅保存在 claims 内的有效限制。 - 为会话受限凭据实现通用委派策略包含关系求解:会话受限凭据的设计定位是受限叶节点凭据而非子签发者,直接禁止有效会话策略持有者创建或更新子密钥即可用 59 行代码闭合越权路径。
- 一律拒绝损坏密钥的清理:会导致管理员无法利用存储的身份元数据清理 token 已损坏、已禁用或已过期的非 root 服务账号。
回归证据
提交 2616d43fbc3400a02cc1e6f9299e26ae8b2e71a6 与后续提交 9954392605827b213877c45262c741e638598dd8 通过了以下测试套件:
| 检查 | 结果 |
|---|---|
完整 TestIAM* / TestSTS*(连接真实 etcd、LDAP、两个 OIDC Provider 和外部授权插件) |
57 个顶层测试;含子测试共 380 条通过记录;0 失败、0 跳过。 |
TestIAMAdmin* 回归测试(使用签名请求及加密 Admin 请求体) |
通过。 |
TestIAMAdmin* 与 peer 删除/重载测试的定向 -race 检查 |
通过。 |
| 认证、身份配置与策略配置相关包测试 | 通过。 |
make verifiers 与 make build |
lint、生成文件、品牌与兼容性检查通过,成功构建 silo 二进制文件。 |
PR #242 云端 CI 检查 |
DCO、漏洞扫描、格式/构建/Vet、lint/生成文件、race、交叉编译、internal 与 cmd 测试共 8 项全部通过。 |
cmd/admin-handlers-users-authz_test.go 覆盖了 root 空策略、无绑定策略用户自管理、父用户/组/会话/插件拒绝、带过期时间的历史 inherited 凭据、非 root 操作 root 目标、禁用或过期的损坏密钥清理、删除复制、自身别名,以及空策略子密钥签发的正反向访问验证。LDAP 测试套件额外通过 AddServiceAccountLDAP 签发带过期时间的子密钥并验证 S3 访问。补充边界测试验证了损坏 root 密钥的清理拦截,以及五个自管理 Handler 对 STS 显式与隐式拒绝的处理。
验证过程中同时修正了两处既有测试夹具问题:peer 重载测试在构造对象存储夹具前显式清理残留的 etcd 客户端状态;IAM 导入测试改为从 policy.DefaultPolicies 动态获取默认策略名。
复现验证命令:
交付与升级说明
- 发行版本:Server 20261018(PR
#242,提交2616d43fb、4e55fc7db与995439260)。 - 安全通告:SN-2026-017。
- 请升级所有接收 Admin 流量的服务端节点,避免混合版本部署将请求路由至未修补的 Handler。本次变更不涉及磁盘格式变更,无需批量重写存量凭据。
4.27 - 配置环境文件不是 Shell 脚本
本文定义 MINIO_CONFIG_ENV_FILE 的启动解析契约,并记录提交 2aea7fe9c(已随 Server 20260903 发布)中的兼容性修复。该文件是 SILO 的专用输入格式;为方便运维而支持可选的 export 前缀,并不意味着它是一段 POSIX shell 脚本。
概览
SILO 支持在启动时从指定文件加载环境变量:
解析器接受如下形式的键值赋值:
最后两行展示了为什么 POSIX shell 标识符校验过于狭窄。多目标(multi-target)配置会在下划线后原样拼接目标名称。配置子系统并不要求目标名称符合 shell 变量命名规则:包含 -、.、:、数字或可打印 Unicode 字符的目标名称均能被正常发现并精确解析。
此前的一项加固改动曾使用 [A-Za-z_][A-Za-z0-9_]* 校验环境文件中的每一个键名,导致 my-hook 被判定为非法并在服务重启时拒绝启动,尽管旧版加载器与配置目标模型均支持该名称。修复后的解析器改为校验 SILO 与操作系统环境真正依赖的不变量:
- 键名非空、为合法 UTF-8,且仅由可见的非空白字符组成;
- 键名中禁止包含
=与 NUL; - 值中禁止包含 NUL;
- 非法输入仅报告文件路径与行号,绝不在日志中输出变量值;
- 整个文件全部解析通过后,才会开始向进程环境写入变量。
为什么这是一次真实的回归缺陷
环境文件加载器在解析完成后直接调用 os.Setenv。操作系统环境块本质上是一组 KEY=VALUE 字符串列表,而非 shell 变量命名空间。Shell 赋值语法之所以更严格,仅仅是因为 shell 需要在其命令语言中对变量引用进行分词与展开。
SILO 的命名配置目标采用如下拼接格式:
例如:
目标发现(target discovery)按固定的 MINIO_<SUBSYSTEM>_<PARAMETER>_ 前缀枚举环境变量,并将剩余后缀原样作为目标名称;目标读取时同样使用该原样后缀重建环境变量名,不会将其转为大写或替换连字符。因此,在环境文件解析器中拒绝 - 会直接打断一条原本完整可用的“发现—解析”链路,却并没有保护任何 shell 执行路径——因为没有任何 shell 会去解析或执行 MINIO_CONFIG_ENV_FILE。
这一故障在运维层面尤为尖锐:MINIO_CONFIG_ENV_FILE 仅在进程启动时读取。服务器可能依赖内存中的旧环境变量持续正常运行,却在二进制升级或配置重载后的下一次重启时突然无法启动。面对畸形输入应当快速失败(fail fast),但文件解析器绝不能擅自发明比配置子系统更狭窄的目标语法。
文件语法
行与注释
- 忽略空行与仅含空白字符的行;
- 忽略第一个非空白字符为
#的整行注释; - 若行首存在后跟空白字符的独立
export关键字,在解析键名前先将其剥离; exportFOO=value的键名保持为exportFOO,不会被误判为export前缀;- 第一个
=用于分隔键与值,值中出现的后续=字符全部原样保留。
环境文件不是 shell 脚本,不执行 $VAR 变量展开、命令替换、反斜杠转义处理或行内 # 注释截断。
键名
键名两侧的空白字符会先被去除。处理后的键名必须满足:
- 非空且为合法 UTF-8;
- 仅包含 Unicode 图形字符(
unicode.IsGraphic); - 不包含空白字符、
=、NUL、控制字符或不可见的 Unicode 格式字符。
这一规则在完整保留操作系统兼容变量名与多目标后缀的同时,拒绝了视觉上为空或结构含糊的键名。以数字或标点符号开头的键名可通过语法解析;SILO 仍只读取自身配置与运行时组件实际使用的精确名称。
值与引号
未加引号的值会自动去除首尾空白。若需要保留有意义的首尾空格,请使用匹配的单引号或双引号包裹完整值:
解析器最多剥离一对匹配的外层引号,不解释引号内部的反斜杠转义。NUL 字节在任何情况下均被拒绝,因为操作系统底层环境项以 NUL 结尾的 C 字符串表示。
失败与保密契约
任何语法错误都会立即终止服务启动。诊断消息包含文件路径、从 1 开始的行号以及非法键名或错误类别,但绝不输出变量值,从而确保畸形行上的密码或密钥不会被写入启动日志。
解析过程遵循全有或全无(all-or-nothing)原则:任一行出现语法错误都会丢弃全部解析结果,只有整个文件校验通过后才会开始调用 os.Setenv。若操作系统拒绝某个已通过语法校验的赋值,SILO 同样会终止启动并指明键名与文件路径,绝不以“只加载了一半”的环境对外提供服务。
由于 MINIO_CONFIG_ENV_FILE 包含 root 凭据、KMS 密钥与 webhook 令牌等高权限机密,运维人员仍必须通过严格的文件系统属主与权限(如 0600)保护该文件。
回归测试矩阵
单元测试与配置子系统测试覆盖了以下场景:
=两侧的空格与制表符;- 使用单引号或双引号保留首尾空格的值;
- 独立
export前缀(包括其后紧跟 Unicode 空白字符); - 以
_、数字或标点符号开头的键名; - 包含
-、.、:与可打印 Unicode 字符的命名目标; - 通过配置子系统端到端发现并解析命名目标;
- 空键名、键名内部空白、NUL 字节以及零宽空格等不可见格式字符;
- 包含 NUL 字节的值;
- URL、查询字符串与 Base64 令牌中包含多个
=的值; - 严格脱敏变量值的文件路径与行号诊断;
- 语法错误时的全有或全无原子返回保障。
兼容性与 #65 热重启陷阱
常规环境配置无需任何迁移:标准环境变量名行为不变,包含空格的 = 赋值更加可预测,带连字符或点号的命名目标恢复正常工作。
以下四项外部可见行为是明确设计的契约:
- 非法或含不可见字符的键名会在启动时立即报错,不再静默忽略;
- 未加引号的值会自动去除首尾空白,需要保留首尾空格时必须加引号;
- 畸形输入会以带行号且脱敏的诊断信息阻止启动;
- 合法的标点命名目标不再仅仅因为 POSIX
sh无法直接通过NAME=value语法赋值而被拒绝。
对原始 #65 的调查还揭示了一个隐蔽的热重启陷阱:旧版解析器按 = 切分时未去除键名尾随空白,会将 KEY = new 保存为带尾随空格的键名 KEY 。在使用 systemd 冷启动时,由于 systemd 会自行解析 EnvironmentFile= 并去除 = 两侧空格,该缺陷往往被掩盖;但当管理员通过管理 API 触发原地热重启(mcli admin service restart)时,re-exec 出的新进程会从父进程继承 KEY=old,而环境文件中新解析出的 KEY 无法覆盖 KEY,导致服务虽重启成功却继续使用过期的 root 凭据、KMS 配置或 IdP 设置。Server 20260903 修复键名空白裁剪后,KEY = new 在冷启动与热重启下均会正常覆盖 KEY;若环境文件中曾包含带空格的 = 赋值,升级前请核对其配置值是否符合预期。
结论
通过在 silo-pkg/v3(1a0d788)中收紧 config.ReadEnvFile 词法规则并在服务端(a643483be)升级依赖,MINIO_CONFIG_ENV_FILE 在冷启动与原地热重启下具备完全一致的解析行为,同时保持对标准 Shell 环境文件的完整向后兼容。
4.28 - 两把 SSE-C 密钥,一份 CopyObject 响应
本文记录提交 e73436c99(已随 Server 20260903 发布)中的 CopyObject SSE-C 校验和响应修复。该修复仅作用于目标对象成功提交后的 CopyObject XML 与 HTTP 响应生成阶段;已存储的对象字节、校验和元数据、加密格式、源对象解密、联邦复制与站点复制均保持不变。
概览
一次 SSE-C CopyObject 请求可以同时携带两套相互独立的客户端加密密钥:
| 角色 | 请求头 | 用途 |
|---|---|---|
| 源对象 | X-Amz-Copy-Source-Server-Side-Encryption-Customer-* |
解密源对象及其已存储的元数据 |
| 目标对象 | X-Amz-Server-Side-Encryption-Customer-* |
加密并在提交后解释目标对象及其元数据 |
SILO 在写入阶段能够使用目标密钥正确加密目标对象并密封其校验和元数据。但在目标对象提交完成后,CopyObjectResult XML 生成器与通用的 PUT 成功响应头辅助函数都直接收到了完整的原始 CopyObject 请求头。由于校验和元数据解密函数 decryptChecksums 在检测到 X-Amz-Copy-Source-Server-Side-Encryption-Customer-* 请求头存在时会优先使用该请求头——这一优先级在读取源对象阶段是完全正确的——导致它在解释已提交的目标对象元数据时误用了源密钥。
当源密钥为 A、目标密钥为 B 时,旧流程表现为:
落盘的目标对象与加密校验和元数据本身完全正确,缺陷仅出现在一次性的成功响应中。修复方案通过克隆请求头并剥离三个 copy-source SSE-C 请求头来构造目标响应视图,使用目标密钥 B 对目标校验和元数据只执行一次解密,并将得到的映射表同时复用于 CopyObjectResult XML 响应体与 HTTP 响应头。
可观察故障
当目标对象包含校验和且源、目标使用不同的 SSE-C 密钥上下文时,即可复现该故障:
在修复前:
CopyObject返回 HTTP 200;- 使用密钥
B对目标对象执行GetObject可读取正确的明文正文; - 使用密钥
B对目标对象执行HeadObject可正常解密并返回x-amz-checksum-crc32与x-amz-checksum-type; - 但
CopyObject当次返回的 XML 响应体与 HTTP 响应头中却缺失了ChecksumCRC32/x-amz-checksum-crc32与ChecksumType/x-amz-checksum-type。
同样的密钥遮蔽问题也会出现在同对象原地换钥(即将对象复制到自身并更换为新 SSE-C 密钥 B)场景中:尽管目标元数据已用新密钥 B 重新密封,请求中仍保留着代表旧源对象的 copy-source 密钥 A,导致换钥响应丢失校验和字段。
为什么不能修改全局解密器
decryptChecksums 优先读取 copy-source SSE-C 请求头本身并非缺陷。在 CopyObject 的提交前阶段,服务端需要先检查源对象的校验和元数据,以决定是直接沿用源算法、将分片组合校验和重算为整对象校验和,还是为无校验和源对象补充默认的 CRC64NVME 校验和。对于 SSE-C 源对象,这份源元数据由源密钥保护,必须使用 X-Amz-Copy-Source-Server-Side-Encryption-Customer-* 请求头才能解密。
如果简单地将 decryptChecksums 的全局优先级反转为“目标 SSE-C 请求头优先”,虽然能修复提交后的响应,却会破坏提交前的源校验和解析。正确的安全边界必须按时间阶段与操作对象严格区分:
实现设计
目标响应请求头视图
在目标对象提交成功后,处理器克隆请求头映射并精确删除以下三个源端 SSE-C 请求头:
X-Amz-Copy-Source-Server-Side-Encryption-Customer-AlgorithmX-Amz-Copy-Source-Server-Side-Encryption-Customer-KeyX-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5
常规的目标端 X-Amz-Server-Side-Encryption-Customer-* 请求头保持不变。SSE-S3 与 SSE-KMS 目标元数据不依赖客户端提供的密钥请求头,继续沿用原有路径。
解密一次,投影两次
修复前,CopyObject 在构造 CopyObjectResult XML 时调用一次 decryptChecksums,随后在 setPutObjHeaders 写入成功响应头时又调用一次。对于 SSE-S3 或 SSE-KMS 对象,这会导致重复执行主密钥或 KMS 解封操作。
修复后的流程只执行一次目标校验和解密,并将结果投影到两处输出:
通用的 setPutObjHeaders 包装函数仍继续服务于 PutObject、CompleteMultipartUpload 与 DeleteObject;CopyObject 则直接调用接收已解密校验和映射表的内部辅助函数,确保 ETag、VersionID、删除标记、生命周期预测与校验和响应头共享同一套逻辑。
回归测试矩阵
加密与 API 测试套件覆盖了以下组合:
- 明文源对象复制到 SSE-C 目标对象;
- 压缩与未压缩的 SSE-C 目标对象;
- SSE-C 源密钥
A复制到 SSE-C 目标密钥B; - 同时校验
CopyObjectResultXML 与 HTTP 响应头中的校验和摘要及ChecksumType; - 验证持久化目标校验和可用密钥
B解密且与目标正文匹配; - 同对象从密钥
A到密钥B的原地 SSE-C 换钥及换钥后的校验和响应; SSE-S3源与目标组合在各纠删码后端上的表现。
兼容性与运维影响
CopyObject成功响应: 当已提交的目标对象包含校验和时,此前在跨密钥 SSE-C 复制中缺失的校验和值与ChecksumType现已在 XML 响应体和 HTTP 响应头中完整返回。- 已存储对象: 无需重写数据、无需修改元数据或加密格式,也无需数据迁移;存量对象的持久化校验和原本就是正确的。
- 性能开销: 每次
CopyObject响应的元数据校验和解密次数从两次减为一次,不增加任何对象数据读取或哈希计算开销。 - 滚动升级与回滚: 滚动升级窗口内,新节点会返回完整校验和字段,旧节点可能省略该字段,但两端写入的对象数据完全互操作。
结论
在构造 CopyObjectResult XML 响应前对 ObjectInfo 执行元数据解密(21870fa2e),使 SSE-C 与 KMS 加密对象的 CopyObject 响应恢复返回已持久化的校验和字段,且不改变底层存储布局与加密语义。
4.29 - ListMultipartUploads 行为契约与设计权衡
本文记录 ListMultipartUploads 在完整工程生命周期中的设计与演进:Server 20261018 当前采用的 MinIO 兼容契约、16 盘纠删集容量压测后于 10 月 18 日执行的部分回滚、10 月 17 日无条件磁盘扫描尝试(#243,合入提交 7e9454456)、9 月 16 日可选 strict 模式(#198),以及 8 月 30 日的原始根因分析。
Server 20261018 当前行为契约
Server 20261018 在 main 分支上恢复了传统的 MinIO 兼容公共 ListMultipartUploads 契约(提交 4733e1423,随后由 0f9199e0c 补齐空父目录清理修复):
- 不带
prefix时,ListMultipartUploads返回当前节点内存分片缓存(multipartUploadsCache)中跟踪的上传记录。 - 带非空
prefix时,ListMultipartUploads将prefix视为精确对象键(sha256(bucket/object)),直接检查磁盘上该对象对应的分片上传目录。 delimiter、key-marker、upload-id-marker与encoding-type仍由 HTTP 处理器解析并在需要时校验(如encoding-type=url)及回显到 XML 响应中,但候选集选取遵循 MinIO 兼容的“内存缓存 / 精确对象键”路径,不再对.minio.sys/multipart执行全盘扫描。UploadID、GetMultipartInfo、PutObjectPart、ListObjectParts、CompleteMultipartUpload与AbortMultipartUpload在进程重启后仍可凭Bucket + Object + UploadID正常工作,因为活跃上传持久化在磁盘.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>下。MINIO_API_MULTIPART_LISTING与api multipart_listing保留为兼容性空操作(no-op),旧脚本或已保存配置无需修改即可继续启动。GET /minio/admin/v3/multipart-preflight保持可用,固定返回mode: "legacy"且不兼容上传数为0。
为什么在 2026-10-18 回滚默认磁盘扫描
在 2026-10-17(#243,提交 4ed86dfe6)至 2026-10-18(提交 4733e1423)之间,main 曾短暂将所有公共 ListMultipartUploads 请求统一切换为对 .minio.sys/multipart 的无索引磁盘扫描。针对 16 盘纠删集的容量分析与基准测试表明:将该磁盘扫描设为无条件默认路径,会引入比原有列表限制严重得多的可用性故障模式。
为什么进程重启不会删除未完成的分片上传
未完成的分片上传持久化在磁盘路径 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID>/xl.meta 与 part.* 下。服务进程重启只会清空内存中的 multipartUploadsCache,不会删除磁盘上的未完成上传。因此,客户端持有的 UploadID 在重启后依然有效,直至完成合并、显式中止(Abort),或被后台过期清理任务回收(默认过期时间 7 天,巡检周期 12 小时)。
由于未完成上传会跨重启和客户端重试不断累积,任何遍历磁盘 .minio.sys/multipart 的操作,其开销都取决于纠删集当前存留的未完成上传总数,而非仅取决于本次进程启动后新建的上传数。
100,000 目录项保护阈值在 16 盘纠删集上的失效机制
10 月 17 日的磁盘扫描实现在整个纠删集所有磁盘合计的粒度上施加了 maxMultipartWalkEntries = 100000 上限,将每块磁盘上的 <sha256(bucket/object)> 目录项与 <uploadID> 目录项一并计入预算。
对于包含 $N$ 块磁盘、跨 $H$ 个不同对象键共有 $U$ 个活跃上传的纠删集,一次未命中缓存的桶级 ListMultipartUploads 扫描需要遍历的目录项总数为:
当每个上传对应不同对象键($H = U$)时:
- 在 4 盘纠删集上,$4 \times (10{,}000 + 10{,}000) = 80{,}000$ 个目录项,恰好落在
100,000阈值以内。 - 在 16 盘纠删集上,$16 \times (3{,}126 + 3{,}126) = 100{,}032$ 个目录项——整个纠删集(所有桶合计)只要累积到 3,126 个活跃上传,就会突破
100,000上限,导致所有公共列表请求直接返回 HTTP503 SlowDown。 - 即便所有上传都集中在同一个对象键($H = 1$)下,16 盘纠删集在单键累积到 6,250 个上传时也会达到 $16 \times (1 + 6{,}250) = 100{,}016$ 个目录项,使精确对象键查询(
prefix=<exact-object-key>)同样因503 SlowDown失败。
演进脉络与问题定责
| 变更阶段 | 日期 / 提交 | 具体改动 | 工程评估 |
|---|---|---|---|
引入可选 strict 模式(#198) |
2026-09-16 / 4cbb074cc |
在 MINIO_API_MULTIPART_LISTING=strict 后提供磁盘扫描列表,默认保持 legacy |
提供了安全的按需启用边界,但最初缺少目录遍历硬上限 |
安全加固(SN-2026-009..012) |
2026-09-16 / 143f6970d |
增加 checkUploadID、maxMultipartWalkEntries = 100000、503 SlowDown 映射及 readQuorum 过滤 |
为磁盘扫描补齐了必要的 DoS 保护,但按全部 $N$ 块盘的原始目录项总数计费 |
无条件统一为磁盘扫描(#243) |
2026-10-17 / 4ed86dfe6 |
移除 legacy 分支,将所有公共 ListMultipartUploads 强制路由到磁盘扫描 |
步子迈得过大:仅凭 4 盘单键基准测试就将无索引磁盘扫描设为必经路径,在 16 盘纠删集上于 3,126 个上传处触发 503 SlowDown |
部分回滚方案权衡对比
| 备选方案 | 公共 S3 列表语义完整度 | 16 盘纠删集容量表现 | 存储格式与后台 I/O 影响 | 决策 |
|---|---|---|---|---|
保留 #243 无条件磁盘扫描 |
在 16 盘 3,125 个上传以内支持完整前缀/分隔符/游标分页 | 在全集累积 3,126 个不同键上传时立刻触发 503 SlowDown |
每次未缓存列表产生 16 × (1 + H) 次 ListDir RPC |
淘汰 |
调高或移除 maxMultipartWalkEntries |
超过 3,126 个上传后仍能继续扫描 | 重新打开无索引 .minio.sys/multipart 上的无界 $O(N \times U)$ 磁盘遍历与内存分配风险 |
并发 GET /bucket?uploads 下延迟与文件描述符/RPC 放大严重 |
淘汰 |
| 构建持久化按桶分片索引 | 无需全盘扫描 sha256(bucket/object) 即可支持完整 S3 列表 |
开销仅与匹配前缀的上传数成正比 | 需要全新的分布式元数据索引、自愈协议与崩溃恢复契约 | 延后(需独立立项设计索引) |
对 #198、143f6970d 与 #243 执行完整 git revert |
恢复 MinIO 兼容列表行为 | 无 100,000 目录项悬崖 |
会连带丢弃 checkUploadID 路径穿越防护、Abort 缓存/节点通知清理以及 xl.meta 身份元数据 |
淘汰 |
外科手术式部分回滚(4733e1423 + 0f9199e0c) |
恢复 MinIO 兼容公共列表(空前缀走缓存、非空前缀走精确键查盘) | 消除跨桶 100,000 目录项悬崖;精确键查盘每块盘仅访问 1 个哈希目录 |
完整保留 xl.meta 身份字段、checkUploadID、Abort 清理与空哈希父目录回收 |
采纳 |
回滚内容与保留的修复项
- 在公共列表路径上回滚的部分(
4733e1423):- 公共
ListMultipartUploads不再遍历.minio.sys/multipart下的全部<sha256(bucket/object)>目录。 - 空
prefix直接读取内存multipartUploadsCache;非空prefix直接计算bucket/prefix的 SHA-256 哈希,仅检查该对象对应的单个<sha256(bucket/object)>目录。
- 公共
- 保留下来的安全加固与存储修复(
4733e1423+0f9199e0c):NewMultipartUpload仍在xl.meta中写入x-minio-internal-multipart-v1-bucket与x-minio-internal-multipart-v1-object,使落盘上传具备自描述能力,便于诊断及未来构建索引。- 所有分片操作继续强制执行
checkUploadID校验(SN-2026-009)。 AbortMultipartUpload继续同步驱逐本地缓存并通知集群对端节点。cleanupStaleUploads继续在后台清理磁盘上的过期上传,且提交0f9199e0c确保当.minio.sys/multipart/<sha256(bucket/object)>下最后一个<uploadID>因过期清理、合并完成或显式中止而被删除时,空的<sha256(bucket/object)>父目录也会一并删除,防止空哈希目录长期堆积。
排序成本与发现成本的本质冲突
在 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID> 布局中,目录名是 bucket/object 的 SHA-256 摘要,既打散了对象键的字典序,也抹去了桶边界。在没有二级索引的前提下,要按 S3 字典序回答任意前缀查询(如 prefix=trips/),存储引擎必须先打开纠删集内所有桶、所有活跃上传下的 xl.meta 才能还原 (Bucket, Object) 并排序分页。相比之下,精确对象键查询(prefix=trips/2026/a.mp4)只需计算一次 SHA-256 并读取每块盘上的单个目录(将 ListDir 次数从 $O(N \times H)$ 降为 $O(N)$)。
提交 4733e1423 上的容量验证基准
使用 go test -run=^$ -bench=BenchmarkMultipartPublicListingCapacity -benchmem -benchtime=1x ./cmd 实测结果如下:
测试场景(BenchmarkMultipartPublicListingCapacity) |
回滚前(4ed86dfe6 磁盘扫描) |
回滚后(4733e1423 MinIO 兼容路径) |
延迟(4733e1423) |
内存分配 / 次数(4733e1423) |
|---|---|---|---|---|
4_drives_10000_distinct_keys/empty_prefix_cached |
200 OK(在磁盘上遍历 80,000 个目录项) |
200 OK(从内存缓存返回 1,000 条上传) |
1.12 ms |
257,104 B / 14 allocs |
16_drives_3126_distinct_keys/empty_prefix_cached |
503 SlowDown(100,032 项 > 100,000 上限) |
200 OK(从内存缓存返回 1,000 条上传) |
0.36 ms |
90,208 B / 14 allocs |
16_drives_3126_distinct_keys/exact_key_after_cache_clear |
503 SlowDown(遍历全部 3,126 个哈希目录) |
200 OK(清空缓存后从磁盘返回 1 条上传) |
0.78 ms |
30,024 B / 389 allocs |
16_drives_10000_uploads_one_key/exact_key_lookup |
503 SlowDown(单键在 6,250 个上传处超限) |
200 OK(从磁盘返回 1,000 条上传) |
383.85 ms |
13,721,672 B / 236,704 allocs |
关联文档记录
| 文档 | 说明 |
|---|---|
| S3 API 兼容性 — 分片上传 API 差异说明 | Server 20261018 中 ListMultipartUploads 的权威用户侧兼容性说明 |
| SILO 20260916 发布注记 | 记录已发布的 legacy 默认、strict 可选、UploadID 校验和取消边界 |
历史记录:10 月 17 日统一磁盘扫描尝试(#243)
2026-10-17 合并的 PR #243(提交 4ed86dfe6)曾短暂移除 legacy/strict 模式开关,将所有 ListMultipartUploads 请求统一路由到 #198 引入的磁盘扫描实现。尽管该默认路由在次日由 4733e1423 回滚,下文仍完整保留 #243 的架构决策记录,以便查阅 .minio.sys/multipart 的落盘格式、法定人数判定规则与并发中止竞态分析。
10 月 17 日架构决策记录
10 月 17 日实验的目标契约
10 月 17 日的统一尝试旨在不引入外部元数据数据库的前提下,让 ListMultipartUploads 同时满足以下三条不变式:
- 跨重启持久可见:
ListMultipartUploads直接从磁盘.minio.sys/multipart读取活跃上传,而非仅依赖内存multipartUploadsCache。 - 完整的 S3 过滤与分页语义:将
prefix作为对象键前缀匹配,支持delimiter折叠CommonPrefixes,并按(Key, Initiated, UploadID)排序及执行key-marker/upload-id-marker分页。 - 自描述上传元数据:
NewMultipartUpload在每个上传的xl.meta内写入桶名与对象键,使<sha256(bucket/object)>目录下的上传可反向还原出(Bucket, Object)。
当时尝试统一两种模式的背景
在 #198 之前,调用方根据 prefix 为空、为目录前缀或为完整对象键,会观察到截然不同的行为:
| 请求示例 | 传统 MinIO / legacy 行为 |
S3 标准 / 磁盘扫描行为 |
|---|---|---|
GET /photos?uploads(CreateMultipartUpload 后立即调用) |
返回当前节点内存缓存中记录的上传 | 返回磁盘上达到读法定人数的活跃上传 |
GET /photos?uploads(服务进程重启后) |
返回空列表(重启清空了 multipartUploadsCache) |
仍能从 .minio.sys/multipart 返回未完成的活跃上传 |
GET /photos?uploads&prefix=2026/(目录前缀) |
除非存在字面名为 2026/ 的对象(sha256("photos/2026/")),否则返回空列表 |
返回对象键以 2026/ 开头的所有活跃上传 |
GET /photos?uploads&prefix=2026/trip.mov(精确对象键) |
直接查磁盘 .minio.sys/multipart/<sha256("photos/2026/trip.mov")>(跨重启可见) |
返回对象键以 2026/trip.mov 开头的活跃上传 |
Issue #198(2026-09-16)与 #213 最初通过 MINIO_API_MULTIPART_LISTING=legacy|strict 同时保留两种模式,避免升级前遗留的旧上传静默不可见。PR #243(2026-10-17)在补充惰性回填逻辑后,尝试收敛这一配置分叉。
#243 评估过的备选方案
| 候选方案 | 跨重启可见性 | 前缀与游标支持 | 额外索引维护成本 | 当时取舍与后续复盘结论 |
|---|---|---|---|---|
1. 维持 legacy + strict 双模式 |
仅 strict 支持 |
仅 strict 支持 |
无 | #243 为消除双模式而放弃;4733e1423 重新回到兼容路径 |
| 2. 仅使用内存缓存 | 不支持 | 仅在缓存存活期内支持 | 无 | 淘汰:重启后丢失全部列表可见性 |
3. 扫描 .minio.sys/multipart + xl.meta 身份字段(#243) |
支持 | 支持 | 无 | #243 选中;因 $O(N \times (H+U))$ 遍历在 16 盘 3,126 个上传处触发 100k 上限而于 4733e1423 回滚 |
| 4. 每个上传附加旁路 JSON 文件 | 支持 | 支持 | 每次创建/中止多一次文件读写 | 淘汰:元数据文件 I/O 翻倍 |
| 5. 按桶维护磁盘分片索引树 | 支持 | 支持(仅与匹配前缀成正比) | 需配套分布式索引自愈与清理协议 | 延后:大规模纠删集唯一可行的长期路径,但需独立设计崩溃恢复与自愈 |
| 6. 外部 KV / SQLite 索引 | 支持 | 支持 | 在纠删码体系外引入外部状态 | 淘汰:违背纠删集自包含架构原则 |
磁盘存储布局与 xl.meta 身份字段
每个活跃分片上传在磁盘上的存储路径为:
由于 <sha256(bucket/object)> 是单向哈希摘要,仅凭目录路径无法反推出桶名或对象键。提交 4cbb074cc 扩展了 cmd/erasure-multipart.go 中的 newUploadedObjectMetadata,在 xl.meta 内写入两个内部元数据键(在 20261018 中继续保留):
x-minio-internal-multipart-v1-bucket:明文桶名x-minio-internal-multipart-v1-object:明文对象键
针对上游 MessagePack xl.meta 读取器的兼容性验证(对照提交 3be10fcc1a44f6620ded0bd303461f9d688cca23):MetaSys 在 xl.meta 中是标准的 map[string][]byte 映射,旧版本读取器会自动忽略未知的 x-minio-internal-* 键,不会触发反序列化错误。
磁盘扫描路径中的发现与法定人数规则
当 #198 与 #243 执行磁盘扫描(listMultipartUploadsScan)时,每个纠删集按以下五步工作:
- 在所有在线磁盘上列出
.minio.sys/multipart下的<sha256(bucket/object)>目录,要求每个哈希目录至少出现在readQuorum = floor(N/2) + 1块磁盘上。 - 列出每个满足法定人数的哈希目录下的
<uploadID>子目录,使用checkUploadID校验每个uploadID并要求满足readQuorum。 - 对每个候选
uploadID从在线磁盘读取xl.meta,要求至少readQuorum块磁盘对(Bucket, Object)身份达成一致。 - 若升级前遗留的旧上传缺少
x-minio-internal-multipart-v1-*键,则在精确前缀或缓存命中提供候选键时验证哈希并惰性回填xl.meta。 - 按
Bucket、Prefix、Delimiter、KeyMarker与UploadIDMarker过滤,按(Key, Initiated, UploadID)排序并跨存储池归并。
目录遍历成本与 4 盘基准测试(#243)
在包含 $N$ 块磁盘、跨所有桶共有 $H$ 个活跃 <sha256(bucket/object)> 目录和 $U$ 个活跃 <uploadID> 目录的纠删集上:
- 每次未缓存扫描检查的目录项总数:$N \times (H + U)$
- 每次未缓存扫描发起的
ListDir调用总数:$N \times (1 + H)$
在 #243 提交时,BenchmarkMultipartListingCapacity 仅在 4 盘单纠删集夹具($N = 4$)上针对单个对象键下的 10,000 个上传($H = 1, U = 10{,}000$)进行了测试,总目录项为 $4 \times (1 + 10{,}000) = 40{,}004$:
4 盘测试夹具(4ed86dfe6 上的 BenchmarkMultipartListingCapacity) |
活跃上传数 | 耗时 | 内存分配量 | 分配次数 |
|---|---|---|---|---|
| 单对象键($H = 1, U = 10{,}000, N = 4$) | 10,000 |
101.6 ms |
3.58 MB |
61,210 |
这一 4 盘单键基准测试掩盖了次日在 16 盘纠删集上暴露的两个乘数因子:$N = 16$ 块磁盘($4\times$ 倍率)与不同对象键 $H = U$(目录项 $2\times$ 倍率、ListDir 次数 $U\times$ 倍率),两者叠加使 3,126 个上传即突破 maxMultipartWalkEntries = 100000。
16 盘 EC:8 纠删集上的 Abort 与延迟创建竞态分析
在 #243 评审期间,我们还分析了在固定 readQuorum = floor(N/2) + 1(例如 16 盘 EC:8 纠删集上为 9)下,AbortMultipartUpload 是否可能因延迟写入而出现“死而复生”的上传:
- 假设
NewMultipartUpload先在8块快盘上完成了xl.meta写入,另有7块盘的写入仍在飞行途中阻塞,1块盘离线。 - 此时并发到达的
AbortMultipartUpload删除了前8块快盘上的副本并返回成功。 - 随后那
7个延迟的NewMultipartUpload写入落盘,且离线的1块盘恢复或自愈后补齐副本——此时磁盘上重新凑齐了8份可读副本(达到EC:8对象读取法定人数),使客户端已中止的上传重新变为可操作状态。
尽管 4733e1423 保留的 AbortMultipartUpload 缓存驱逐与对端节点通知能够保持内存缓存一致,但要在磁盘层面彻底消除延迟创建复活,仍需引入墓碑(tombstone)或按上传维度的代际锁。
10 月 17 日代码改动汇总(后续已部分回滚)
cmd/object-api-interface.go、cmd/server-main.go、cmd/config-current.go:将api multipart_listing设为兼容性空操作(4ed86dfe6;在4733e1423中继续保留为空操作)。cmd/erasure-multipart.go:增加精确键解析时的xl.meta惰性回填,并将全部公共列表路由到磁盘扫描(4ed86dfe6;公共扫描路由已在4733e1423回滚,xl.meta写入元数据与0f9199e0c空父目录清理予以保留)。
10 月 17 日测试证据
go test ./cmd -run 'TestMultipartUploadListing|TestMultipartPreflight|TestMultipartListingConfig'在4ed86dfe6上全部通过。BenchmarkMultipartListingCapacity在 4 盘单键夹具上耗时101.6 ms,但未能覆盖 16 盘多键拓扑下的阈值悬崖。
#243 的后续工程教训
未来任何试图提供“跨重启持久且支持任意前缀”的 ListMultipartUploads 方案,必须按 (bucket, object, uploadID) 建立索引,严禁在公共列表路径上跨纠删集所有磁盘扫描 .minio.sys/multipart/<sha256(bucket/object)> 目录,且必须在 16 盘不同对象键($H = U$)拓扑下完成容量基准验证。
历史记录:9 月 16 日可选 strict 模式(#198)
Issue #198(提交 4cbb074cc,并由 143f6970d 与 0ff5b37fc 加固)在 Server 20260916 中交付了最初的按需启用实现:
- 新增
MINIO_API_MULTIPART_LISTING=legacy|strict(默认legacy)及mcli admin config set/reset ALIAS api multipart_listing。 - 新增
GET /minio/admin/v3/multipart-preflight,供运维人员在开启strict前检查磁盘.minio.sys/multipart中是否存在缺少x-minio-internal-multipart-v1-bucket与x-minio-internal-multipart-v1-object的历史上传。 - 在
strict模式下实现按(Key, Initiated, encoded UploadID)的 S3 字典序排列与floor(N/2) + 1磁盘发现法定人数。 - 9 月 16 日 Docker Desktop 环境实测记录:跨
4个容器的10,000个活跃上传在虚拟化容器文件系统上因逐盘读取xl.meta耗时18.7 s(并发度10下p95 = 2.68 s)。
原始根因分析(2026-08-30)
问题概述
在上游 MinIO 的纠删码存储引擎(erasureObjects)中,CreateMultipartUpload 将每个分片上传持久化在磁盘路径 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID> 下,同时将该上传写入按桶组织的内存缓存(multipartUploadsCache)。由于磁盘目录名对 bucket/object 做了 SHA-256 哈希,存储层在已知精确 bucket 和 object 时可以直接定位到对应目录;但在不打开每个活跃上传目录的前提下,无法直接从磁盘按桶或按对象键前缀列出上传。
8 月 30 日的原始建议
8 月 30 日的调研建议分两阶段改进 ListMultipartUploads:第一步先在 NewMultipartUpload 时向 xl.meta 写入 (bucket, object) 身份元数据并提供按需启用的磁盘扫描模式(#198);第二步再评估是否将磁盘扫描或专用按桶索引设为默认行为。
证据与信息来源
AWS S3 规范要求
AWS S3 ListMultipartUploads 规范要求支持 prefix 与 delimiter 过滤,按 Key 与 Initiated 时间升序排列(时间相同时按 UploadId 排序),并通过 key-marker 与 upload-id-marker 实现分页续传。
仓库 Issue #198
Issue #198 报告:带目录前缀(如 prefix=dir/)调用 ListMultipartUploads 时,即使刚刚执行完 CreateMultipartUpload 也会返回空列表;而不带前缀的 ListMultipartUploads 在服务进程重启后同样返回空列表,尽管使用之前保存的 UploadId 继续调用 UploadPart 和 CompleteMultipartUpload 完全正常。
上游 MinIO 历史演进
- MinIO PR
#5248:将早期镜像桶目录结构的分片上传树替换为.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>,以规避 POSIX 文件系统上的超长路径限制与空父目录清理缺陷。 - MinIO PR
#20407:引入内存multipartUploadsCache,使空prefix的ListMultipartUploads无需扫盘即可返回近期创建的上传;同时将非空prefix直接作为精确对象键传给listMultipartUploads(bucket, prefix, ...)。 - MinIO Issue
#20989:在docs/minio-limits.md中正式记录了这一上游限制:ListMultipartUploads仅支持空前缀(读内存)或将prefix设为完整对象键。
pgsty/silo 代码走查
cmd/bucket-handlers.go(ListMultipartUploadsHandler):解析了prefix、keyMarker、uploadIDMarker、delimiter与maxUploads,校验encoding-type后传给objectAPI.ListMultipartUploads。cmd/erasure-sets.go(erasureSets.ListMultipartUploads):按prefix == ""分支——为空时读取multipartUploadsCache.List(bucket);非空时对pathJoin(bucket, prefix)计算哈希并调用单哈希目录上的set.listMultipartUploads。cmd/erasure-multipart.go(erasureObjects.listMultipartUploads):仅列出.minio.sys/multipart/<sha256(bucket/object)>下的<uploadID>子目录,忽略了delimiter、keyMarker与uploadIDMarker。cmd/erasure-server-pool.go(erasureServerPools.ListMultipartUploads):在prefix != ""时顺序遍历各存储池,并在首个返回非空结果的存储池处提前返回。
设计评审结论
初步代码评审确认该问题属于存储布局层面的架构取舍,而非简单的参数解析遗漏:HTTP 处理器完整解析了所有 S3 查询参数,但底层存储缺少从 bucket + key-prefix 到 <sha256(bucket/object)> 的索引映射。
#198 之前的历史行为(即 20261018 恢复的默认行为)
空 prefix 路径
当 prefix == "" 时:
erasureSets.ListMultipartUploads直接读取目标桶的内存multipartUploadsCache。- 进程重启前创建的上传不在内存缓存中,因此不会出现在返回结果里,尽管磁盘
.minio.sys/multipart/<sha256(bucket/object)>/<uploadID>目录依然完好。 - 分布式节点间的缓存同步采用尽力而为机制(
updateMultipartUploadInfo)。
非空 prefix 路径
当 prefix != "" 时:
erasureSets.ListMultipartUploads将prefix视为完整对象名(object = prefix),计算bucket/object的 SHA-256 哈希以定位目标纠删集,并读取磁盘上的.minio.sys/multipart/<sha256(bucket/object)>。- 若调用方传入的是目录前缀(如
prefix=photos/2026/),服务端会对字面字符串"bucket/photos/2026/"计算哈希,因不存在该精确名称的对象而返回空列表。 - 若调用方传入精确对象键(如
prefix=photos/2026/video.mp4),服务端会定位到"bucket/photos/2026/video.mp4"对应的哈希目录,从磁盘读取匹配的<uploadID>子目录,即使经历过服务重启也能返回该对象下的全部活跃上传。
多存储池提前返回机制
在 erasureServerPools.ListMultipartUploads 中,非空 prefix 的提前返回对“精确对象键”是自洽的,因为 sha256(bucket/object) 确定性地映射到唯一存储池中的唯一纠删集;而若要支持任意前缀列表,则必须归并所有存储池的结果。
为什么缺少候选键时无法自动回填历史上传
由于 20260916 之前的 .minio.sys/multipart/<sha256(bucket/object)>/<uploadID>/xl.meta 既未记录桶名也未记录对象键,磁盘上遗留的历史上传目录仅包含 bucket/object 的 SHA-256 摘要。在既不知道候选 bucket/object 明文(无法正向计算哈希比对)、xl.meta 中又没有 x-minio-internal-multipart-v1-bucket 与 x-minio-internal-multipart-v1-object 的情况下,服务端无法逆向还原出 (Bucket, Object)。
结构性复杂度根源
| 层次 | 隐含假设 | 直接后果 |
|---|---|---|
S3 HTTP API(ListMultipartUploadsHandler) |
prefix 是对象键前缀;delimiter 折叠公共前缀;游标在有序键集合上分页 |
要求底层按桶提供有序的 (Key, Initiated, UploadID) 候选集 |
磁盘布局(.minio.sys/multipart/<sha256(bucket/object)>) |
调用方总是提供精确的 bucket 与 object(如 PutObjectPart、Complete、Abort) |
精确键查找为 $O(1)$,但既无按桶分区,也无字典序索引 |
内存缓存(multipartUploadsCache) |
仅跟踪本次进程启动以来观察到的分片上传 | 空 prefix 按桶列出速度快,但进程重启即清空 |
运维与兼容性影响
协议兼容性影响
使用目录 prefix 调用 ListMultipartUploads 的 SDK 与备份工具(例如 aws s3api list-multipart-uploads --bucket B --prefix dir/)在 prefix 不等于完整对象键时会收到空列表。
存储与孤儿上传清理影响
依赖重启后或按目录前缀调用 ListMultipartUploads 来发现并中止残留上传的外部工具,无法通过列表接口看到这些上传;但 Silo 内置的后台过期清理任务(cleanupStaleUploads)直接按修改时间遍历 .minio.sys/multipart,不依赖 ListMultipartUploads 即可自动回收过期分片。
运维可用性权衡
正如 2026-10-18 所验证的,强制 ListMultipartUploads 在每次未命中缓存的公共请求上遍历 .minio.sys/multipart,会把一个列表过滤限制变成 16 盘纠删集在 3,126 个活跃上传处的 503 SlowDown 可用性悬崖。保持 MinIO 兼容路径能够保护集群级 ListMultipartUploads 的稳定性,同时继续支持精确对象键查盘与后台过期回收。
严重度分级
- 数据完整性风险:无(
PutObjectPart、CompleteMultipartUpload与AbortMultipartUpload均按精确Bucket + Object + UploadID寻址)。 - S3 API 兼容性差异:已在 S3 API 兼容性参考中明确列出。
设计备选方案对比
方案 0:维持现状且不补充说明
会让调用方在 prefix=dir/ 返回空列表时难以排查。已淘汰,改为明确记录兼容性契约并保留 xl.meta 自描述元数据。
方案 1:保持 MinIO 兼容行为并明确记录契约(20261018 最终选择)
空前缀列表读取内存缓存,非空 prefix 读取单对象键磁盘目录,同时保留 xl.meta 身份字段并补齐空哈希父目录清理(0f9199e0c)。在 16 盘扫描回归验证后于 20261018 选定。
方案 2:基于 xl.meta 身份字段直接扫描磁盘 .minio.sys/multipart
在 #198 中作为可选 strict 模式实现,并在 #243 中短暂设为默认。在小规模上传下可用,但开销随纠删集内全桶上传数呈 $O(N \times (H + U))$ 增长,在 16 盘纠删集上于 3,126 个不同键上传处触及 100,000 遍历上限。
方案 3:构建按桶组织的专用分片上传索引
在 .minio.sys/multipart 旁维护按桶隔离、按前缀有序的元数据索引。可避免跨桶扫描 SHA-256 目录,但需要在每次 CreateMultipartUpload、CompleteMultipartUpload、AbortMultipartUpload 与过期清理时同步维护并自愈第二套分布式元数据结构。
方案 4:仅在内存缓存层扩展前缀与分隔符过滤
仅对内存 multipartUploadsCache 执行完整的 S3 prefix、delimiter 与游标过滤,不扫盘。速度快且开销有界,但重启前创建的上传在被精确键操作触达前依然不可见。
磁盘扫描参考架构(于 #198 实现,公共路径已于 4733e1423 回滚)
1. 先行冻结契约
在改动存储层前明确定义 (Key, Initiated, UploadID) 排序规则与游标续传边界。
2. 写入路径(xl.meta 身份写入)
在 NewMultipartUpload 中向 xl.meta 写入 x-minio-internal-multipart-v1-bucket 与 x-minio-internal-multipart-v1-object(20261018 已保留)。
3. 读取路径
从磁盘读取分片元数据时要求跨磁盘达到 readQuorum 一致。
4. 语义过滤层
在单纠删集发现层之上统一执行 Prefix、Delimiter、KeyMarker、UploadIDMarker 与 MaxUploads 过滤。
5. 精确键辅助函数
为内部调用方与精确键查询保留 $O(1)$ 的 sha256(bucket/object) 查盘辅助函数。
6. 缓存同步
在 AbortMultipartUpload 与 CompleteMultipartUpload 时跨对端节点同步驱逐 multipartUploadsCache 条目(20261018 已保留)。
7. 迁移与预检
提供 /minio/admin/v3/multipart-preflight 端点以检查磁盘分片目录状态(20261018 保留为兼容端点)。
8. 降级与挂起存储池处理
在多存储池列表时安全跳过挂起(suspended)的存储池,避免单池挂起导致整体请求失败。
#198 原型验证清单
- 验证
MetaSysMessagePack 字段与既有xl.meta读取器的前向/后向兼容性。 - 测量跨 $N$ 块磁盘的
ListDir与readAllI/O 放大倍数。 - 验证
cleanupStaleUploads在清理最后一个上传后同步删除空的<sha256(bucket/object)>父目录(已于0f9199e0c修复)。
验证门禁
单元测试
覆盖前缀、分隔符、游标分页以及 checkUploadID 路径穿越拒绝测试。
处理器与端到端测试
验证 ListMultipartUploadsHandler 响应 XML、URL 编码以及跨重启的缓存与精确键查盘行为。
故障与容量基准测试
在 4 盘与 16 盘纠删集拓扑下运行高达 10,000 个活跃上传的 BenchmarkMultipartPublicListingCapacity。
交付门禁
在 S3 API 兼容性参考中同步记录公共 ListMultipartUploads 契约,并保持所有历史锚点完整可用。
当前工程结论
在 Server 20261018 中,以 MinIO 兼容公共 ListMultipartUploads 路径(4733e1423)配合空父目录清理修复(0f9199e0c)作为生产默认实现。不要在公共列表路径上重新开启无索引的 .minio.sys/multipart 全盘扫描;如果未来版本确实需要跨重启持久的任意前缀列表,必须先设计按桶隔离的分片索引并在 16 盘纠删集上完成容量基准验证。
4.30 - 为什么 CompleteMultipartUpload 必须返回 ChecksumType:PR #57 评审记录
Shooks(@Dansyuqri)贡献的 PR #57 修复了 #47,并已随 Server 20260903 发布。当对象保存了附加 S3 校验和时,CompleteMultipartUpload 的 XML 响应现在会一并返回已存储的 ChecksumType 元素。
缺陷与范围
对 @cbornet 报告的 #31 的调查拆出了两个独立缺陷。CRC32 分片上传完成阶段的数据计算路径已由提交 c8590413f 与 3e14733f1 修复。但在分片上传成功完成后,虽然底层对象元数据保存了校验和类型且 HeadObject 与 GetObject 均可正常返回该字段,CompleteMultipartUpload 的 XML 响应却遗漏了 ChecksumType。这导致 S3 SDK 调用方在完成响应中收到了有效的校验和摘要,却拿不到对应的 ChecksumType。
这一遗漏虽然不会损坏已存储的对象数据,却使客户端无法仅凭完成响应区分整对象校验和(FULL_OBJECT)与分片组合校验和(COMPOSITE)。对于同一种校验和算法,两种模式生成的 Base64 摘要长度完全相同,下游校验方无法仅凭摘要字符串判断应当复现哪一种计算方式。
响应契约
| 已存储的校验和状态 | CompleteMultipartUpload 响应 |
|---|---|
| 整对象校验和 | ChecksumType=FULL_OBJECT 及对应算法摘要元素 |
| 分片组合校验和 | ChecksumType=COMPOSITE 及对应算法摘要元素 |
| 无附加校验和 | 不输出 ChecksumType 元素,也不伪造校验和 |
S3 CompleteMultipartUpload API 规范定义了 FULL_OBJECT 与 COMPOSITE 两种校验和类型。对象 ETag 是独立的响应字段,不能替代附加 S3 校验和元数据。
实现与证据
生产代码在 CompleteMultipartUploadResponse 结构体中新增带 xml:"ChecksumType,omitempty" 标签的 ChecksumType string 字段,并在解码已存储的校验和元数据后将 cs[xhttp.AmzChecksumType] 填入响应。响应生成器直接复用 HeadObject 与 GetObject 所读取的同一份已存储状态,既不会重新扫描对象内容,也不会根据分片数量猜测校验和类型。
合并提交(a96116b12)包含了针对整对象、分片组合与无校验和三种完成响应的单元测试。
兼容性与相邻改动
忽略未知 XML 元素的客户端保持完全兼容,而检查 ChecksumType 的 SDK(如 silo-go bc3b35f97)则能在分片完成响应中直接获取已存储的类型值。本次修复未引入新算法、未修改磁盘元数据格式,也无需迁移存量对象。
另外两项相邻的分片校验和修复同样随 Server 20260903 发布:UploadPart 校验和推断修复与 CompleteMultipartUpload 校验和错误映射(其中 CRC64NVME + COMPOSITE 会被显式拒绝,不再静默规范化)。
4.31 - 总量未知时,进度条应该说什么
在 SILO Console v2.1.1 中下载文件夹时,Downloads / Uploads(下载/上传)抽屉会显示 NaN%。尽管后端的流式 ZIP 归档仍在正常传输、底层存储对象也完好无损,但进度条却从“总量未知”错误地跳入了确定进度(determinate)渲染状态——导致运维人员误以为下载已卡死或失败,进而反复点击 Download。
该修复已随 Console 2.2.0(提交 16960f7ab,跟踪议题 #62)发布,并自 Server 20260903(edc8be6ed)起内嵌于服务端。其核心准则只有一条:
只有当下载任务拥有一个有限、正数且适用于当前响应字节流的总量时,才能进入确定百分比(determinate)模式;否则必须保持不确定(indeterminate)动画状态,直至完成、失败或取消。
(后续发布的 Console 2.4.1 进一步将文件夹与多选下载演进为文件流写入与浏览器原生下载交接,不再将完整 ZIP 缓存在浏览器内存 Blob 中;详见文末后续演进。)
可观察故障
在 SILO Console v2.1.1 中:
- 在某个公共前缀(如
folder/)下上传若干对象; - 停留在父级存储桶列表页,勾选
folder/并点击 Download; - 在传输完成前打开 Downloads / Uploads 面板;
- 当前下载行显示
NaN%,而底层 ZIP 流仍在继续传输。
根因分析
界面上出现的 NaN% 是 S3 前缀元数据、服务端流式 ZIP 响应与前端 XHR 进度算术三层契约错位共同作用的结果。
公共前缀没有对象大小
S3 中的“文件夹”只是逻辑上的公共前缀(Common Prefix),并非实际存储的目录对象。在列表模型中,前缀以 / 结尾并携带逻辑大小 size = 0(Console 列表将其渲染为 -,表示大小不适用)。由于自动生成的 API 模型为 size 字段标记了 omitempty,逻辑零值在列表 JSON 中会被省略。而单选下载 thunk 直接将 object.size 原样传给下载辅助函数,导致运行时传入的 fileSize 为 undefined(或人工构造前缀记录中的 0)。这两者都不能作为除法运算的有效分母。
流式 ZIP 没有预先可知的网络传输长度
当服务端收到以 / 结尾的下载请求时,会递归列出该前缀下的所有对象,并将 zip.Writer 直接连接到 io.Pipe:一边读取源对象、一边执行 Deflate 压缩、一边将生成的字节直接刷入 HTTP 响应流。
这种边压缩边流式输出的设计十分合理:服务端无需在内存或临时磁盘中缓存完整 ZIP 归档即可立即发出首字节。但它的必然推论是:在发送 HTTP 响应头的那一刻,最终压缩后的归档总字节数尚不存在,因此响应只包含 Content-Type: application/zip 与文件名,不包含 Content-Length。源对象的未压缩大小之和也不能替代 Content-Length:源大小是未压缩的逻辑字节,而浏览器 ProgressEvent.loaded 统计的是经过 Deflate 压缩并附带 ZIP 帧头后的网络传输字节,两者的量纲完全不同。
进度事件缺乏有效分母时的算术异常
前端 XHR progress 监听器此前无条件执行以下计算:
对于文件夹前缀或零字节对象,分母 fileSize 为 undefined 或 0,JavaScript 会计算出 NaN(loaded / undefined 或 0 / 0)或 Infinity(正数除以 0):
产品契约
进度展示需要严格遵守四条不变量:
以“分母是否合法有效”而非 objectPath.endsWith("/") 来定义契约,能够用同一套逻辑同时覆盖文件夹前缀、零字节对象、缺失大小元数据以及任何未来的分块流式响应,无需为特定对象类型编写脆弱的特判分支。
目标与非目标
目标
- 文件夹下载在任何时刻都绝不显示
NaN%、Infinity%或伪造的百分比。 - 总量未知的传输任务复用现有的不确定进度(indeterminate)动画。
- 已知大小的普通非空文件保持原有的确定百分比进度行为不变。
- 完成、失败与取消三种终态均必须退出不确定进度动画。
- 零字节文件下载过程中绝不产生非有限百分比,并在完成时正常到达
100%。 - 严禁任何非有限数(
NaN、Infinity)或越界百分比写入 Redux 状态树。
非目标
- 不为了凑出
Content-Length而在服务端预先缓冲整个 ZIP 归档; - 不把未压缩源对象大小之和当作压缩后网络传输进度的分母;
- 不在本次缺陷修复中重构整个 Object Manager 的 Redux 状态结构。
技术决策
最小生产修复由五个紧密配合的部分组成:
D1. 仅在拥有合法总量时计算百分比
将进度算术抽离为独立于 DOM 与 Redux 副作用的纯函数(downloadProgress.ts):
- 若存在有限正数
objectSize,优先使用它以保持普通文件下载进度计算完全不变; - 若
objectSize不可用(0或undefined),但浏览器声明响应长度可计算(event.lengthComputable === true)且提供了有限正数event.total,则将其作为回退分母; - 其他情况下统一返回
null,表示当前不存在可诚实计算的百分比。
D2. 总量未知时保持 Indeterminate 状态
修改 XHR progress 事件监听器,仅当 calculateDownloadPercent 返回非空数值时才派发 progressCallback:
由于下载条目初始化时默认 waitingForFile = true——且 ObjectHandled 组件已经将该状态渲染为 <ProgressBar variant="indeterminate" />——当 percent === null 时跳过派发即可让任务行自然保持在不确定进度模式,直到终态动作到达。
D3. 确保取消操作正确进入终态
此前完成(complete)与失败(fail)reducer 都会清除 waitingForFile,唯独取消(cancelObjectInList)遗漏了这一转换。在 cancelObjectInList 中补齐 item.waitingForFile = false,可防止处于不确定进度模式的文件夹下载在用户点击取消后仍继续播放活动动画。
同时在 XHR 边界增加事件顺序保护:调用 req.abort() 时会先触发 readystatechange (DONE, status = 0),随后才触发 onabort 事件。令通用 DONE 分支在 status === 0 时直接返回、交由专门的 onabort 或 onerror 处理器接管,可避免取消操作被抢先误标为下载失败。
D4. 归一化被省略的零字节大小
在单选下载 thunk 中传入 object.size || 0,与多选下载入口对齐。这样可在完成回调检查 Blob.size === fileSize 之前恢复 API 模型因 omitempty 而省略的逻辑零值,确保零字节对象在收到 HTTP 200 空响应时顺利过渡到 100% 完成状态。
D5. 保持服务端流式 ZIP 不变
Go 后端的文件夹下载处理器继续通过 io.Pipe 流式生成 Deflate 压缩 ZIP 并省略 Content-Length,无需改动任何服务端 API 或归档格式。
进度状态机
| 状态 | waitingForFile |
percentage |
终态标志 | 界面渲染 |
|---|---|---|---|---|
| 排队中 / 尚未收到有效进度 | true |
0 |
无 | 不确定进度条(indeterminate) |
| 总量未知的传输 | true |
0 |
无 | 不确定进度条(indeterminate) |
| 总量已知的传输 | false |
0..100 |
无 | 确定百分比进度条 |
| 已完成(Completed) | false |
100 |
done = true |
成功(100%) |
| 已失败(Failed) | false |
上次有效值 | failed = true, done = true |
错误状态 |
| 已取消(Cancelled) | false |
0 |
cancelled = true, done = true |
已取消状态 |
完备性证明
普通非空文件
objectSize > 0 提供有限正数分母,辅助函数返回 [0, 100] 范围内的整数,updateProgress 将 waitingForFile 置为 false 进入确定百分比模式,下载完成时置为 100%。
流式文件夹 ZIP
objectSize 归一化为 0,同时 lengthComputable = false 且 event.total = 0。辅助函数返回 null,不派发进度更新,任务行保持不确定动画;流式传输完成后置为 waitingForFile = false、percentage = 100 与 done = true。
未来带真实长度的响应
若反向代理或未来的归档端点提供了可信的响应总长度(lengthComputable = true 且 event.total > 0),同一套代码会自动回退使用 event.total 显示真实百分比,无需再次修改前端。
零字节文件
被省略的列表大小归一化为 0;传输期间若触发进度事件则返回 null(绝不计算 0 / 0);收到空响应后,零字节 Blob.size 与归一化后的期望大小相等,直接跃迁至 100% 完成状态。
失败与取消
失败与取消均会将 waitingForFile 置为 false 并将 done 置为 true,确保没有任何已终止的任务行继续显示活动动画。
被拒绝的替代方案
在服务端缓冲完整 ZIP 以获取 Content-Length
在服务端内存或临时磁盘中生成完整归档后再发送,虽然能提供精确的 Content-Length,但会显著推迟首字节时间(TTFB)、加剧内存与磁盘压力并恶化并发下载表现。绝不能为了一个前端进度展示缺陷而放弃流式传输。
将前缀下所有对象的未压缩大小相加
源对象大小之和是未压缩的逻辑字节,而 event.loaded 是经过 Deflate 压缩后的网络字节加 ZIP 帧头。由于分子与分母单位不同,进度条会随压缩率剧烈波动,甚至停留在远低于 100% 的位置或提前达到 100%。
将非法进度强制转换为 0%
在长达数 GB 的文件夹下载过程中一直显示确定模式的 0%,会让用户误以为一个字节都没有传过来、连接已经卡死。“总量未知”必须诚实地表达为未知。
仅对 / 结尾的路径做特判
只检查 objectPath.endsWith("/") 会遗漏真实的零字节对象、缺失元数据的响应以及其他不含 Content-Length 的分块传输响应。正确的判断边界是“分母是否可计算”,而非路径后缀。
将文件夹直接交给旧版 BrowserDownload
Console 2.2.0 时期的旧版 BrowserDownload 辅助函数会在点击隐藏 <a> 标签后立即触发完成回调,在首字节到达前就把任务标为 100% 完成,无法感知后续 HTTP 错误或支持取消。(真正的浏览器原生流式交接已在后续 Console 2.4.1 中独立设计与实现。)
仅在 ProgressBar 组件内部过滤 NaN
仅在展示组件内屏蔽 NaN 会任由非法数值写入 Redux 状态树,且无法阻止 waitingForFile 被错误清零。主防线必须设在进度事件进入应用状态的入口处。
立即将 Redux 状态扩展为 percentage: number | null
在保留 waitingForFile、done、failed 与 cancelled 四个布尔字段的同时再引入 null,只会制造更多自相矛盾的状态组合。既然现有 waitingForFile = true 已经准确建模并渲染了不确定进度状态,直接复用它的兼容性风险最小。
需求与验收条件
功能需求
- FR1–FR3:
calculateDownloadPercent仅返回null或[0, 100]范围内的有限整数;总量未知的文件夹下载保持不确定模式(waitingForFile = true);已知总量的普通对象下载保持确定百分比进度。 - FR4–FR5: 归一化被省略的零字节大小使空文件正常完成至
100%,并在完成、失败与取消时统一将waitingForFile置为false。
非功能需求
- 保持服务端流式 ZIP(
io.Pipe无全量缓冲)、S3/Console API 路由以及 Redux 状态类型不变。
验收条件
- 在文件夹、普通文件、零字节文件与中途取消等全部下载场景中,下载面板均不出现
NaN%或Infinity%。
验证矩阵
纯函数计算矩阵
| 场景 | loaded |
objectSize |
lengthComputable |
event.total |
期望返回值 |
|---|---|---|---|---|---|
| 普通文件下载过半 | 50 |
100 |
false |
0 |
50 |
公共前缀(folder/) |
1024 |
0 |
false |
0 |
null |
初始 0 / 0 |
0 |
0 |
false |
0 |
null |
| 响应总长度回退 | 50 |
0 |
true |
200 |
25 |
| 响应总长度为零 | 0 |
0 |
true |
0 |
null |
| 已加载字节超过总量 | 150 |
100 |
true |
100 |
100 |
非法对象大小 NaN |
10 |
NaN |
false |
0 |
null |
省略的对象大小 undefined |
10 |
undefined |
false |
0 |
null |
非有限响应总量 Infinity |
10 |
0 |
true |
Infinity |
null |
负数 loaded |
-1 |
100 |
true |
100 |
null |
状态转换测试
单元测试直接验证:新下载任务以 waitingForFile = true 启动;无有效百分比派发时保持不确定模式;收到有效进度后更新有限百分比并将 waitingForFile 置为 false;完成、失败与取消均将 waitingForFile 置为 false。
真实浏览器端到端回归
Playwright 测试在真实 SILO + Console 实例上利用 Chromium CDP 网络限速保留传输观察窗口,验证文件夹下载期间进度抽屉显示不确定动画且不含 NaN% 或 Infinity%,验证中途取消能正确进入 Cancelled 终态,并验证不限速下载的 ZIP 归档完整性、普通非空文件进度以及零字节文件下载。
实现范围
- 新增
downloadProgress.ts纯计算模块; - 修改
Objects/utils.ts:仅当计算结果非空时调用progressCallback,并让status === 0的终止事件交由onabort/onerror处理; - 在单选下载 thunk 中将
object.size || 0归一化; - 在
cancelObjectInListreducer 中将item.waitingForFile置为false; - 在
playwright.config.ts中配置纯单元测试与 Chromium 限速端到端回归测试。
交付与回滚
该修复已随 Console v2.2.0(silo-console#29)发布,并内嵌于 SILO Server 20260903,关闭 pgsty/silo#62。不涉及任何数据或 API 迁移。
完成定义
-
calculateDownloadPercent仅返回null或[0, 100]范围内的有限整数; - 总量未知的文件夹下载展示不确定进度,普通文件与零字节文件正常展示确定进度;
- 完成、失败与取消均退出不确定进度模式(
waitingForFile = false); - 随 Console
2.2.0与 Server20260903正式发布。
后续演进任务
评审期间识别出的五项演进方向——消除前端整包 Blob 内存缓冲并直接流式交接给浏览器、进度终态可辨识联合类型、服务端流式 ZIP 错误中断传播、共享进度条组件的非有限值兜底守卫,以及 Blob JSON 错误响应解码——已分别在后续 Console 版本中推进落地。
后续演进:Console 2.4.1 的浏览器原生流式交接
在合入上述进度修复的同时,Console 2.2.0 还将对象列表中的 size 改为始终输出的 JSON 字段,并加固了服务端 ZIP 错误传播:不可读条目不再被静默跳过,响应头发出前的错误返回 HTTP 500,响应开始后的错误则通过中断流暴露给客户端。
随后发布的 Console 2.4.1 进一步消除了整包 XHR Blob 内存缓冲:单文件夹与单对象下载直接交接给浏览器原生下载管理器,多选打包下载则通过文件系统流式写入器(FileSystemWritableFileStream)或原生流交接落盘。在这一新架构下,Console 下载面板中的完成状态代表“已成功交接给浏览器下载管理器”,后续字节传输进度与取消操作均由浏览器原生下载管理器接管。
4.32 - 列举不能丢掉仍有仲裁的 null 版本
本文记录 2026-09-16 调查的对象列举漏项问题:未开启版本控制的存储桶在并发覆盖写下返回 HTTP 200 的列表,却漏掉了同一端点仍能正常 GET 的对象键。下文说明已确认的合并缺陷机制、提交 8d06424b1(随 Server 20260916 发布)的针对性修复与验证数据,以及在 #218 中跟踪、随后在 Server 20261018 中由 PR #239 关闭的滚动重启漏项边界(详见 LIST 恢复设计)。
缺陷机制
对象列举会合并被询问各块磁盘返回的版本流。当各盘状态不一致时,mergeXLV2Versions 选出一个最新版本并统计同意该版本的磁盘流数量。上游 2022 年的一处改动(PR #14125)让这一计票过程容忍自愈(Healing)留下的签名差异——这一容忍本身是必要的,但它的版本选择与计票方式对输入切片顺序敏感。当每块盘各持有一个普通 null 版本、而版本更新的少数盘恰好排在切片末尾时,旧一代版本的计票会被重置。原本实际达到读仲裁(Quorum)的旧一代版本因此被丢弃,合并函数返回空切片,解析器进而将该对象键当作不存在跳过。由于 HTTP 请求整体仍返回 200 OK,客户端会看到一份看似完整、实则漏掉有效键的列表;而对该键直接发起 GET 仍能成功,因为单对象读路径独立解析读仲裁。
修复前的代码在 9 种磁盘排列顺序下以此方式复现失败(具备完整的签名 LIST 报文证据),而完全相同的磁盘内容在其余排列顺序下则列举正常。
修复方案(8d06424b1)
提交 8d06424b1 新增的二次计票仅在原选择逻辑未能达到仲裁时触发,且严格限定于能够精确判定的输入场景:在裁剪前的原始输入上,每个非空磁盘流恰好持有一个普通 null 版本、不包含 free version,且所有磁盘流的纠删码参数完全一致。满足上述条件时,算法按元数据头部重新分组,唯有达到原始有效仲裁数的组才会返回。混合版本历史、显式版本 ID、删除标记、free version 与混合纠删布局均保持原有行为;严格模式、签名比对、平票决胜与代表条目选择规则保持不变,共享该合并函数的其他调用方对所有原本成功的输入均得到完全相同的结果。
8d06424b1 的验证数据:
- 9 个旧顺序反例与 7 个新顺序反例全部通过;5,620 个差分测试输入保持契约结果一致。
- 完整签名 HTTP 列举、对象读取、常规与
-race并发检查,以及扫描器(Scanner)、自愈(Healing)与迁移(Migration)等下游调用方测试全部通过(含 6 个顶层测试与 23 个嵌套子用例)。 - 在 4 节点 16 盘集群上叠加并发覆盖写,连续 20,000 次稳态成功
LIST零漏项。
滚动重启边界(#218,已由 20261018 的 #239 闭环)
在同一套 4 节点 16 盘集群上,使用 20260916 构建对四个节点执行滚动重启并叠加并发覆盖写时,27,966 次成功返回的 LIST 请求中有 2,624 次漏掉了 1 至 4 个对象键。这些被漏掉的键实际可读:8,192 次同端点 GET/HEAD 核查中有 8,186 次返回 200 OK 且内容为最后一次确认的写入。最典型样本发生在最后一个节点健康检查恢复正常 17.6 秒之后,所漏掉的键最后一次成功 PUT 已在 11.5 秒前确认完成且其后无新写入。原始 XML、HTTP 请求关联与响应体哈希均经独立复核,排除了分页与客户端解析因素。
这一滚动重启漏项发生在进入 mergeXLV2Versions 之前或周边的列举管线中——20260916 构建中仍存在将瞬态读取或锁竞争失败静默转为“键不存在”的分支:
listPathRaw获取命名空间读锁(NSLock)在高并发写竞争下达到 1 秒硬超时后直接失败或跳过;- 单块远端磁盘在流式列举中途断连,导致解析器拿到的有效条目少于仲裁数或缺少缓存元数据;
- 部分解析回调(Partial-Resolution Callback)在失效 reader 数量未超过整集容错阈值时静默丢弃单个条目。
Issue #218 专门跟踪了上述滚动重启调查,随后在 Server 20261018 的 PR #239 中通过移除 listPathRaw 的 1 秒读锁硬超时并引入有界 metacache 流恢复机制彻底解决。完整根因分析、修复设计与基准测试见节点重启与 NSLock 锁竞争下的 LIST 恢复设计。
4.33 - ListObjects 快捷路径不能把不存在的桶伪装成空桶
随 SILO Server 20260903 发布的 PR #37(PR 提交 e9c5340be,合并提交 49c8aeac4,关闭 SILO #32),修复了 ListObjects、ListObjectsV2 与 ListObjectVersions 中三条提前返回(early-return)快捷路径在目标桶不存在时错误返回 HTTP 200 OK 空列表、而非 HTTP 404 NoSuchBucket 的 S3 兼容性回归。
太长不看(TL;DR)
对不存在的桶执行普通 ListObjects、ListObjectsV2 或 ListObjectVersions 请求时,扫描存储层会触发 BucketNotFound;但以下三类输入会在访问存储前提前返回:
marker(或start-after)不以prefix开头;max-keys=0;prefix以/开头,包括 #32 中boto3使用的Prefix="/"。
这三个分支原先直接返回 io.EOF,上层调用方将其解释为“列表正常结束”,导致客户端收到空的 HTTP 200,而非 S3 标准规定的 HTTP 404 NoSuchBucket。同一个不存在的桶,仅因过滤参数不同就从资源不存在错误变成成功响应,既破坏了 S3 兼容性,也阻塞了从回归前版本升级的用户。
修复该问题不能把昂贵的桶存在性检查重新加回每一次列表请求。选定方案仅将这三处裸 io.EOF 替换为调用辅助函数 listPathShortcutEOF:该函数调用一次 GetBucketInfo,当桶不存在或集群无法确认时返回真实错误,当桶存在时仍返回 io.EOF。正常列表热路径完全不变,额外的节点与磁盘扇出开销仅由原本会在访问存储前直接退出的快捷请求承担。
这是什么问题
同一个 API 出现两套桶存在性语义
#32 给出的最小复现是在不存在的桶上调用:
AWS S3 抛出 NoSuchBucket,而 SILO 却返回一个成功的空列表。差异源于对象层 listPath 的控制流:
触发提前返回的不仅限于 / 前缀:
| 快捷条件 | 为什么结果必为空 | 修复前的缺陷 |
|---|---|---|
marker 不以 prefix 开头 |
当前实现不扫描该不相交区间 | 未确认桶是否存在即返回 io.EOF |
max-keys=0 |
调用方明确要求返回零个键 | 将“请求零条结果”错误等同于“桶资源有效” |
prefix 以 / 开头 |
SILO 的扁平键空间不会生成以 / 开头的对象名 |
过滤条件在检查桶身份之前发生短路 |
对于已存在的桶,这三个分支直接返回空列表是合理的快路径优化;但对于不存在的桶,同一个 io.EOF 会掩盖应当优先返回的资源错误。
追溯上游 #18917 引入的回归起点
报告者确认 RELEASE.2024-01-29T03-56-32Z 行为正确,而从 RELEASE.2024-01-31T20-20-33Z 开始出现回归。追溯提交历史可以定位到上游变更 minio/minio#18917(80ca12008):该提交从通用参数检查中移除了 GetBucketInfo,改为依赖后续实际的 Put、List 与 Multipart 存储操作自行暴露不存在的桶。
这一优化假设在请求真正进入存储层时成立,却遗漏了“提前返回路径根本不会到达存储层”的边界。#32 并不需要全面回滚 #18917 的优化,只需补齐被绕过的三处控制流出口。
为什么要修复
S3 契约明确要求返回 NoSuchBucket
AWS ListObjects 与 ListObjectsV2 均将 NoSuchBucket 定义为 HTTP 404(指定桶不存在)。prefix、marker、start-after 与 max-keys 仅用于筛选桶内对象,不能让不存在的桶变成一次成功请求。同时,ListObjectVersions 共享同一个对象层列表引擎,在三处快捷入口上统一桶存在性语义可避免 V1、V2 与版本列表行为分化。
错误的空列表会改变客户端控制流决策
空的 HTTP 200 与 HTTP 404 在客户端逻辑中代表截然不同的分支:
- HTTP 404 会指示部署脚本、SDK 或测试代码先创建桶、修正配置或终止流程;
- 空的 HTTP 200 则声明桶已存在,只是当前没有匹配的对象;
- 使用 SILO 作为本地 S3 替身的集成测试会在本地假性通过,部署到 AWS S3 时却直接报错。
修复面极窄且易于验证
该缺陷完全集中在 cmd/metacache-server-pool.go 的三个相邻提前返回分支中,不涉及对象数据、元数据格式、排序、分页令牌编码、IAM 权限或 XML 结构。
为什么不能简单恢复全局检查
上游在 #18917 中移除通用 GetBucketInfo 并非随意清理:每次 Put、List 与 Multipart 操作前先检查桶存在性会在所有节点间产生 RPC 扇出;即便经过向量化合并,当集群规模超过 100 个节点后,该开销依然十分显著。
在 SILO 当前实现中,erasureServerPools.GetBucketInfo 会调用 S3PeerSys.GetBucketInfo,并发向所有节点发起请求并按存储池聚合法定人数,每个节点还需检查本地桶状态。因此两个极端方案均不可取:
- 完全不检查: 继续在不存在的桶上返回错误的空 HTTP 200;
- 每次
List前都检查: 恢复了404语义,却撤销了大型集群上的关键性能优化。
将 GetBucketInfo 严格限制在“不会访问存储、因而无法自然发现缺桶”的三条快捷分支上,即可在不影响正常列表热路径的前提下修复该兼容性缺陷。
具体修复方案
仅替换三处裸 io.EOF 返回
在 cmd/metacache-server-pool.go 中,三个快捷分支原先均执行:
现统一改为调用 listPathShortcutEOF:
该辅助函数定义如下:
其行为分为三类:
- 桶存在: 返回
io.EOF,保留原有的空列表返回行为; - 桶不存在: 返回
BucketNotFound,交由既有错误映射生成 HTTP 404NoSuchBucket; - 集群状态无法确认: 原样向上传播仲裁失败、节点离线、超时或上下文取消错误,不再伪造空成功响应。
正常的 listMerged、metacache 扫描、排序、分页与响应生成路径完全不变。
为什么辅助函数必须放在 listPath
将检查紧贴 listPath 的快捷出口放置基于三点原因:
- 只有
listPath知道当前请求即将绕过所有底层存储访问; - 若上移到通用 HTTP 参数校验层,会让所有正常列表请求承担额外扇出开销;
- 若下移到存储扫描层则完全无效,因为这三个分支根本不会进入扫描层。
为什么不引入桶存在性缓存
虽然增加 bucket-existence cache 可以降低快捷路径的扇出开销,但会立即引入桶创建、删除、跨站点复制、故障恢复与 TTL 过期窗口期的一致性失效问题。为了三个低频快捷分支引入第二套状态源,其复杂度与陈旧数据风险远高于收益。若未来生产遥测表明大型集群存在高频 max-keys=0 或 / 前缀探测流量,再基于真实观测数据评估限流或专用元数据快路径。
测试与评审证据
对象层契约测试
对象层测试在单盘与多盘纠删码配置下,对以下四类输入逐一验证:
- 以
/开头的prefix(Prefix="/"与Prefix="/sub"); max-keys=0;- 不以
prefix开头的marker; - 普通
prefix(作为由底层存储自然返回BucketNotFound的对照基准)。
每组测试均覆盖 ListObjects、ListObjectsV2 与 ListObjectVersions,并使用类型化的 isErrBucketNotFound 断言。
HTTP 协议层契约测试
Handler 测试使用真实签名请求验证三个公开列表 API:
| API | 请求形态 | 断言 |
|---|---|---|
ListObjects |
GET /missing-bucket?prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
ListObjectsV2 |
GET /missing-bucket?list-type=2&prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
ListObjectVersions |
GET /missing-bucket?versions&prefix=/ |
HTTP 404,XML 错误码为 NoSuchBucket |
本地与 CI 质量门禁
修复通过了完整本地验证(go test ./cmd -count=1 耗时 116.2 秒、10 个新增对象层与 HTTP 回归子场景、定向 go test -race、CGO_ENABLED=0 go build ./...、go vet ./... 以及独立设计与代码审查)。在 GitHub Actions 上,带 DCO 签名的 PR 提交 e9c5340be 通过了 DCO、VulnCheck 及 Go CI 全部 6 个构建测试任务;合并提交 49c8aeac4 随后也独立通过了 VulnCheck 与 Go CI。
衍生风险与边界情况
快捷路径请求现在会产生集群扇出
这是本修复唯一刻意接受的运行时成本:针对已存在桶的三类快捷请求过去仅相当于一次本地分支判断,现在需要调用 GetBucketInfo。本机微基准测试对比如下:
| 路径 | 本机观测耗时与分配 |
|---|---|
| 修复前快捷路径 | 约 0.55 μs,7 allocs |
| 修复后单盘快捷路径 | 约 7.8–8.1 μs,45–47 allocs |
| 修复后 32 盘快捷路径 | 约 70–81 μs,977 allocs |
| 32 盘正常列表请求 | 约 0.95 ms |
在真实多节点部署中,GetBucketInfo 还包含跨节点网络往返与法定人数聚合延迟。这也正是为何绝不能将该检查扩展到正常列表热路径。若存在配置错误的客户端高频轮询 max-keys=0、/ 前缀或不相交 marker,可通过 S3 追踪或监控指标识别并按需限流。
降级集群会暴露真实的仲裁错误
过去当节点离线或桶仲裁不足时,快捷路径由于根本不接触集群状态,仍会返回空的 HTTP 200。修复后,这类请求在集群降级无法确认桶是否存在时会返回仲裁、超时或服务不可用错误,避免了在无法确认资源状态时谎称“存在有效空桶”。
桶创建与删除并发仍不具备线性化快照
GetBucketInfo 与返回空列表是两个先后动作:桶可能在检查通过后立即被删除,或在形成缺桶错误后立即被创建。这与其他所有“先校验资源存在性、再执行操作”的 S3 API 属于同一并发模型。
依赖旧错误行为的客户端会收到 404
此前误把缺桶上的空 HTTP 200 当成正常状态的客户端在升级后会进入 404 错误分支。这一行为变化恢复了 S3 标准契约以及 2024 年 1 月回归之前的历史行为。
刻意留在范围外的两个相邻边界
设计审查记录了两个不阻塞合并的低优先级边界:
- 桶删除后的陈旧分页令牌: 在恢复 metacache 分页续传(continuation)时,
c.fileNotFound分支仍直接返回裸io.EOF。如果桶被删除后客户端携带陈旧或手工构造的 continuation token 请求,理论上仍可能收到空 HTTP 200。若在此处加入GetBucketInfo会影响所有正常分页续传请求的性能,需单独设计。 - Handler 层的
NotImplemented校验优先级: V1 与版本列表的某些marker/prefix组合会在 HTTP handler 参数校验阶段先返回NotImplemented,尚未到达对象层(V2 的start-after则会进入对象层listPath)。本补丁修复的是存储快捷路径掩盖缺桶错误,不改变畸形参数校验与资源错误之间的既有优先级。
评估并否决的替代方案
保持上游现状
优点是零性能变化且减少与上游分支的差异;缺点是继续违反 S3 契约、保留已知回归,并导致 SILO 在作为 S3 集成测试环境时给出错误信号。否决。
恢复通用 checkBucketExist
虽然能一次覆盖所有路径,但会把跨节点扇出重新加回每一个 Put、List 与 Multipart 操作,直接撤销 #18917 的大型集群优化。成本与收益严重失衡,否决。
只修复 Prefix="/"
仅修补 / 前缀虽能通过 #32 的单条 boto3 复现,但会遗留 max-keys=0 与不相交 marker 两个同源缺陷。三个分支在 listPath 中紧邻且语义完全相同,用同一个辅助函数收敛更完整。否决。
增加桶存在性缓存
缓存虽能降低快捷路径延迟,但需要处理创建、删除、复制、自愈与 TTL 窗口期的陈旧状态。当前并无遥测数据表明这三类快捷请求具有足够高的流量来支撑一套新缓存,否决。
复杂度与成本收益评估
| 维度 | 评价 | 说明 |
|---|---|---|
| 生产代码复杂度 | 低 | 仅修改三处调用点并新增一个 7 行辅助函数;无新状态、依赖或磁盘格式 |
| 测试复杂度 | 低到中 | 同时覆盖 V1、V2、版本列表、三类快捷条件、正常前缀对照与 HTTP 404 映射 |
| 正常路径风险 | 极低 | listMerged 正常列表热路径无任何变更 |
| 快捷路径运行时成本 | 仅快捷路径上升 | 从本地分支返回变为集群级 GetBucketInfo 查询 |
| 兼容性收益 | 高 | 恢复 HTTP 404 NoSuchBucket、回归前升级兼容性及 S3 测试保真度 |
| 运维复杂度 | 无 | 无需数据迁移、配置项、特性开关或缓存维护 |
验收决策
补强后的 PR #37(保留 Jason Lin 在 e9c5340be 上的共同作者署名)已合并为 49c8aeac4,包含类型化错误断言、V1/V2/版本列表对象层覆盖及 HTTP 404 NoSuchBucket 验证,并随 SILO Server 20260903 正式发布。
总结
本问题的根源在于列表引擎把 io.EOF 同时用于表达两件不同的事:“已存在桶的查询结果为空”以及“从未确认桶是否存在的提前过滤退出”。上游为优化大型集群性能而移除通用存在性检查,依赖的是“后续真实存储操作会自然暴露缺桶错误”这一前提。选定方案仅在三个绕过存储的出口调用 GetBucketInfo,在完整保留正常列表热路径性能的同时,恢复了这一前提与 S3 NoSuchBucket 协议契约。
4.34 - 只读 Checksum 审计与可靠的 CLI 输出契约
本文记录 MCLI 只读 checksum 校验工作流,以及发布复核阶段发现的 non-TTY 输出缺陷 pgsty/mc#5 的设计与实现。该命令经由 pgsty/mc 的 pull request #8 与 #13 合入主干,并随 mcli 20260903 正式发布(已内置于 Server 20260903 容器镜像)。
核心概览
历史版本的 CopyObject 实现可能在压缩或加密后的底层存储字节上计算附加校验和(additional checksum),而非在 S3 GET 返回给客户端的逻辑对象字节上计算。mcli checksum verify 会筛选候选对象,独立地将逻辑对象流送入已记录的校验和算法,并把每个候选分类为 MATCH、MISMATCH、NO_CHECKSUM、WOULD_VERIFY(dry run)、十种 UNKNOWN_* 分类之一,或三种 SKIPPED_* 结果之一。
首版实现在交互式终端中工作正常,但当 stdout 被重定向到文件或管道时却没有任何输出(#5)。原因在于 MCLI 为在非终端环境中禁用进度条 UI,会自动将内部状态 globalQuiet 标记为启用;新命令误把这项内部状态当成了“用户要求隐藏审计结果”。PR #13 将语义输出与进度条抑制解耦,在不改变全局 quiet 行为、也不在 CI 中重新打开进度条的前提下恢复了非终端输出。
命令与范围
第一版支持标记为 FULL_OBJECT 的 CRC32、CRC32C、CRC64NVME、SHA1 与 SHA256 校验和。检查目标可以是单个对象、精确 VersionID、前缀下的当前对象、所有历史版本,或由 JSON Lines manifest 给出的精确条目。命令同时支持 SSE-C 密钥映射、时间与大小过滤、--dry-run 成本预估、有界并发 worker(--max-workers)、全局下载限速、--json 输出以及可选的 --report JSON Lines 报告。
该命令严格只读:不校验分段复合校验和(COMPOSITE),不从 ETag 推断校验和类型,不直接读取底层 xl.meta,也不修改任何对象元数据。服务端点必须随校验和一并返回类型头(x-amz-checksum-type);对于不返回该头的端点,所有带校验和的对象都会被归类为 UNKNOWN_CHECKSUM_TYPE,绝不猜测类型。
当前不支持 --rewind、分层恢复、服务端批处理、断点续查或修复。
只读数据路径
对每个候选对象,mcli checksum verify 执行以下五步:
- 启用 checksum mode 发送
HEAD请求,保留所有受支持的校验和摘要与ChecksumType。 - 对不支持或含糊的元数据状态直接归入
UNKNOWN_*,不做猜测。 - 将
GET返回的逻辑字节流送入有界内存哈希器,不把对象正文写入本地磁盘。 - 对不可变历史版本使用
VersionID固定目标;对可变的未版本化或null版本对象使用If-Match,并在流式读取完成后再次HEAD核验以防并发替换。 - 将独立计算的摘要与已存摘要逐一比较。
S3 传输边界仅允许 LIST、HEAD 与 GET;若 mock endpoint 收到任何写方法,自动化测试立即失败。
专用 S3 helper 通过 StatObject 保留所有校验和与类型,再用 Core.GetObject 读取逻辑字节,不启用 SDK 自动校验器。这样无需改变共享 ClientContent,也不会改变 stat、复制、镜像或 JSON 输出的既有契约。
结果与退出码契约
每个候选对象只产生一个稳定的分类结果:
| 结果 | 含义 |
|---|---|
MATCH |
所有受支持的已存校验和均匹配 GET 返回的逻辑对象字节 |
MISMATCH |
至少一个已存校验和与逻辑对象字节不一致 |
NO_CHECKSUM |
对象没有附加校验和,不读取对象正文 |
WOULD_VERIFY |
--dry-run 发现可校验的 FULL_OBJECT 校验和 |
UNKNOWN_* |
MCLI 无法给出可靠校验结论(如 COMPOSITE 或缺失 ChecksumType) |
SKIPPED_* |
过滤条件排除了该对象(SKIPPED_TOO_LARGE、时间过滤或删除标记) |
最终汇总(summary)携带 objects、verified 计数、每种结果状态的计数以及 incomplete 标志。verified 等于 MATCH 加 MISMATCH——只有这两种结果真正把对象正文流过了哈希器。枚举了大量对象却实际核验为零的运行,会在汇总中清晰体现。
--fail-on 支持 mismatch、unknown、no-checksum、any 与 none:
- 默认值
any在出现MISMATCH、UNKNOWN_*或审计不完整时返回退出码1。其中SKIPPED_TOO_LARGE会使any返回退出码1(因为大小上限使审计不完整),而时间过滤与删除标记跳过不会触发失败。 no-checksum包含any的全部失败条件,并在任一对象缺少校验和或verified == 0时返回1。--dry-run不执行--fail-on失败判定。- 命令行参数、认证、对象枚举与报告写入失败属于命令级错误,直接返回非零退出码。
输出与自动化契约
逐对象记录和最终 summary 都是命令的语义输出:
- 除非调用方显式指定
--quiet、-q或MC_QUIET=true,TTY 与 non-TTY 下的 stdout 都必须收到全部对象记录和最终 summary。 - non-TTY 下的
--json每行输出一个紧凑 JSON 值(JSON Lines);TTY 下的--json保留 MCLI 既有的缩进美化格式。 - 全局参数(
--json、--quiet)在根命令mcli、checksum与verify三层位置均生效。 --report独立于 stdout:即使显式--quiet让 stdout 静默,--report仍会完整写入对象记录和最终 summary 的 JSON Lines。- 输出通道不会改变
--fail-on的退出码判定。
这一区分非常关键:MCLI 历史上的 globalQuiet 混合了两个来源——用户显式传入的 quiet 参数,以及拿不到终端尺寸时自动启用、用于关闭进度条 UI 的 non-TTY 状态。如果直接修改全局 globalQuiet,会让 cp、get、put、mirror 等命令在 CI 日志中重新输出进度条。
因此 PR #13 的修复仅作用于 checksum 命令内部:它沿完整 CLI context 链查找显式 quiet/JSON 参数(绕开 CLI 库 GlobalBool 只检查最近一层父级 flag set 的限制),并在 verify action 内部恢复被嵌套 Before 钩子重置的 JSON Lines 模式。
报告、密钥安全与运行成本
报告文件(--report)在 POSIX 系统上以 0600 权限新建,Windows 使用账户的文件系统 ACL。若目标路径已存在则报错退出;报告中只保存对象元数据与校验结果,绝不包含对象正文或 SSE-C 客户密钥。输入清单(--manifest)同样只记录 bucket、key 与可选 versionId。
由于校验过程会完整下载每个受支持对象的逻辑正文,运维人员应组合使用 --dry-run、--max-size、时间过滤、--max-workers 与全局下载限速控制网络与存储负载。NO_CHECKSUM 与 UNKNOWN_* 计数在汇总中独立展示,绝不与 MATCH 混同。
Mismatch 能证明什么
MISMATCH 证明:校验时刻 HEAD 返回的附加校验和,与同一时刻 GET 返回的逻辑对象字节不一致。单凭 MISMATCH 本身,不能直接断定对象一定由某个历史压缩复制缺陷生成,也不能替代与外部权威数据源的比对。
排查出 MISMATCH 时,不要原地覆写底层校验和元数据。应先通过只读审计完成分类;对确认受影响且有业务消费的对象,优先写入新对象键或新版本,用 mcli checksum verify 验证新对象返回 MATCH 后再切换下游消费方。UNKNOWN_* 对象不得纳入自动化修复脚本。
验证记录与输出 Schema
验收矩阵覆盖 TTY 人类可读与 JSON 输出、non-TTY 管道、普通文件重定向、app/parent/leaf 三层 JSON 与 quiet 参数、环境变量 MC_QUIET=true、quiet 模式下的 --report、报告写入失败、MISMATCH/UNKNOWN_* 退出码,以及针对真实 SILO 服务端的 MATCH、MISMATCH 与 COMPOSITE 对象实测(详见 mc checksum verify 命令参考)。
JSON 消费方应校验 schemaVersion: 1,并通过 type: object 与 type: summary 区分逐对象记录和最终汇总。--max-workers 默认为 4(范围 1–64),用于限制并发对象校验协程数。
测试还覆盖多校验和任一不匹配、固定与 null 版本、正常完成时每个候选只输出一次,以及 worker 取消后正常关闭结果流。InvalidObjectState 优先归类为需要恢复的存储状态,不能误作通用 403;SSE-C 错误分类不依赖 URL 或对象名文本。
4.35 - 可选校验和,强制失败:修复 UploadPart 与 UploadPartCopy 兼容性
本文记录 SILO #46(合并提交 7fea6d5a5,随 Server 20260903 发布,后续延伸修复见 #63 与 #64)的设计与实现:如何围绕 S3 分片上传的可选校验和请求头,同时理顺分片合并(completion)语义、复制响应、压缩与加密数据流以及兼容性基线守卫。
太长不看(TL;DR)
分片上传(Multipart Upload,简称 MPU)会将大对象切分为多个分片(part)分别上传。AWS S3 允许客户端在上传每个分片时附带校验和(checksum),但将其定义为可选请求头。SILO 过去却将其作为必填项强制校验:普通 UploadPart 请求一旦省略校验和就会返回 400 错误,而本身不包含请求体的 UploadPartCopy 则必然失败。
修复后,若客户端提供了分片校验和,SILO 继续严格校验;若客户端未提供,SILO 则在读取原始明文流的同一遍过程中自行计算校验和并写入分片元数据。计算发生在压缩与加密变换之前,无需二次读取磁盘,也不改变磁盘存储格式。
最终决策
当分片上传在 CreateMultipartUpload 阶段声明了校验和算法后,SILO 遵循以下处理规则:
- 若客户端在请求中提供了分片级校验和,服务端继续按原逻辑校验;校验值不匹配或算法不一致时直接报错拒绝,绝不用服务端计算结果掩盖客户端错误。
- 若客户端省略了分片级校验和,服务端使用该 MPU 记录的算法,在压缩与加密之前的逻辑明文字节流上单遍计算校验和并持久化到分片元数据。
- 普通
UploadPart仅在客户端请求携带了校验和时才在响应头中回显;服务端自行补算的校验和不在UploadPart响应头中返回。 UploadPartCopy没有客户端请求体校验和,统一由服务端计算并在CopyPartResultXML 响应体中返回对应字段。ListParts返回已持久化的各分片校验和。FULL_OBJECT模式的合并操作继续从各分片校验和线性推导完整对象校验和;COMPOSITE模式的合并操作继续要求客户端提交各分片的校验和(客户端可通过ListParts查询获取)。- 校验和计算在首次读取数据流时同步完成,合并阶段绝不重新读取整个对象。
我们如何发现问题
该缺陷在排查另一个分片上传校验和议题 #31 时暴露。
#31 处理的是 CompleteMultipartUpload:当校验和类型为 FULL_OBJECT 时,客户端只需提交分片编号、ETag 及可选的完整对象校验和,无需在合并 XML 中逐一列出所有分片的校验和。在向上游回溯数据流时,我们发现 erasureObjects.PutObjectPart 在写入任何分片前有一处硬性拦截:
只要 MPU 在创建时声明了校验和算法,后续每个 UploadPart 请求都必须携带匹配的 x-amz-checksum-* 请求头,否则直接返回:
API 探针在单盘与纠删码后端上均复现了该错误。
进一步核查 CopyObjectPartHandler 后,这一兼容性问题直接升级为 P0 级缺陷:UploadPartCopy 请求根本没有可供客户端计算校验和的请求体。处理函数从源对象读取字节并构造内部读取器,最终同样调用 PutObjectPart。客户端既无法通过补充请求头绕过,也无法通过调整 SDK 配置规避,导致所有开启校验和的 MPU 都无法使用 UploadPartCopy。
AWS 契约到底是什么
AWS S3 接口文档对三类相关操作的输入与输出有明确界定:
- AWS UploadPart API 将各算法对应的校验和请求头定义为可选的数据完整性检查,并明确规定:仅当请求中提供了校验和时,响应头才会返回对应的校验和字段。
- AWS UploadPartCopy API 则规定:若创建 MPU 时指定了校验和算法,
CopyPartResult响应中将包含目标分片的校验和。由于复制请求不含数据体,该值必然由服务端计算生成。 - AWS ListParts API 用于查询未完成 MPU 中各已上传分片的校验和。
算法与校验和类型的支持矩阵如下:
| 算法 | FULL_OBJECT |
COMPOSITE |
|---|---|---|
| CRC64NVME | 支持 | 不支持 |
| CRC32 / CRC32C | 支持 | 支持 |
| SHA1 / SHA256 | 不支持 | 支持 |
FULL_OBJECT 仅支持可线性合并的 CRC 算法;而 SHA1 与 SHA256 虽不支持线性合并,仍需在每个分片中记录正确的摘要(digest),才能在 COMPOSITE 模式下完成合并校验。
在实际使用中,尽管新版 AWS SDK 默认倾向于为支持校验和的请求自动计算摘要,但用户一旦配置 request_checksum_calculation = when_required,或通过底层 API 在发起 MPU 时指定算法、而在上传分片时省略校验和请求头,就会在旧版 SILO 上触发 400 错误。
为什么不能只删除强制检查
最直观的改法是直接删掉 PutObjectPart 中的校验条件,放行不带校验和的分片写入。但这只会把报错推迟到 CompleteMultipartUpload 阶段。
SILO 在合并分片时不会重新读取并拼接所有分片的数据字节,而是直接读取每个 part.N.meta 中的 ObjectPartInfo.Checksums:
- 若分片元数据缺少对应算法的校验和,立即返回
InvalidPart; FULL_OBJECT模式调用Checksum.AddPart,按各分片字节长度线性合并 CRC;COMPOSITE模式将各分片摘要的原始字节依次拼接,再对拼接结果计算一次对象级哈希。
因此,存储层依赖的核心不变量是:
如果只删除入口检查而不补齐分片元数据,就会导致 UploadPart 表面返回成功、ListParts 缺失校验和字段、UploadPartCopy 响应缺值,最终在合并阶段因 InvalidPart 失败。
我们研究过的方案
| 方案 | 优点 | 致命问题 | 结论 |
|---|---|---|---|
| 仅删除入口强制检查 | 改动最少 | 分片元数据缺少校验和,合并阶段必然报错 | 否决 |
仅对 FULL_OBJECT 放宽限制 |
可兼容部分默认 CRC 客户端 | COMPOSITE 与 SHA1/SHA256 依然报错,无法完整修复 #46 |
否决 |
| 合并阶段重新读取所有分片计算 | 上传阶段无需保存摘要 | 引入 $O(\text{对象大小})$ 的额外磁盘与网络读取,且无法修复 ListParts 与复制响应 |
否决 |
普通 UploadPart 始终返回服务端计算值 |
易于旧版联邦转发 | 违反 AWS“仅在请求提供时回显”的响应规范 | 否决 |
| 照搬 AIStor 实现 | 有商业版先例可循 | 仅支持可线性合并的 CRC,且哈希器挂载位置在压缩/加密路径下存在读取变换后字节的风险 | 否决 |
| 在逻辑明文流上单遍计算并持久化 | 完全符合 S3 规范、无二次 I/O、同时覆盖 CRC 与 SHA | 需在内部显式分离明文校验和读取器与底层存储读取器 | 采用 |
商业版 AIStor 给了什么线索
我们对当时最新的 MinIO AIStor RELEASE.2026-08-07T18-34-35Z 二进制执行了 Go pclntab 符号还原与 ARM64 反汇编分析。静态分析显示,AIStor 已实现了以下机制:
- 当客户端未提供校验和时挂载服务端哈希计算器;
- 将计算结果写入分片元数据;
- 在
CopyPartResult结构体中新增校验和字段。
但 AIStor 的回退计算仅对满足 CanMerge() 的 CRC 算法(CRC32、CRC32C、CRC64NVME)生效,SHA1 与 SHA256 的 COMPOSITE 上传仍会触发 checksum missing 错误。更重要的是,AIStor 在对象层直接将哈希计算器挂载到当前的 r.Reader 上;而在开启服务端压缩或加密的路径中,传入对象层的 r.Reader 已经是经过压缩或加密变换后的存储流。因此,SILO 需要在更上游的明文边界挂载计算器,并同时覆盖 SHA1 与 SHA256。
设计审查如何推翻第一版设计
初版方案曾计划将所有逻辑收口到 erasureObjects.PutObjectPart:由对象层读取 MPU 元数据,发现客户端未提供校验和时,直接在传入的读取器上挂载服务端哈希计算器。
然而代码审查发现,这一方案在启用服务端压缩时会产生并发竞态与数据截断。newS2CompressReader 并非惰性包装器,其构造函数在调用瞬间就会启动后台压缩协程:
底层 S2 编码器还会并发预读多个数据块。HTTP 处理函数在创建压缩读取器之后,还需依次执行选项解析、加密封装,最后才进入对象层 PutObjectPart。等到 PutObjectPart 尝试挂载哈希计算器时,底层明文流往往已经被后台压缩协程消费了数 MiB:
- 较大分片计算出的校验和会丢失开头已读走的数据块;
- 较小分片可能在挂载前就已被全部读完(到达 EOF),导致无法生成任何校验和;
- 在后台协程并发调用
Read的同时修改ServerSideHasher字段,还会触发直接的数据竞争(data race)。
这一发现决定了最终的职责划分:
- HTTP 处理函数负责在启动任何立即消费数据的流变换(如并发压缩或加密封装)之前,在逻辑明文流上挂载哈希计算器;
- 对象层负责复核算法配置、验证校验和计算结果已就绪,并将其原子写入分片元数据。
最终实现
独立的逻辑 checksum reader
PutObjReader 原本包含两个读取器字段:
Reader:最终交付给底层存储的字节流(可能已经过压缩或加密);rawReader:供 ETag 计算等既有逻辑使用的原始读取器。
在压缩路径下,rawReader 也并非始终直接读取明文字节,有时仅通过 etag.Tagger 接口透传 ETag。为避免破坏既有 ETag 语义,我们在 PutObjReader 中新增了未导出字段:
checksumReader 始终绑定未经变换的 S3 逻辑分片明文流。后续调用 WithEncryption 包装存储流 Reader 时,会原样保留 checksumReader。
同时,PutObjReader 新增了未导出的访问方法:
- 获取最终生效的校验和类型(客户端提供或服务端补算);
- 若客户端提供了校验和,优先返回客户端校验结果;
- 若客户端未提供,则返回读取到达 EOF 时由服务端哈希器生成的计算结果。
将该字段与方法保持为包内未导出,既避免了扩大公共 Go API 表面,也为后续 #63 修复普通 CopyObject 预留了同一套底层机制。
在 transform 之前准备 hasher
辅助函数 prepareMultipartChecksumReader 负责读取 MPU 元数据中记录的算法与校验和类型:
- 若 MPU 未声明校验和算法,直接返回;
- 若客户端已提供校验和,比对其基础算法是否与 MPU 声明一致;
- 若算法不匹配,按原逻辑返回
InvalidArgument; - 若客户端未提供校验和,则在明文
hash.Reader上挂载对应算法的服务端哈希计算器。
对于普通 UploadPart:
- 压缩路径在完成请求校验和解析(
actualReader.AddChecksum)之后、调用newS2CompressReader启动并发压缩之前完成挂载; - 非压缩路径在请求校验和解析之后、构造加密读取器之前完成挂载。
对于 UploadPartCopy:
- 开启校验和的 MPU 先在源对象的目标读取区间上构造内层明文
hash.Reader; - 字节范围复制(range copy)仅对所选区间内的明文字节计算校验和;
- 内层读取器挂载就绪后,再依次包裹压缩与目标端加密流。
对象层仍然是最终权威
在 HTTP 处理函数层提前挂载哈希器并未削弱对象层的守卫职责。erasureObjects.PutObjectPart 依然执行完整校验:
- 重新解析 MPU 元数据中要求的校验和算法;
- 验证传入读取器的有效校验和类型存在且与 MPU 算法一致;
- 在纠删码编码写入完成后提取校验和结果映射;
- 若 MPU 启用了校验和但未能取到计算结果,立即记录内部错误并中止提交;
- 将校验和连同 ETag、分片大小与编号一并写入
part.N.meta,最后原子重命名提交该分片。
因此,任何绕过 HTTP 处理函数的内部调用方若未按要求准备合法校验和,仍会被对象层拦截,不会写入缺少校验和元数据的损坏分片。
CopyPart 响应
CopyObjectPartResponse 结构体新增了当前支持的五种校验和字段:
所有字段均带有 omitempty 标签,未启用校验和的 MPU 仍输出与原先完全一致的 XML。普通 UploadPart 继续仅通过 TransferChecksumHeader 回显客户端传入的校验和请求头,服务端自动补算的结果不会写入响应头。
为什么这个修改能解决问题
修复后的分片上传数据流如下:
这一管线同时满足四项核心要求:
- 协议兼容: 客户端省略可选的分片校验和请求头时,上传与复制均能正常完成。
- 完整性校验不降级: 客户端提供校验和时仍执行端到端比对,错误校验值不会被服务端重算结果覆盖。
- 字节语义准确: 校验和始终针对 S3 逻辑明文字节计算,不受底层压缩或加密变换影响。
- 单遍流式处理: 哈希计算随原有的单遍读取同步完成,无需在内存中缓存完整分片,也无需在合并阶段二次读取磁盘。
此外,hash.Reader 仅在读取到达 EOF 时生成最终的 ServerSideChecksumResult。在压缩路径下,压缩管道(pipe)的关闭操作天然同步了后台压缩协程与底层存储读取;对象层在纠删码写入返回后读取校验和结果时,计算已确定完成。定向 -race 测试验证了这一并发同步边界。
兼容性基线守卫处理
CopyObjectPartResponse 新增的五个字段属于导出 Go 符号。SILO 的 buildscripts/rebrand-guard 工具会自动扫描导入路径、环境变量、HTTP 头、路由、存储标记及导出符号,并与 buildscripts/rebrand-guard/compat-baseline.json 进行双向精确集合比对;任何未登记的新增导出符号都会阻断 CI。
将 #46 新增的五个字段写入基线后,守卫脚本仍报告两项未登记符号:
排查确认,这两个符号来自 main 分支更早合入的数据库通知修复提交 f1ba68358:cmd 启动流程需要通过 errors.As 匹配该导出错误类型,但原提交漏更新了 compat-baseline.json,导致后续基于该分支的所有 PR 都会在兼容性守卫检查处失败。
我们将这两项通知错误符号与 #46 的五个响应字段一并补入基线,最终基线差异为精确的 7 项新增、0 项删除:
验证证据
新增回归测试套件共包含 76 个子测试,完整覆盖以下矩阵:
- CRC32、CRC32C 与 CRC64NVME 的
FULL_OBJECT模式; - CRC32、SHA1 与 SHA256 的
COMPOSITE模式; - 客户端提供正确校验和、错误算法与错误校验值;
- 验证服务端补算值不出现在普通
UploadPart响应头中; - 验证
UploadPartCopy响应体与ListParts正确返回服务端计算值; - 5 MiB + 1 KiB 双分片的真实对象线性合并;
- 零字节分片与同分片编号覆盖上传;
- 指定字节区间的
UploadPartCopy(验证 SHA256 仅覆盖所选区间); - 单盘与 16 盘纠删码两种存储后端;
- 普通桶、版本控制、压缩、加密,以及“压缩 + 加密”组合模式;
- 显式 SSE-C 与 SSE-S3 服务端加密。
本地验证命令:
成本、风险与滚动升级取舍
当客户端省略分片校验和时,服务端需在读取数据流时额外执行一次哈希计算。CRC 系列算法的 CPU 开销极小,SHA1/SHA256 的 CPU 开销相对较高,但均在既有的数据读取流上单遍完成,不增加全分片内存缓冲,也不产生额外磁盘 I/O。
在集群滚动升级窗口内,新旧节点对省略了校验和的分片上传请求会表现出不同行为:已升级节点正常接收,未升级节点仍返回 400 错误。由于磁盘上 ObjectPartInfo.Checksums 的存储格式并未改变,已落盘数据完全向后兼容;待集群所有节点完成升级后,客户端可见行为即全面收敛。
为什么拆出两个独立后续
代码审查期间还识别出两项同源但边界独立的问题,分别拆分为独立议题跟进:
#63:CopyObject + Compression
普通 CopyObject 在启用服务端校验和与服务端压缩时,哈希计算器同样可能被挂载到压缩变换之后的字节流上。该问题与 #46 共享根因及新增的 checksumReader 机制,但涉及不同的 API 入口与测试矩阵,因此在 #63 中复用同一明文读取器契约独立修复。
#64:Legacy Federation
旧式 etcd 联邦代理会将 UploadPartCopy 转换为发往远端集群的普通 UploadPart 请求。由于普通 UploadPart 遵循 AWS 规范不在响应头中返回服务端补算的校验和,代理节点无法直接从远端 UploadPart 响应头中拿到填充 CopyPartResult 所需的校验和。#64 在不破坏外部 UploadPart 响应合规性的前提下,单独处理联邦代理路径的校验和获取。
这两项后续修复均遵循本次确立的流式处理契约:
所有由服务端计算的 S3 校验和都必须绑定在逻辑明文流上,在启动任何并发或流式变换之前完成挂载,并由对象层在落盘前复核与持久化。
工程要点总结
- 客户端可选输入不代表服务端可省略内部状态。 当协议允许客户端省略分片校验和时,服务端必须在写入阶段自行补算,以满足合并阶段的元数据不变量。
- 区分请求处理与响应回显规则。 普通
UploadPart可在内部补算校验和,但按 S3 规范不得在响应头中回显该值;而UploadPartCopy则必须在响应体中返回服务端计算值。 - 流式管线的挂载位置取决于字节语义与并发时机。 哈希计算器必须挂载在未经压缩和加密的逻辑明文流上,且必须在
newS2CompressReader启动并发预读协程之前完成安装。 - 外部实现反汇编仅作参考,不能替代完整路径验证。 AIStor 二进制分析验证了服务端补算的方向,但也暴露了仅覆盖 CRC 以及在对象层挂载流读取器的局限。
- 通过符号基线守卫显式管理导出表面。
compat-baseline.json的双向精确比对确保每次导出符号变更都有明确的提交归属。 - 共享底层不变量,按独立边界拆分交付。 #63 与 #64 复用本文建立的
checksumReader契约,但各自保持独立的测试矩阵与回归边界。
4.36 - BadDigest、InvalidRequest 与 CompleteMultipartUpload 校验和契约
#48 与 #50 的修复已随 Server 20260903 发布,流式校验和后续修复(PR #143)已随 Server 20260916 发布。本文取代八月早期提案中“暂时保留 CRC64NVME 静默规范化”的结论。
20261018:消息文字与提交失败
Server 20261018 包含 #225、#229 与 #238 的消息格式修复:Content-MD5 大小写对齐 S3 规范、PutObject 与 UploadPart 校验失败时显示已知算法名,以及移除 CompleteMultipartUpload 错误消息中重复的 "checksum" 字样。未知算法仍保留通用描述,各操作对应的 S3 错误码与校验条件保持不变。下文提及的“保持 UploadPart 错误映射器不变”对应八月修复范围;十月版本更新了其消息文本生成。客户端应依据 HTTP 状态码与 S3 错误码判断失败原因,而非解析完整的英文消息字符串。
此外,#232 将最终提交阶段暂存 xl.meta 丢失归类为 503 SlowDownWrite。该状态与下表中客户端提供错误校验和导致的 HTTP 400 不同,也不改变请求体不完整时的原有处理逻辑。详见 S3 写入提交错误参考。
为什么必须在完成阶段执行校验
CreateMultipartUpload 在初始化上传时确定校验和算法与对象校验和类型,每次 UploadPart 调用分别记录对应分片的校验和。CompleteMultipartUpload 必须依据已保存的上传契约,同时校验最终对象摘要与任何显式声明的校验和类型断言:请求既不能因为基础算法相同而在完成阶段将 COMPOSITE 篡改为 FULL_OBJECT,也不能通过省略最终摘要来绕过不匹配的类型断言。
修复方案严格区分“省略校验和类型”与“显式声明校验和类型”,仅规范化内部表示标志;同时为分片完成路径引入操作专用错误类型,保持 UploadPart 及全局校验和不匹配错误映射器的原有行为不变。
当前错误契约
下表列出的所有校验失败均返回 HTTP 400,且不会提交新的已完成对象。
| 请求条件 | S3 错误码 |
|---|---|
整对象(FULL_OBJECT)或组合对象(COMPOSITE)摘要不匹配 |
BadDigest |
| 显式声明的合法类型与初始化类型不一致(包括仅提供类型的断言) | BadDigest |
| 未知或小写的校验和类型 token | InvalidArgument |
| 完成阶段提供的校验和算法与初始化算法不一致 | InvalidArgument |
| 缺少组合校验和所需的分片校验和 | InvalidRequest(指明算法与分片编号) |
初始化时将 CRC64NVME 与 COMPOSITE 组合 |
InvalidArgument |
完成时同时提供 CRC64NVME 摘要与 COMPOSITE 类型(在解析阶段被拒绝) |
InvalidArgument |
对 CRC64NVME FULL_OBJECT 上传在完成时仅断言 COMPOSITE 类型 |
BadDigest |
UploadPart 期间客户端提供的分片校验和错误 |
XAmzContentChecksumMismatch |
对于 FULL_OBJECT 上传,完成请求 XML 中可以省略分片级校验和,但凡是显式提供的分片校验和仍会与已存储的分片元数据逐一比对。与初始化类型一致的纯类型断言允许通过;省略可选的校验和类型请求头不会隐式断言 COMPOSITE。将 SHA1 或 SHA256 与 FULL_OBJECT 组合的请求会在算法与类型解析阶段直接被拒绝,不会进入已存储类型比对阶段。
CRC64NVME 决策与源码证据
早期评审曾考虑在收集更多 SDK 兼容性证据前暂缓处理 #50(此前代码会将 CRC64NVME + COMPOSITE 静默规范化为 FULL_OBJECT)。然而静默规范化会掩盖非法的客户端请求,违背快速失败原则。PR #93 在请求头与 trailer 解析路径中直接拒绝 CRC64NVME + COMPOSITE 组合,PR #96 则移除了 CompleteMultipartUpload 阶段的静默规范化。由于完成请求既可能在请求头解析阶段失败(同时携带 CRC64NVME 摘要与 COMPOSITE 类型),也可能在已存储类型比对阶段失败(对已初始化为 CRC64NVME FULL_OBJECT 的上传仅发送裸 COMPOSITE 类型请求头),上表分别记录了对应的两种错误码(InvalidArgument 与 BadDigest)。
最初的完成阶段错误映射由 PR #74 引入,显式类型断言后续修复(7e079ff05)经由 PR #85 合入。cmd/erasure-multipart-fullobject_test.go 中的处理器测试覆盖了整对象与组合摘要不匹配、纯类型断言、非法 token 以及 CRC64NVME 的两个拒绝阶段。
独立的流式校验和后续修复
PR #143 修复了 @cbornet 报告的 #107,专门处理 AWS Java SDK v2 使用的混合请求模式:aws-chunked 流式请求在 x-amz-trailer 中声明了校验和请求头,但实际通过普通 HTTP 请求头直接发送该校验和值。该修复同时拒绝格式非法的请求头校验和值,防止因解析失败而静默跳过校验。此项修复已随 Server 20260916 发布,与分片完成阶段的错误映射相互独立。
兼容性与剩余边界
检查 S3 错误码的客户端在完成阶段遇到摘要或类型不匹配时会收到 BadDigest,遇到缺失组合分片校验和时会收到 InvalidRequest。不带校验和的常规分片上传与 ETag 语义保持不变,无需迁移磁盘元数据。相邻的分片上传校验契约详见 CompleteMultipartUpload ChecksumType 响应契约、UploadPart 校验和设计 与 重复分片编号安全公告。
目前仍保留三项较窄的边界行为:
- 若
CreateMultipartUpload初始化时未记录任何校验和算法,但在完成阶段提供了最终校验和摘要,请求会以BadDigest拒绝。 - 组合校验和的分片数量不匹配与摘要值不匹配共用同一条
BadDigest错误描述。 - 附加在
FULL_OBJECT校验和请求头末尾的-N分片数后缀会被忽略,不作为分片数断言进行校验。
4.37 - 旧版元数据与扫描响应复用时的残留字段修复
本文记录 PR #233(验证提交 86c5a92eef3d,合并提交 d94b3b3c9251,随 Server 20261018 发布)的设计与验证。该补丁修复了旧版 xl.meta 版本头部与远程命名空间扫描响应在复用结构体时带入残留字段的问题。
问题概述
两处独立代码路径在复用 Go 结构体时未完整重置其逻辑状态,导致新输入中未出现的字段沿用了上一轮留下的旧值。修复直接落在明确知晓当前输入字段布局的解码器与响应构造处:
| 路径 | 触发条件 | 修复前状态 | 修复后状态 |
|---|---|---|---|
| 旧版 V2 版本头部 | 将旧版头部解码至 EC 字段非零的复用结构体 | EcN 归零,EcM 残留旧值 |
两个缺失的 EC 字段均归零 |
| 旧版 V1 版本头部 | 复用带有签名或 EC 字段的结构体 | V1 布局中不存在的字段继续残留 | Signature、EcN、EcM 均归零 |
| 扫描进度响应 | 复用上一轮携带最终缓存的响应信封 | 同时包含新 Update 与旧 Final |
仅包含 Update |
| 扫描最终响应 | 复用上一轮携带进度更新的响应信封 | 同时包含旧 Update 与新 Final |
仅包含 Final |
旧版 xl.meta 版本头部
xl.meta 内部的紧凑版本头部(version header)拥有独立的版本号,与外层 xl.meta 文件格式版本及 SILO 发行版本号相互独立。当前版本头部为 V3,包含纠删码分片参数 EcN 与 EcM;旧版 V2 头部仅有 5 个字段,不含这两个 EC 字段;V1 解码器对应更早的四字段布局,既无签名也无 EC 字段(源码注释标明 V1 布局从未正式发布,此处属于对保留解码路径的防御性清理)。
此前 V2 的字节切片解码入口 UnmarshalMsg 与流式解码入口 DecodeMsg 均写成了:
由于对 z.EcN 重复赋值,z.EcM 从未被清零。当调用方传入新分配的零值结构体时该缺陷不会暴露;一旦复用此前存有 EcN=4, EcM=8 的结构体,解码 V2 头部后便会得到 EcN=0, EcM=8。修复将其更正为 z.EcN, z.EcM = 0, 0,并在 V1 解码入口同步清空缺失的 Signature 字段。当前 V3 头部则正常保留线上编码中的 EC 字段。
在实际运行中,loadIndexed 会直接复用版本头部切片的底层数组空间,因此仅在新建结构体时初始化无法覆盖该路径;合入的回归测试专门针对这一切片复用路径进行了验证。具体改动见旧版解码器实现与索引头部加载及比对逻辑。
若不清零残留字段,同一份二进制头部的解码结果会随复用结构体原先的内容而变化。由于头部相等比对与法定票数(quorum)统计均包含这些字段,不同的残留值会将本应一致的头部拆入不同分组(即便 hasEC() 本身要求 EcN 与 EcM 同时非零才判定为有效 EC 布局)。
命名空间扫描响应
远程磁盘扫描通过 grid RPC 流持续发送 nsScannerResp 响应信封。按协议契约,每条消息只能在两个指针字段中二选一:Update 携带单次进度更新 dataUsageEntry,或 Final 携带扫描完成后的汇总缓存 dataUsageCache。服务端处理器的对象池会跨扫描周期复用 nsScannerResp 实例,但不会在归还时自动清空字段。
此前 NSScannerHandler 仅对当次发送所需的字段赋值,未显式清空另一个指针。当后续扫描复用到同一个信封对象时,两个指针字段可能同时非空并被一并序列化发送。而客户端接收循环对两个字段分别独立判断:Update != nil 时转发进度更新,Final != nil 时记录最终缓存。其结果是,本应只发最终汇总的响应可能夹带上一轮残留的进度更新进入用量聚合,而中间进度消息也可能夹带上一轮扫描遗留的最终缓存。
尽管扫描结束前的最后一条合法 Final 消息会覆盖客户端保存的最终缓存指针,但中间消息破坏了字段互斥不变式并在节点间传输了陈旧数据。修复在服务端构造响应时显式清空另一侧指针:
该修复作用于跨节点远程磁盘扫描响应(本地磁盘扫描直接调用内部接口,不经过该 RPC 响应信封)。相关代码见服务端扫描处理器、客户端接收逻辑与存储桶用量聚合。
为什么不在通用 RPC 对象池中调用 Recycle
原始 PR 最初尝试在通用流式 RPC 处理器的 PutResponse 中调用 Recycler.Recycle(),随后再将同一响应对象放回处理器自身的 sync.Pool。这一做法混淆了“转移底层资源所有权”与“重置结构体字段”两种语义。
以 grid.MSS 为例,其 Recycle() 方法会将内部底层 map 归还给专门的 map 池,并将外层结构体的 map 字段置为 nil。如果紧接着把外层包装对象又放回响应池,下一次 NewResponse 从池中取出该对象时就会跳过构造函数,直接向调用方返回一个无法写入的 nil map。我们编写的补充复现用例在修复前主分支上正常通过,而在引入该通用池改动后会触发失败。
因此,维护者补丁撤回了通用 PutResponse 改动以及新加的 nsScannerResp.Recycle 方法,仅保留扫描处理器构造响应时的显式字段赋值。最终合入的 internal/grid/handlers.go 与 cmd/storage-rest-common.go 相对合并前主分支保持零改动,完整保留了 grid 类型定义中的 Recycler 资源所有权契约。
验证证据
确定性回归测试
合入代码包含两项回归测试:TestXLMetaV2HeaderLegacyUnmarshalResetsEc 与 TestNSScannerHandlerClearsStaleResponses。
版本头部测试覆盖了 V2 字节切片解码、V2 流式解码、loadIndexed 切片复用、V1 残留字段清零以及当前 V3 格式的 EC 字段保留。扫描器测试连续三轮调用真实 NSScannerHandler,在消息经序列化与反序列化后逐条校验两类响应的互斥性及各轮扫描数据归属。
值得强调的是,测试中通过池构造函数新分配的每个响应对象都会预先填入非空的旧 Update 与 Final 指针。这是因为 Go 的 sync.Pool 随时可能回收池中缓存的对象;如果测试仅依赖“上一轮归还的脏对象一定会在下一轮被取出”,在垃圾回收介入时就会退化为分配全新零值对象,导致测试在未触发缺陷的情况下假性通过。直接在构造函数中预置脏字段保证了测试的确定性:该用例在修复前主分支(0d3ba5d4cbfd)上稳定失败,在应用修复后稳定通过。
分布式集群扫描实测
端到端验证在本地回环网络上启动了两个服务进程、共八个数据目录的分布式实例,创建 12 个存储桶,每桶先写入两个不同大小的对象,随后在每个桶内执行删除一个对象、覆盖一个对象、新增一个对象的组合操作。
| 检查点 | 对象总数 | 逻辑总字节数 | 验证结果 |
|---|---|---|---|
| 首轮写入后 | 24 | 64,740 | 连续三次扫描刷新后,各桶及集群汇总用量均完全匹配 |
| 删除、覆盖与新增后 | 24 | 111,960 | 连续三次扫描刷新后,各桶及集群汇总用量均完全匹配 |
| 对象回读校验 | 24 | 111,960 | 全部 24 个对象的字节长度与 SHA-256 摘要完全一致 |
两个节点均产生了真实的存储桶扫描记录,其中非协调节点通过远程 RPC 流累计执行了 119 次 ScanBucketDrive 操作。
远端 CI 检查
PR 分支提交 86c5a92eef3d 通过了全部 8 项 CI 检查:DCO、6 项 Go CI 任务以及 VulnCheck。合入主分支后,主干上的 6 项 Go CI 任务与 VulnCheck 亦全部独立通过。
兼容性与运维指引
本次修复仅修正既有解码器与扫描响应构造处的字段赋值,不涉及存储格式、RPC 协议字段、依赖版本、IAM 策略或配置开关变更,无需重写存量对象或手动清理用量缓存。
- 将集群节点升级至 Server 20261018 或更高版本。 节点间线协议完全兼容,可按常规流程滚动升级;跨组件版本配套要求参见组件版本矩阵。
- 等待后台扫描自然收敛。 可通过
mcli admin scanner status ALIAS -n 1查看扫描进度,待完整扫描周期结束后核对各存储桶的对象计数与逻辑容量。 - 无需人工干预底层元数据。 切勿手动编辑磁盘上的
xl.meta、删除内部缓存对象或批量重写数据;常规后台扫描周期会自动刷新派生的用量统计。
致谢与交付记录
感谢 Julien Laurenceau 提交初始修复与测试用例。维护者在此基础上收窄了对象池改动范围,保持了 grid.Recycler 的资源所有权契约,并补充了确定性脏分配回归测试;原作者提交记录与 DCO 签名完整保留在合并历史中。
4.38 - LIST 漏项修复:仅对存疑对象补读确认并收敛锁等待开销
随 SILO Server 20261018 发布的 PR #239(合并提交 fbcb51197,关闭 #218 并保留 mr javad seydi 在 #221 中的原始签署提交)以及 10 月 18 日的目录与公共前缀跟进修复(3dd53eb81 与 2080b3d03),解决了并发读写期间 ListObjects 与 ListObjectsV2 静默漏报对象的问题。本设计承接九月的 null 版本仲裁修复,并替代了早期“直接调用普通 GET 元数据路径”的恢复方案。
成功的目录列举可能漏掉真实存在的对象
假设桶中存在 reports/a.csv,客户端可以通过 GET 正常下载,但 LIST 请求却返回 HTTP 200 OK 且结果中不包含该文件名。备份与资产清点工具通常将成功的列表响应视为权威输入,从而误报文件缺失;若同步工具开启了“删除源列表不存在的目标端对象”,甚至会误删目标端的有效副本。相比明确返回错误,一个返回 HTTP 200 但漏项的列表往往会造成更严重的下游破坏。
LIST 会收集不同磁盘在不同时刻看到的目录与元数据记录。在覆盖写、删除、滚动重启或跨池迁移期间,部分磁盘返回旧记录,部分返回新记录,还有部分暂时没有记录。达成列举法定人数(quorum)要求足够多的磁盘提供彼此相容的证据;不同代际或不同纠删码布局的记录不能简单将票数相加。此前,当某条目录记录无法形成法定人数结论时,旧路径会直接跳过该记录,而整个 LIST 请求依然返回成功。
本修复针对 ListObjects 与 ListObjectsV2(例如 GET /bucket?list-type=2&prefix=reports/),与 #79 的 ListMultipartUploads 分属不同接口。修复不新增 prefix=true、环境变量或任何客户端参数。
选定的恢复设计
列举流水线首先执行原有的目录合并逻辑,已达成仲裁的条目直接沿快路径返回。仅当条目属于只列举最新版本、存在至少一条可解码对象记录、且“可解码记录数加失败读取流数”达到原始列举阈值但仍未决时,才进入恢复流程。失败的读取流仅作为触发进一步确认的依据,绝不计作对象存在或满足读仲裁的证据。低于入口阈值的输入以及完整版本历史列举(ListObjectVersions)保持原有行为。
符合恢复条件的未决对象名会被传递至存储池汇总层(pool merger),按完整同名组统一确认。汇总层先获取该对象名的读锁以阻止并发同名修改打断确认过程,随后重新读取当前磁盘元数据。确认结果分为三类:
- 确认存在: 按对象实际的纠删码布局和原有读仲裁规则选出当前版本,并保留删除标记直至跨池合并阶段再行过滤旧副本。
- 确认已删除: 来源存储组(source set)的每一个配置盘位都必须在本次原始读取中返回
FileNotFound,且汇总层还须核对每个存储池当前应存放该对象名的目标存储组(target set)均无存活对象后,才能从列表结果中省略该名称。 - 仍无法确认: 返回既有的失败响应(例如 HTTP 503
SlowDownRead),确保客户端不会将不确定的状态误认为完整清单。离线盘、损坏元数据、缺失卷与解码失败均不得当作“已删除”处理。
流式分页仍可在全桶扫描结束前先返回已满的页面。客户端必须读取所有后续分页;若任一分页失败,此前已收到的页面均不能视作完整桶清单。
异步缓存清理同样保留原始请求身份:提交 2a4db6a4f 在启动后台清理任务前捕获缓存节点并按值传递列举选项,防止前台重置缓存 ID 时与后台清理任务发生竞态或向 RPC 发送空 ID。缓存格式与分页令牌编码均保持不变。
为何确认来源存储组不存在后还要检查其他存储池?假设在双池集群中,池 A 的旧目录扫描包含 a.csv,而池 B 先前的目录扫描为空;随后跨池迁移将对象复制到池 B 并删除了池 A 的副本。如果仅因池 A 当前为空就省略该名称,就会漏掉池 B 中的当前对象。因此在确认来源存储组严格为空后,汇总层会查询各池当前的目标存储组,并用最新结果替换整个同名旧结果组。同一次恢复中,每个不同的存储组最多补读一次。
正常并发 DELETE 是另一个典型反例:假设 16 个目录输入中有 6 份旧记录、6 份新记录、4 份无记录,目录扫描本身无法达成仲裁;但此时 DELETE 已返回 204、GET 已返回 404,且当前 16 块磁盘均明确返回 FileNotFound。#239 的早期版本曾将这种健康的删除后状态也判定为未决并返回 503。最终方案通过核对全盘原生 FileNotFound 准确放行正常删除,同时对真正的磁盘或解码故障保持失败关闭。
对象缺失不得吞掉同名目录
10 月 18 日合入的 3dd53eb81 修复了同名组中对象已确认不存在、但同名目录条目依然有效的场景:仅移除缺失的对象,保留目录(公共前缀)。后续 2080b3d03 覆盖了待恢复对象、普通对象与目录混合出现时的排序处理:重复目录折叠为一个公共前缀,仍存活的对象保持对象结果,避免将“确认对象不存在”误扩大为“整组目录不存在”。
内存元数据表示
本修复不新增任何持久化文件、xl.meta 字段、磁盘索引或迁移步骤,仅在内存 metacache 条目结构体中增加一个指向来源存储组的指针:
“待确认”状态仅在进程内部通道中流转,由池汇总层消费。两个缓存编码入口均显式拒绝尚未确认的条目,listingSet 绝不会写入分页缓存或发送给客户端,因此磁盘布局与网络编码完全不变。
补读使用只读元数据辅助函数,复用既有的版本选择与读仲裁规则,避免触发完整 GET 路径中的机会性修复(healing)或残留清理副作用。此外还包含一项窄范围的内存重计数逻辑:仅当原始选择失败、且最新 null 版本具有已知纠删码布局时,按相容的纠删码布局分组重新对照原始阈值统计票数,既不降低仲裁阈值,也不混加不同布局的票数。
各子系统具体改动
| 位置 | 改动及原因 |
|---|---|
| 目录解析与跨池汇总 | 将待确认名称传递至完整同名结果组并在汇总层统一补读,防止单池私自裁决导致漏项或旧副本复活。 |
| 元数据读取 | 区分原生的“全盘均不存在(FileNotFound)”与读取/解码故障;复用版本与读仲裁规则,避免引入 GET 的写副作用。 |
| 对象锁 | 多池场景沿用池级锁;单池多存储组场景按固定顺序获取首组与目标组读锁,同时覆盖 DELETE/COPY 与 PUT 的锁域;相同锁域只获取一次。 |
| 分页缓存 | 先发布列举的终态错误再结束缓存,使 HTTP 错误响应无需等待失败的远端缓存更新;远端更新失败时保留本地原缓存 ID 与错误。 |
| 读取待生成缓存块 | 读取失败后及时检查生产端状态,每次重试仅轮转探测一块在线盘,探测成功后再发起完整仲裁读取;正常读取不增加额外状态 RPC。 |
| 取消与锁竞争 | 允许上下文取消打断锁重试等待;仅对可明确识别的 LIST 恢复锁冲突提供一次 2 毫秒短等待与重检,降低短暂读锁触发长退避的概率。 |
| 回归测试与 CI | 在 -race 下覆盖真实删除、跨池迁移时序、双存储组锁路由、分页终止、失败 RPC、请求取消及迟到锁授予。 |
底层目录遍历回调保留原有函数签名。由于恢复错误统一由汇总层传播,扫描器、自愈、再平衡(rebalance)与退役(decommission)调用方无需修改接口。
当单池恢复需要跨两个不同锁域加锁时,持有第一把读锁等待第二把读锁期间可能会短暂延迟首组路由上的 DELETE 或 COPY 请求。恢复流程会在编码结果、发送通道与发起缓存 RPC 之前释放锁,避免慢客户端拖长持锁时间。若第二次加锁失败或请求取消,会立即释放第一把锁。
评估与否决的替代方案
| 候选方案 | 优点 | 否决原因 |
|---|---|---|
| 保持现状 | 零新增运行时开销 | 仍可能在 HTTP 200 响应中静默漏项,导致备份与同步工具误判。 |
| 遇到分歧一律返回 503 | 补丁最小,无需补读元数据 | 一次补读即可确认的对象也会报错;正常并发删除后也会反复返回 503。 |
| 对每个列举对象都加锁并补读 | 规则简单统一 | 将磁盘读取和锁开销强加给所有 LIST 请求,严重拖慢大桶列举与并发写入。 |
| 仅在来源池确认,或不加锁确认 | 减少锁获取或跨池查询次数 | 并发覆盖写、删除与跨池迁移仍可打断确认过程,实测反例证明依然会漏项。 |
| 全局调低所有写请求的重试间隔 | 可降低部分锁竞争长尾 | 影响面扩大到全局写路径,显著增加集群锁 RPC 流量与竞争。 |
| 对符合条件的分歧按需确认,配合局部短等待(已选定) | 正常路径保持轻量;可恢复时返回准确结果,不可确认时明确报错 | 分歧路径需承担实际补读与锁等待开销,实现与验证复杂度高于直接报错。 |
运行时开销分析
| 触发条件 | 额外代价 |
|---|---|
| 正常列举条目 | 在 64 位构建中,单个内存条目从 56 字节增至 64 字节(新增一个 8 字节指针)及少量分支判断;无额外对象读锁或磁盘补读。 |
| 单个名称进入恢复 | 对待确认来源存储组并发补读磁盘元数据;单池跨锁域需获取两次读锁,多池使用一次池级锁。 |
| 来源存储组明确不存在 | 进一步查询每个池的当前目标存储组(复用已读结果),读取上限为来源组与目标组的去重并集,而非扫描全桶。 |
| 大量名称同时需要恢复 | 同名组按目录字典序串行确认,高比例分歧会拉长整次扫描耗时。 |
| 恢复读锁与写锁冲突 | 每次识别出的恢复锁冲突 RPC 最多在互斥锁外名义等待 2 毫秒后重检一次,每个本地锁管理器共享最多 64 个并发等待名额(每次等待约增加 264 字节、4 次分配)。 |
| 锁获取失败后的普通退避 | 为支持上下文取消,将休眠改为计时器与 select,仅在初次取锁失败需要退避时增加少量分配;一次加锁成功的快路径无此开销。 |
以内存中同时驻留 1,000 个条目计算,新增指针字段本身约占用 8 KiB。单个对象名的恢复使用 5 秒上下文超时预算,取消后若有部分或迟到的锁授予也会立即清理。
验证证据与基准测试边界
本地 make test 通过全部 1,773 项顶层测试,且针对目录恢复、分页缓存、锁路由、分布式与本地锁取消及迟到授予的专项 -race 测试全部通过。在提交 87c1c6b3d 上,8 项 PR 检查全部通过(包括 Go CI、DCO、漏洞检查 与快照构建流水线)。
针对主干 7e9454456 的独立 10 轮配对基准测试(2 轮相同主干 A/A 对照 + 4 组交替顺序的主干/候选配对;每轮预热 20 秒、测量 120 秒,采用 8 进程、2 存储池、单块 NVMe 上的 32 个逻辑盘目录与回环网络小对象固定速率负载)共完成 22,410 次完整扫描,期间零漏项、零重复、零内容损坏、健康状态下零 HTTP 错误:
| 指标 | 候选 / 主干配对 p95 变化 | 95% 置信区间 |
|---|---|---|
完整 LIST 页面 |
+0.82% | −3.53% ~ +5.36% |
同名 DELETE |
−0.38% | −1.80% ~ +1.05% |
不同名 DELETE |
−0.55% | −4.74% ~ +3.84% |
次要指标配对变化如下:完整分页扫描 p95 约 +0.47%,PUT p95 约 +2.82%,GET p95 约 −0.51%,每页上限 7 条的中间续页 p95 约 +12.60%,同名 DELETE p99 约 +18.95%(候选版本出现一次 327.248 毫秒的同名 DELETE 峰值,而额外 A/A 基线对照中也曾出现 148.186 毫秒样本)。8 进程总 CPU 配对变化约 −0.07%,RSS 合计变化约 +0.11%。由于完整 LIST 页面 p95 的 95% 置信区间上界为 +5.36%(略超严格的 5% 门禁线),且两轮相同主干的不同名 DELETE p95 自身存在约 12% 波动,严格的“不超过 5% 退化”统计门禁记录为待定(inconclusive),而点估计表明主要稳态指标变化极小。
同一候选二进制还完成了 8 进程逐一优雅重启的滚动验证:负载期间完成 5,235 次完整扫描(测量窗口内 4,754 次),零漏项、零重复、零内容损坏;139 次失败 HTTP 请求全部落在对应端点的停机至就绪窗口内;最终 80 个对象状态、8 个端点在分页上限 1/7/1000 下的分页检查、416 次预期存在的 GET 与 512 次预期不存在的 GET 均与最后确认的写入或删除一致。
兼容性
S3 请求参数、分页令牌编码、磁盘存储格式、配置项与客户端调用方式完全保持不变。无需重写存量对象或启用新配置开关。进入恢复流程但因故障仍无法确认的条目会使 LIST 返回既有错误码(如 503 SlowDownRead),以此替代原先漏项却返回 HTTP 200 的行为。降级服务端无需转换数据。
4.39 - 对象锁保留期越权与版本覆写漏洞修复设计(SN-2026-015 / SN-2026-016)
本文档完整记录针对外部报告与内部关联排查发现的两处对象锁(Object Lock / WORM)安全缺陷——**SN-2026-015(PutObjectRetention 绕过请求头越权与条件策略失灵)**与 SN-2026-016(版本寻址写入覆写绕过及写路径锁缺口)——的事实核验、Git 历史溯源、修复设计、关键路径影响评估与候选方案的权衡取舍。完整修复(#234、#235、#241)已随 Server 20261018 正式发布。
关联公开安全公告:
1. 问题确认与外部报告核验
1.1 两条独立外部报告来源
本次修复覆盖两条独立报告链及一次内部同源排查:
- 首报(F2 主路径):由外部安全研究者 szopi00 通过 GitHub Security Advisory 私密报告(GHSA-67mv-7hc9-wj88),指出持有
s3:PutObjectRetention但无s3:BypassGovernanceRetention的主体可通过发送原始x-amz-bypass-governance-retention: true请求头缩短未到期的GOVERNANCE保留期。 - 独立外部报告(F1 / F2 / F3):另一份外部报告独立复现了三类攻击向量:未经授权将
GOVERNANCE改为多年COMPLIANCE、绕过基于请求体条件键的s3:PutObjectRetentionDeny 策略,以及无 bypass 授权缩短未到期的GOVERNANCE保留期。 - 内部同源关联排查(
SN-2026-016/ F4 与目录对象null版本覆写):在收到上述报告后,我们对“拥有写权限但无 bypass 权限的调用者还能通过哪些入口破坏受锁版本”进行了全量写路径审计,定位到非可信复制请求携带?versionId=原地覆写历史版本、目录键dir/隐式null版本覆写以及已持久化目标版本的分段上传完成三条同源缺口。
1.2 独立报告逐项核验结论
以下核验与代码行号针对修复前的 bcb5fc956 快照:
| 邮件报告论断 | 核验结果 | 代码证据与定性说明 |
|---|---|---|
危害 1(未经授权设置 COMPLIANCE 保留期):无 s3:BypassGovernanceRetention(甚至无 s3:PutObjectRetention 或被条件 Deny)的攻击者可将 GOVERNANCE 改为 COMPLIANCE 或直接写入超长 COMPLIANCE |
完全属实 | cmd/bucket-object-lock.go 第 205 行仅检查原始请求头 !byPassSet;cmd/auth-handler.go 第 773–781 行在目标模式为 COMPLIANCE 时跳过 IAM 求值,保持 byPassSet == true,并于 if byPassSet || retSet 直接返回 ErrNone。一旦写成 COMPLIANCE,即使 S3 root 管理员也无法通过 S3 API 在到期前删除 |
危害 2(削弱保留期 / 勒索软件擦除前置):无 s3:BypassGovernanceRetention 的凭据可携带请求头缩短未到期 GOVERNANCE 保留期 |
完全属实 | 请求体模式为 GOVERNANCE 时,虽然 isPutRetentionAllowed 把 byPassSet 更新为 false,但紧接着求值 retSet(s3:PutObjectRetention)为 true,末尾 byPassSet || retSet 依然放行 |
SILO 与上游 MinIO 在 DeleteObjectVersion 上的爆炸半径差异:SILO 限制了无 s3:DeleteObjectVersion 时的历史版本删除;上游 MinIO 将 s3:DeleteObjectVersion 设为 DenyOnly: true,仅凭 s3:DeleteObject 即可等锁到期后删空历史版本 |
完全属实 | SILO 在提交 75a6734e4(2026-09-02)引入 deleteObjectAction 显式要求 s3:DeleteObjectVersion;而上游 MinIO cmd/auth-handler.go 至今对指定 versionId 的删除使用 DenyOnly: true |
受影响范围与代码行号:缺陷位于服务端 cmd/ 而非 silo-pkg;SILO RELEASE.2026-09-03T13-18-01Z(9b11dc94)、RELEASE.2026-09-16T00-00-00Z、修复前的 SILO main(bcb5fc956)及当时核验的上游 minio/minio 快照均受影响 |
完全属实 | silo-pkg 的 IAM 策略引擎对 Deny 求值完全正确;问题纯粹出在 cmd/object-handlers.go、cmd/bucket-object-lock.go 与 cmd/auth-handler.go 的调用编排与布尔短路逻辑 |
复现脚本中无法用无条件 Deny s3:PutObjectRetention 规避:若对应用账号无条件 Deny s3:PutObjectRetention,则连向配置了默认保留期(Default Retention)的桶执行普通 PutObject 也会失败 |
完全属实 | 普通 PutObject 在相关对象锁检查路径中依赖 s3:PutObjectRetention,无条件 Deny 会阻断正常业务写入,因此生产策略只能使用带条件键的 Deny——而这正是被 F3 绕过的场景 |
2. 缺陷全景与 Git 历史溯源
2.1 SN-2026-015:PutObjectRetention 鉴权短路与绕过漏洞(F1 / F2 / F3)
在修复前的实现中,PUT /{bucket}/{object}?retention 存在两处结构性错位:
- 处理器入口只做签名认证,不做策略鉴权:
PutObjectRetentionHandler调用authenticateRequest(ctx, r, policy.PutObjectRetentionAction),该函数只验证签名/令牌有效性,不查询 IAM 策略; - 存量锁绕过检查与 IAM 策略裁决混杂且布尔方向写反:在对象写锁回调
enforceRetentionBypassForPut及辅助函数isPutRetentionAllowed中,服务端直接信任了客户端请求头x-amz-bypass-governance-retention: true,并用byPassSet || retSet将“绕过权限”与“写保留期基础权限”做逻辑或(||)短路。
由此产生三类具体漏洞(F1–F3)及一处清除保留期时的剩余天数计算缺陷:
| 编号 | 攻击者拥有的权限 | 触发方式与安全后果 |
|---|---|---|
| F1 | 仅通过身份认证、没有任何对象锁权限(无 s3:PutObjectRetention、无 s3:BypassGovernanceRetention) |
携带 x-amz-bypass-governance-retention: true 发送:① 空 <Retention/> 直接清零未到期的 GOVERNANCE 保留期;② <Mode>COMPLIANCE</Mode> 将任意对象(含已有 GOVERNANCE 锁的对象)直接设为到期前不可解除的 COMPLIANCE 保留期 |
| F2 | 持有 s3:PutObjectRetention、但没有 s3:BypassGovernanceRetention(两份报告均覆盖) |
携带 x-amz-bypass-governance-retention: true 将未到期的 GOVERNANCE 保留期缩短至几秒后,或原地改为 COMPLIANCE |
| F3 | 持有 s3:PutObjectRetention(受条件 Deny 限制)或持有 s3:BypassGovernanceRetention |
① 目标模式为 COMPLIANCE 或空 <Retention/> 时,携带 bypass 请求头直接跳过 isPutRetentionAllowed 中的策略求值,完全绕过挂在 s3:PutObjectRetention 上的条件 Deny(独立报告症状 2);② 持有 s3:BypassGovernanceRetention 时,在 GOVERNANCE 模式下同样因 byPassSet || retSet 绕过 s3:PutObjectRetention 条件 Deny |
| 附带算术缺陷 | 持有受 s3:object-lock-remaining-retention-days 条件限制的 s3:PutObjectRetention |
发送空 <Retention/> 清除保留期时,RetainUntilDate 为零值 0001-01-01,旧代码 math.Abs(objRetention.RetainUntilDate.Sub(t).Hours()) 因 time.Duration 292 年上限截断后取绝对值算出 106,752 天,导致“禁止剩余保留期小于 N 天”的 NumericLessThan 策略被清除操作直接绕过 |
精确 Git 提交溯源
- 2019-11 至 2020-03 的正确初始设计:
- 2020-04 引入核心缺陷(上游 #9259,
43a3778b4,2020-04-06,Harshavardhana):- 为支持来自 XML 请求体的三个锁条件键(
s3:object-lock-mode、s3:object-lock-retain-until-date、s3:object-lock-remaining-retention-days),该提交删掉了 handler 入口的checkRequestAuthType,将鉴权下沉到enforceRetentionBypassForPut内部的新函数isPutRetentionAllowed,一举引入三处错误:enforceRetentionBypassForPut直接读取原始请求头byPassSet := IsObjectLockGovernanceBypassSet(r.Header),并用if !byPassSet包裹防缩短守卫;isPutRetentionAllowed仅在if retMode == objectlock.RetGovernance && byPassSet时才向 IAM 求值s3:BypassGovernanceRetention,导致retMode == COMPLIANCE时byPassSet保持原始请求头的true不变;- 函数末尾写成
if byPassSet || retSet { return ErrNone },造成||短路。
- 为支持来自 XML 请求体的三个锁条件键(
- 2020-05 扩大 F1 爆炸半径(上游 #9677,
bede525dc,2020-05-22,Ashish Kumar Sinha):- 允许发送空
<Retention/>(Mode == "")清除GOVERNANCE锁,同样落在retMode != RetGovernance分支,使零权限攻击者不但能锁死COMPLIANCE,还能直接清零GOVERNANCE锁,同时引入了零值日期math.Abs算出106,752天的算术缺陷。
- 允许发送空
- 2025-02 临时遮蔽 F1 与 2025-04 机械回归:
- 上游 #20929(
437dd4e32,2025-02-12,Ramon de Klein)发现零权限用户可调用PutObjectRetention,于是在 handler 入口加回不带条件键的checkRequestAuthType(ctx, r, policy.PutObjectRetentionAction, bucket, object),临时挡住了 F1(但破坏了带锁条件键的Allow策略,且完全未修 F2、F3)。 - 仅 50 天后,上游 #21103(
8c7097528,2025-04-03,Harshavardhana)在重构流式签名验证时,把入口的checkRequestAuthType机械替换为只验签名、不查策略的authenticateRequest,却保留了上方的旧注释// Check permissions to perform this object retention operation,导致 F1 重新彻底暴露。SILO 分叉自该提交之后,完整继承了 F1–F3。
- 上游 #20929(
2.2 SN-2026-016:版本寻址写入覆写绕过及写路径锁缺口(F4 与同源路径)
标准 S3 协议中,公开对象写入操作(PutObject、CopyObject、CreateMultipartUpload、PostPolicy)只允许服务端生成新版本号(或在未开启/暂停版本控制时写入 null 版本),绝不允许客户端指定目标 versionId 去原地改写既有历史版本。然而现有实现存在三处同源写路径缺口:
| 路径编号 | 触发方式 | 安全后果 |
|---|---|---|
F4(显式目标 versionId 写入) |
普通 s3:PutObject 主体在 PutObject、CopyObject、CreateMultipartUpload、PostPolicy 或 Snowball 归档解压(PAX minio.versionId)中传入既有版本的 versionId(UUID 或 null) |
存储层按指定 versionId 原地覆盖该版本的数据载荷与元数据。对于处于未到期 GOVERNANCE 保留期的版本,其数据与保留期被直接替换或抹除 |
同源缺口 A(目录键 dir/ 隐式 null 版本覆写与慢速请求体竞态) |
对启用了对象锁的桶中任意以 / 结尾的目录键发起普通 PUT /{bucket}/dir/(不带 ?versionId=) |
① putOpts 将目录键隐式映射为 opts.VersionID = "null" 原地覆写,而 checkPutObjectLockAllowed 通过 getOpts 读到的 opts.VersionID 为空,跳过存量锁检查,导致普通 PUT dir/ 可原地覆盖并抹除目录 null 版本的 GOVERNANCE、COMPLIANCE 保留期与 LegalHoldOn;② 即便修补了 HTTP 预检,由于预检发生在读取 HTTP 请求体之前,写入者仍可通过预检后暂停发送请求体字节流,待管理员对 dir/ 加上 COMPLIANCE 锁后再发完请求体,在存储层覆盖已加锁的 null 版本;③ 预检阶段 checkPutObjectLockAllowed 仅在 getObjectInfoFn 返回 err == nil 时检查锁状态,遇到读仲裁失败等错误时直接跳过检查放行写入 |
| 同源缺口 B(已持久化目标版本的分段上传完成) | 分段上传在 NewMultipartUpload 时将目标 fi.VersionID 持久化到 .minio.sys/multipart/.../uploadID/xl.meta,CompleteMultipartUpload 通过独立的 completeMultipartOpts 提交该版本 |
升级前已创建的遗留分段上传任务、由复制主体发起的任务、或旧上传状态恢复后与已复制落盘的同 UUID 版本发生碰撞的任务,其目标版本号保存在 uploadID/xl.meta 中且不经过 putOpts;若完成阶段不在对象写锁下对 fi.VersionID 执行存量版本查重,仅有 s3:PutObject 的调用者即可在目标版本被加上 COMPLIANCE 锁后完成上传并覆写该版本 |
精确 Git 提交溯源
- 2020-05 对象锁检查遗漏
GOVERNANCE(上游 #9513,9b3b04ece,2020-05-04):cmd/bucket-object-lock.go的checkPutObjectLockAllowed在if opts.VersionID != "" && !replicaTrusted分支中只拦截了未到期的COMPLIANCE与LegalHoldOn,漏掉了未到期的GOVERNANCE,且仅在getObjectInfoFn返回err == nil时才进入检查。 - 2020-09 为桶复制开放目标
versionId却未校验复制信任(上游 #10598,56d1b227c,2020-09-30,Poorna Krishnamoorthy):在cmd/object-api-options.go的putOpts中读取?versionId=并赋给opts.VersionID,但从未检查trustedReplication,导致任何拥有s3:PutObject的客户端都能直接寻址覆写既有版本。 - 2023-05 目录对象强制使用
null版本(上游 #17132,1d0211d39,2023-05-04,Harshavardhana):在putOpts和delOpts中对isDirObject(object)强制设置opts.VersionID = nullVersionID,但未同步更新checkPutObjectLockAllowed与存储提交阶段,留下目录对象隐式null版本锁覆写缺口。
3. 最终修复方案:要修改哪些代码、怎么改
最终修复方案由**核心方案 A(SN-2026-015)与入口 + 存储写锁双重防线 + 预检 Fail-Closed(SN-2026-016)**两部分组成,共涉及 cmd/ 下 11 个生产文件:
| 所属问题 | 修改文件 | 核心改动内容 |
|---|---|---|
| SN-2026-015 (核心方案 A,3 个文件) |
cmd/object-handlers.go |
在 PutObjectRetentionHandler 解析请求体 XML 后、读取任何桶/对象状态前,立即调用 isRetentionActionAllowed 无条件校验 s3:PutObjectRetention |
cmd/bucket-object-lock.go |
新增 isRetentionActionAllowed(修正零值日期为 "0" 天、超远日期用秒差向上取整);重写 enforceRetentionBypassForPut,仅在削弱未到期存量 GOVERNANCE 锁时同时要求 bypass 请求头与 s3:BypassGovernanceRetention 授权 |
|
cmd/auth-handler.go |
删除存在 byPassSet || retSet 短路漏洞的 isPutRetentionAllowed 函数(净删 45 行) |
|
| SN-2026-016 (入口与写锁双重防线,8 个文件) |
cmd/object-api-options.go |
在所有 HTTP 写入选项的唯一汇合点 putOpts 中,对 vid != "" && !trustedReplication 一律返回 errAuthentication(403 AccessDenied) |
cmd/batch-handlers.go |
在 BatchJobReplicateV1.writeAsArchive 中为外层 SnowballOptions 补齐 ReplicationRequest: true 标记,使服务端 batch replicate 归档复制正常通过外层复制信任校验 |
|
cmd/bucket-object-lock.go |
① 在预检 checkPutObjectLockAllowed 中补齐 isDirObject(object) 的 nullVersionID 解析,将未到期 GOVERNANCE 纳入不可覆写条件,并在 getObjectInfoFn 返回非“不存在”错误时立即 Fail-Closed;② 新增 checkObjectLockedForOverwrite 供存储写锁内复检 null 版本锁状态(同样 Fail-Closed) |
|
cmd/bucket-handlers.go |
在 PostPolicyBucketHandler 中对目录对象(isDirObject(object))补充调用 checkPutObjectLockAllowed |
|
cmd/object-multipart-handlers.go |
在 CompleteMultipartUploadHandler 调用 checkPutObjectLockAllowed 时传入实际的 trustedReplication 状态 |
|
cmd/erasure-object.go |
在 erasureObjects.putObject 与 erasureObjects.CopyObject 持有目标对象命名空间写锁(NewNSLock)期间,对目标版本为 "" 或 nullVersionID 的非复制写入调用 checkObjectLockedForOverwrite |
|
cmd/erasure-multipart.go |
在 CompleteMultipartUpload 持有目标对象写锁期间,对 null 版本调用 checkObjectLockedForOverwrite,对非空 UUID 版本执行 getObjectInfo(多池下为 replicaObjectInfo)存量版本查重 |
|
cmd/erasure-server-pool.gocmd/erasure-sets.go |
在多池模式下为公开写入与分段上传完成注入跨池 z.replicaObjectInfo,并在 CopyObject 中透传版本与复制协调选项 |
3.1 SN-2026-015 修复代码详解(核心方案 A)
(1)cmd/object-handlers.go:PutObjectRetentionHandler 入口无条件基础鉴权
在 authenticateRequest 验签并调用 ParseObjectRetention(r.Body) 解析完请求体后,在读取任何桶信息(GetBucketInfo)、桶锁配置(globalBucketObjectLockSys.Get)或对象版本(getOpts)之前,立即调用 isRetentionActionAllowed 校验 s3:PutObjectRetention:
(2)cmd/bucket-object-lock.go:isRetentionActionAllowed 与 enforceRetentionBypassForPut
抽出统一的 isRetentionActionAllowed 辅助函数,从解析后的请求体构造 object-lock-mode、object-lock-retain-until-date、object-lock-remaining-retention-days 三个条件键并调用 globalIAMSys.IsAllowed:
- 当请求为清除保留期(空
<Retention/>,RetainUntilDate为零值0001-01-01)或非未来时间时,将object-lock-remaining-retention-days精确求值为"0"(修复旧代码因time.Duration截断与math.Abs算出106,752天的漏洞,且始终保留该条件键以确保NumericLessThan的Deny策略能够命中清除请求); - 当日期为未来时间时,改用 Unix 秒数差与纳秒差向上取整计算天数,避免
Time.Sub在约 292 年后发生time.Duration饱和。
同时,将对象写锁内的 enforceRetentionBypassForPut 收敛为纯粹基于存量版本状态的规则:
(3)cmd/auth-handler.go:删除 isPutRetentionAllowed
直接删除 cmd/auth-handler.go 中 45 行的 isPutRetentionAllowed 函数,消除 byPassSet || retSet 短路逻辑。
3.2 SN-2026-016 修复代码详解(入口守卫 + 存储写锁复检 + 预检 Fail-Closed)
为什么 SN-2026-016 必须在 HTTP 入口守卫之外保留存储写锁内的复检?原因在于以下四类场景无法仅靠入口或来源标记拦截:
- 慢速请求体导致的
PUT dir/覆写竞态(TestPlanReviewSlowDirectoryPut):PutObjectHandler的预检checkPutObjectLockAllowed运行在流式读取 HTTP 请求体r.Body之前,而存储写锁er.NewNSLock在请求体写完临时文件后才获取。仅持有s3:PutObject的写入者可以先发起PUT /bucket/dir/通过预检,随后暂停发送请求体字节流,等管理员对dir/设置COMPLIANCE保留期并收到200 OK响应后,再继续发完请求体。因此必须在对象写锁内复检null版本锁状态。 - 升级遗留或复制发起的已持久化
uploadId完成请求(TestPlanReviewLegacyMPUCompliance):putOpts只在NewMultipartUpload创建上传任务时运行;而CompleteMultipartUpload使用completeMultipartOpts,目标版本号直接读取自.minio.sys/multipart/.../uploadID/xl.meta中持久化的fi.VersionID。对于升级前已创建的遗留分段上传任务(或由复制凭据初始化的任务),必须在完成阶段的对象写锁下校验目标版本。 - 旧上传状态恢复后与已复制落盘的
COMPLIANCE版本碰撞(TestRecoveredMultipartUploadAfterReplica):即便分段上传在初始化时由服务端生成了新 UUID(甚至带有服务端来源标记),在旧上传状态快照恢复且存活副本已将该完成版本及COMPLIANCE锁重新同步回本地后,暂存目录中的fi.VersionID与已落盘的受锁版本具有相同 UUID。来源标记只能证明 UUID 生成时的来源,不能证明完成上传时该版本仍不存在。因此必须在CompleteMultipartUpload的对象写锁内直接查重目标fi.VersionID是否已存在。 - 预检与写锁复检的读取失败必须 Fail-Closed(
TestPlanReviewPreflightReadFailure):只有明确确认目标版本不存在(ObjectNotFound、VersionNotFound或删除标记MethodNotAllowed)时才能按“无锁”放行;当桶锁配置读取失败或底层存储发生读仲裁错误(如InsufficientReadQuorum)时,预检与写锁复检都必须拒绝写入。
完整改动分为以下四步:
(1)在 putOpts 唯一汇合点拦截非可信复制的 versionId(cmd/object-api-options.go)
所有 HTTP 写入选项构造(PutObject、CopyObject、NewMultipartUpload、PostPolicy、PutObjectExtract)最终都汇聚到 putOpts。在解析 vid 前增加 3 行守卫:
(2)补齐 Batch Snowball 归档复制发送端的外层复制标记(cmd/batch-handlers.go)
SILO 服务端自身的 batch replicate 在源与目标均为 SILO/MinIO 时会通过 writeAsArchive 发送 Snowball 归档,并在每个 tar PAX 条目中携带 minio.versionId。接收端 PutObjectExtractHandler 要求外层 HTTP 请求头具备 X-Minio-Source-Replication-Request: true(markerExact)才会对条目授予 entryTrusted。因此在 writeAsArchive 中为外层 PutObjectOptions 补上复制请求标记:
(3)在 HTTP 预检与存储对象写锁内双重拦截 null 版本锁覆写,且读取失败时一律 Fail-Closed(cmd/bucket-object-lock.go、cmd/erasure-object.go、cmd/erasure-server-pool.go、cmd/erasure-sets.go)
- HTTP 预检阶段(
checkPutObjectLockAllowed):使目录对象(isDirObject(object))与putOpts/delOpts一致地将空版本解析为nullVersionID;在!replicaTrusted时将未到期的GOVERNANCE与COMPLIANCE、LegalHoldOn一并视为不可覆写;并且当getObjectInfoFn返回非ObjectNotFound/VersionNotFound/MethodNotAllowed的错误时立即返回错误(Fail-Closed):
- 存储提交写锁阶段(
checkObjectLockedForOverwrite):在er.putObject、er.CopyObject、er.CompleteMultipartUpload持有目标对象命名空间写锁(NewNSLock)期间,对所有目标版本为""或nullVersionID的非复制写入调用checkObjectLockedForOverwrite(多池模式下通过z.replicaObjectInfo汇总跨池锁状态)。桶锁配置读取失败或对象读取失败时同样拒绝写入,只有确认目标null版本不存在或未被锁定才放行:
(4)在 CompleteMultipartUpload 对象写锁下按持久化 fi.VersionID 精确区分 null 版本与 UUID 版本覆写(cmd/erasure-multipart.go)
分段上传在初始化与完成之间可能跨越桶版本控制状态的切换,或涉及升级遗留任务、跨主体完成及旧上传状态恢复。因此在对象写锁(NewNSLock)内直接依据 uploadid/xl.meta 中持久化的 fi.VersionID 裁决非复制完成请求(!opts.ReplicationRequest && !opts.ReplicaLockReconcile && !opts.DataMovement):
- 若
fi.VersionID == "" || fi.VersionID == nullVersionID(写入null版本,含版本控制开启前发起的正常在途上传及目录对象上传):调用checkObjectLockedForOverwrite,在未触发对象锁时正常放行完成; - 若
fi.VersionID为非空 UUID:无论当前桶处于Enabled还是Suspended,通过getObjectInfo(多池经replicaObjectInfo)检查该 UUID 版本是否已存在于磁盘(含普通版本与删除标记);若已存在则直接返回errAuthentication(403 AccessDenied),读取失败则按 Fail-Closed 返回对应错误,且不清理未提交的分片文件。
4. 关键路径与性能影响分析
| 操作路径 | 修改后的执行路径与开销说明 |
|---|---|
GetObject / HeadObject / ListObjects / DeleteObject |
完全不经过任何修改点,读路径与删除路径无任何变更 |
普通桶(未开启对象锁)的 PutObject / CopyObject |
putOpts 增加一次内存空串判断 vid != "";写锁内 checkObjectLockedForOverwrite 读取进程内 globalBucketObjectLockSys.Get(bucket) 缓存发现 !retentionCfg.LockEnabled 后立即返回,无额外磁盘 I/O 或跨池请求 |
对象锁桶中的常规对象 PutObject / CopyObject(非 / 结尾) |
对象锁桶强制开启版本控制,常规单次对象写入生成的 fi.VersionID 为非空 UUID,不满足 fi.VersionID == "" || fi.VersionID == nullVersionID,既不在预检也不在写锁内触发额外的 getObjectInfo 磁盘读取 |
对象锁桶中的目录对象写入(PUT dir/)或暂停版本控制下的 null 版本覆写 |
在预检与对象写锁内读取 null 版本元数据进行锁校验,并在读取失败时立即拒绝写入,防止慢速请求体竞态与读仲裁故障下的锁覆写 |
版本化桶中的分段上传完成(CompleteMultipartUpload) |
在 CompleteMultipartUpload 最终提交写锁内执行一次 getObjectInfo(多池下为 replicaObjectInfo)核验 fi.VersionID 是否已存在。该检查仅在分段上传最终合并提交时发生一次(NewMultipartUpload 与各 UploadPart 均不受影响);针对常规新建上传跳过该查询的优化需另行设计能抵御快照恢复重放的机制并独立验证,不在本次安全修复中强行并入 |
PutObjectRetention 合法延长保留期与未授权请求 |
基础权限 s3:PutObjectRetention 前移至读取桶/对象状态之前:未授权请求在入口即被内存态 IAM 求值拒绝(消除原有的磁盘读取与写锁争用);合法延长保留期请求在对象写锁内判定 !weakens 后立即返回,无需在写锁内重复执行 IAM 求值 |
5. 核心安全不变量与升级兼容性说明
5.1 三条核心安全不变量
- 不变量一:基础权限与锁条件键在读取任何状态前无条件成立(消除 F1、F3、清锁天数绕过与存在性探测)
PutObjectRetention支持的三个对象锁条件键全部派生自请求体 XML 与当前时间,不依赖桶或对象的任何存量元数据。因此在ParseObjectRetention之后立即调用isRetentionActionAllowed(policy.PutObjectRetentionAction, ...),既是最早可求值点,也是完全求值点。- 删除
byPassSet || retSet并将清除保留期(空<Retention/>)的剩余天数精确求值为"0"后,任何缺少s3:PutObjectRetention授权、或命中条件Deny/ 不满足条件Allow的请求,无论是否携带 bypass 请求头、也无论请求何种模式,一律在入口被403 AccessDenied拦截。
- 不变量二:Bypass 仅由“存量锁未到期且被削弱”触发,且意图与授权必须同时具备(消除 F2)
enforceRetentionBypassForPut在对象写锁保护下检查已存储的ret.Mode与ret.RetainUntilDate。若存量无锁、已到期、或请求未削弱存量锁(!weakens),无需 bypass;一旦weakens == true,存量COMPLIANCE无条件返回400 ObjectLocked,存量GOVERNANCE必须同时满足请求头(IsObjectLockGovernanceBypassSet)与 IAM 授权(isRetentionActionAllowed(BypassGovernanceRetentionAction, ...))。
- 不变量三:公开数据写入在入口与提交写锁下均不能替换既有 UUID 版本或受锁
null版本,且读取失败一律 Fail-Closed(消除 F4 与同源写路径缺口)- 入口层:
putOpts拒绝一切非可信复制指定的目标versionId;checkPutObjectLockAllowed拦截对受锁目录null版本的写入并在读取错误时立即拒绝放行。 - 提交写锁层:在与实际重命名提交相同的对象命名空间写锁(
NewNSLock)下,对null版本覆写再次执行 Fail-Closed 的checkObjectLockedForOverwrite(闭合慢速请求体 TOCTOU 窗口),对分段上传的非空fi.VersionID执行存量版本查重(闭合升级遗留任务、跨主体完成与旧上传状态恢复重放窗口)。
- 入口层:
PutObjectRetention 完整判定表
retOK 表示带请求中的锁条件键时 s3:PutObjectRetention 被允许,bypassOK 表示同一组条件键下 s3:BypassGovernanceRetention 被允许:
| 目标版本存量状态 | 请求变更类型 | Bypass 请求头 | retOK |
bypassOK |
修复前行为 | 修复后行为 |
|---|---|---|---|---|---|---|
| 任意 | 任意 | 任意 | 否 | 任意 | 常为 200(F1、F3)或 404/400 | 入口 403 AccessDenied |
未到期 GOVERNANCE |
延长保留期 | 任意 | 是 | 任意 | 200 | 200 OK |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 无 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 否 | 200(F2 漏洞) | 403 AccessDenied |
未到期 GOVERNANCE |
缩短 / 清除 / 改模式 | 有 | 是 | 是 | 200 | 200 OK |
未到期 COMPLIANCE |
缩短 / 清除 / 改模式 | 任意 | 是 | 任意 | 400 InvalidRequest(ObjectLocked) |
400 InvalidRequest(ObjectLocked) |
| 无保留期或已到期 | 任意 | 任意 | 是 | 任意 | 200 | 200 OK |
写路径不变量覆盖表
| 写入路径 | 选项汇合点与写锁防线 | 修复后的保证 |
|---|---|---|
PutObject(常规 PUT / 桶复制 / 站点复制 / 单对象 Batch 复制) |
验签后经 evaluateReplicationTrust 写入上下文,再调用 putOptsFromReq -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开客户端(含 root)带 ?versionId= 一律在 putOpts 返回 403;可信复制正常保留源版本 ID |
CopyObject(含元数据复制) |
源版本走 srcOpts,目标版本经 copyDstOpts -> putOpts;null 版本在写锁下执行 checkObjectLockedForOverwrite |
公开复制源版本正常生成新版本;公开指定目标 ?versionId= 返回 403;可信复制正常同步目标版本 |
PostPolicy(浏览器表单上传) |
经 putOptsFromReq -> putOpts;目录对象预检与写锁内双重执行锁检查 |
携带目标 versionId 一律返回 403;受锁目录对象不可被表单覆写 |
PutObjectExtract(Snowball / Batch 归档复制) |
外层校验 markerExact,逐条目校验 s3:ReplicateObject 得出 entryTrusted 再传入 putOpts;writeAsArchive 补齐外层标记 |
未获复制信任的归档条目若带 minio.versionId 一律返回 403;服务端 batch replicate 归档路径正常保留版本 ID |
NewMultipartUpload / UploadPart / UploadPartCopy |
初始化与加密分片走 putOptsFromReq -> putOpts;UploadPartCopy 强制清除复制信任 |
非可信复制初始化分段上传无法指定目标 versionId(返回 403) |
CompleteMultipartUpload |
走 completeMultipartOpts,并在对象写锁下按 fi.VersionID 执行 null 版本锁检查或 UUID 存量版本查重 |
版本控制开启前发起的正常在途上传可正常完成;针对既有 UUID 版本(含升级遗留任务、指定版本任务与恢复重放任务)的非复制完成请求一律返回 403 |
目录对象 PUT / Copy(/{bucket}/dir/) |
预检 checkPutObjectLockAllowed 与写锁内 checkObjectLockedForOverwrite 双重 Fail-Closed 校验 |
一旦目录 null 版本存在未到期的 GOVERNANCE、COMPLIANCE 或 LegalHoldOn(含预检后慢速传输期间并发提交的锁),非复制覆写返回 400 InvalidRequest(ObjectLocked);读取失败时在预检与写锁均拒绝写入 |
5.2 升级与兼容性注意事项
常规的新版本创建、普通对象读写与已获授权的保留期延长保持原有行为。以下收紧的边界行为需在升级与发布说明中提示运维人员:
- 仅授予
s3:BypassGovernanceRetention而无s3:PutObjectRetention的策略不再放行保留期写入(SN-2026-015)- 旧行为:因
byPassSet || retSet漏洞,仅有s3:BypassGovernanceRetention的主体带上请求头也能成功调用PutObjectRetention。 - 新行为:返回
403 AccessDenied。按 AWS S3 规范,s3:BypassGovernanceRetention是削弱治理锁时的附加权限,写保留期始终需要s3:PutObjectRetention。
- 旧行为:因
- 未授权
PutObjectRetention请求在入口统一返回403,不再返回404或400(SN-2026-015)- 旧行为:桶不存在时返回
404 NoSuchBucket,桶未开启对象锁时返回400 InvalidBucketObjectLockConfiguration。 - 新行为:在读取桶状态前完成鉴权,未授权主体一律收到
403 AccessDenied。
- 旧行为:桶不存在时返回
- 清除保留期(空
<Retention/>)时s3:object-lock-remaining-retention-days求值为"0"(SN-2026-015)- 旧行为:零值日期经
time.Duration截断与math.Abs计算后得到106,752天,导致基于s3:object-lock-remaining-retention-days的最短保留天数策略在清除保留期时失效。 - 新行为:清除请求将该条件键求值为
"0",受最短保留天数策略约束的主体无法通过直接清除保留期绕过最小天数限制。
- 旧行为:零值日期经
- 非复制请求(包括
root)不能再通过?versionId=原地覆写对象版本(SN-2026-016)- 旧行为:任何拥有
s3:PutObject的调用者(含root)带?versionId=<id>发送PUT、CopyObject或NewMultipartUpload会原地改写该版本。 - 新行为:不带可信复制标记的请求携带目标
versionId时一律在putOpts返回403 AccessDenied。标准 S3 协议本就不存在公开的按版本号覆写接口。
- 旧行为:任何拥有
- 向 SILO 目标执行
batch replicate时,目标端凭据必须持有s3:ReplicateObject(SN-2026-016)- 旧行为:若跨集群
batch replicate配置的目标端凭据只有s3:PutObject而无s3:ReplicateObject,目标端虽然剥离了内部复制头,但putOpts仍接受了 URL 中的?versionId=,从而静默保留了源版本号。 - 新行为:保留源版本号必须通过目标端的复制信任校验(即目标凭据需具备
s3:ReplicateObject),否则目标端返回403 AccessDenied,与桶级复制(Bucket Replication)的权限要求一致。
- 旧行为:若跨集群
- 对象锁桶中受锁目录对象(
dir/)在锁到期前不可被重复PUT覆写(SN-2026-016)- 旧行为:目录对象强制共用
null版本且跳过了存量锁检查,重复PUT dir/会静默覆盖并重置其保留期。 - 新行为:当对象锁桶设置了默认保留期、或有人对
dir/(versionId=null)设置了保留期/法务保留时,在该锁到期前再次写入dir/会返回400 InvalidRequest(ObjectLocked)。
- 旧行为:目录对象强制共用
- 独立跟踪的相邻议题(不并入本次补丁)
PostRestoreObjectHandler(归档对象恢复)先读快照再调用CopyObject回写完整元数据映射的并发竞态属于归档恢复路径的独立问题,另行立项处理。
6. 候选方案权衡评估与最终取舍
在设计评审期间,我们针对写路径防护评估并实际构造测试检验了以下四套候选方案:
| 评估维度 | 候选一:仅保留 HTTP 入口与预检守卫 (已否决) |
候选二:凭分段上传“服务端生成版本标记”跳过写锁 UUID 查重 (已否决) |
候选三:fe3935476 完整写锁复检(无预检 Fail-Closed) |
最终落地方案:fe3935476 完整写锁复检 + 预检 Fail-Closed |
|---|---|---|---|---|
SN-2026-015 保留期鉴权(F1 / F2 / F3 / 清锁 0 天计算) |
通过 | 通过 | 通过 | 通过 |
慢速请求体 PUT dir/ 竞态覆写 COMPLIANCE(TestPlanReviewSlowDirectoryPut) |
失败(P1):暂停请求体即可覆写已提交的 COMPLIANCE 锁 |
通过 | 通过 | 通过 |
升级遗留或复制发起的 uploadId 覆写(TestPlanReviewLegacyMPUCompliance) |
失败(P1):CompleteMultipartUpload 不经 putOpts,直接覆写 COMPLIANCE 版本 |
通过 | 通过 | 通过 |
旧上传状态恢复后与已复制落盘的同 UUID COMPLIANCE 版本碰撞(TestRecoveredMultipartUploadAfterReplica) |
失败(P1) | 失败(P1):生成标记只能证明初始化时的来源,不能证明完成时该版本仍不存在 | 通过(写锁内无条件按 fi.VersionID 查重) |
通过(写锁内无条件按 fi.VersionID 查重) |
预检阶段读仲裁失败时的行为(TestPlanReviewPreflightReadFailure) |
失败(P1):预检忽略读错误并覆写对象 | 通过 | 写锁内拦截,但预检忽略读错误并继续接收请求体 | 通过(预检即刻 Fail-Closed 拒绝,不进入存储写入) |
| 分段上传完成阶段查询开销 | 无额外查询 | 新上传跳过查询(但引入恢复重放漏洞) | 每次完成在写锁内执行一次 getObjectInfo |
保持写锁内 getObjectInfo 严格查重;性能优化留待后续单独设计验证 |
最终结论
采用**fe3935476 完整写锁防线 + 预检 Fail-Closed 加固**作为最终落地实现,并将慢速目录写入(TestPlanReviewSlowDirectoryPut)、预检读故障(TestPlanReviewPreflightReadFailure)、遗留分段上传(TestPlanReviewLegacyMPUCompliance)及恢复后分段上传碰撞(TestRecoveredMultipartUploadAfterReplica)四组复现用例全部纳入 cmd/object-retention-overwrite_test.go 回归测试集。

