跳转到主要内容

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

返回本页常规视图.

PGSTY SILO 博客

文章、发布注记与安全公告

1 - 文章

关于 MinIO、S3 兼容对象存储与 SILO 社区分支的文章与分析。

关于 MinIO、S3 兼容对象存储与 SILO 社区分支的文章与分析。

1.1 - 续命 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 又暴漏出四个高危漏洞,两个中危,覆盖最后的开源版本。

policy.webp

与此同时,上游官方仓库距离最后一次发布已经过去 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-josego.opentelemetry.io 和 Go 1.26.2 自身吸收的一连串标准库与依赖 CVE,这一版本聚合了接近二十条安全条目。

有的能伪造身份拿到高权限访问,有的能把登录入口拿去枚举和爆破,有的能把对象写成永久不可读,有的能用一条请求把服务吃到 OOM,还有的能在缺失签名校验时直接写入对象。这不是小修小补,这是实打实的维护责任。

issue.webp

这次是怎么修的

在之前那篇文章里,我明确说过我会用 AI Coding Agent 来维护这个项目,事实上我也是这么做的。这一轮修复里,我扮演了一个 Blind Manager 的角色。

简单解释一下我的工作范式。具体到每一条漏洞,流程大致是:

  1. Codex 先打铁:根据 CVE 描述和相关代码路径,产出第一版补丁。
  2. Claude Code 做 review:站在对抗视角挑毛病。
  3. 回到 Codex:我要求它,如果你同意 Claude Code 的意见,那就返工;如果不同意,那就反驳,把理由写清楚。
  4. 把所有思路摊开,再交给 Claude Code 做一轮 review。必要时来回再跑几轮,直到两边收敛。
  5. 进行测试:依然是类似的对抗操作,由 Codex 设计测试用例,Claude 补充。然后由 Codex 去实际执行并产出结果,再由 Claude Code review。
  6. 我来定夺:看 diff,跑测试,做最后决策与验收。

这个过程中,我自己不写一行代码。我的工作是定义问题、约束边界、挑方案、看 diff、跑验收、拍板。

公开提交页上,你能直接看到 VonngCodexClaude Code 三个名字同时出现在几条关键安全提交的 Co-authored-by 里。这不是作秀。这就是 2026 年一个人维护一个中型基础设施项目的真实样子。

fix.webp

这种协作模式有几个实际的好处。

第一,两个 agent 对抗能筛掉大部分“听起来都对、实际上不对”的方案。 单独一个 agent 在修复安全漏洞时会有一种 “幻觉级自信”,写出一份解释流畅、看起来干净的补丁,但漏掉了一个边界条件。让另一个 agent 从敌对视角审视它,这种方案很难活过第一轮。

第二,逼出显式的权衡。 两家不同实现路径撞上了,自然就要回答 “为什么你选 A 而我选 B”。这个对话本身就是在把隐性假设显性化,而显性化的假设,才是我作为 Blind Manager 能拍板的东西。

第三,真正的维护是“补丁打补丁”,而不是一把梭。 拿 LDAP STS 这条洞来说,首版修复推出来以后,很快发现成功请求不该消耗限流额度、默认不该信任 X-Forwarded-For、限流账户要按 “源 IP + 归一化用户名” 双维度算账。 然后又连着补了三次提交才算收敛干净。这个过程如果没有 agent 的火力支持,单个 maintainer 要一边读代码一边迭代,成本是完全不一样的。

agent.webp

有些事还是要人来拍板

但也正因为这个模式运转得不错,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 也累计了 五万+ 下载。数字本身不算什么惊人的成就,但它至少说明了一件事:需要这条线的人,并不只有我自己。

credit.webp

对已经在用 MinIO 开源版的人来说,迁移到这个分支的成本其实很低:

pigsty-module.webp

你不需要换掉整个系统,也不需要重新学习一套对象存储;多数情况下,只是把一个失去维护的上游,替换成一个还会继续交付补丁的分支。 如果你需要完整的生产级部署方案,Pigsty 里也提供了开源免费、开箱即用的 MinIO 生产级高可用部署支持。


承诺是什么

两个月前那篇文章发出去以后,有人私信我,说这事看着挺悲壮。其实不是。

写那篇文章的时候我没有赌气,发这个版本的时候我也没有激动。从头到尾,这就是一件普通得不能再普通的事,用的东西坏了,自己修一下。仅此而已。

只是到了 2026 年,“自己修一下” 这件事的门槛,被 AI Coding Agent 重新定义了。一个人,加两个 agent,加一点点判断力,足以把一个六万 star 的中型基础设施顶起来。这不是我厉害,这是 时代变了

以前我们谈论开源的韧性,谈的是 “社区”,几十上百个志愿者众筹时间。现在这套剧本还在,但 底下多了一层保险:哪怕社区散了,只要有一个人还愿意按下 fork 按钮,项目就能续命。

承诺是什么?承诺不是 “我有激情”,也不是 “我有道义”。承诺是 “下一个 CVE 出来的时候,我还在”

releasenote.webp

下一个 CVE 出来的时候,老冯还在。

就这样。

1.2 - MinIO 已死,MinIO 复生

MinIO 开源仓库正式归档,不再维护。一个时代落幕,但开源的精神不死。 老冯 Fork 了 MinIO,复活了管理控制台,重建了二进制分发渠道,让 MinIO 浴火重生。

如果你正在用 MinIO,把 minio/minio 换成 pgsty/minio ,其他一切照旧。


MinIO 的死亡证明

2025年12月3日,MinIO 在 GitHub 上宣布进入"维护模式"。我写了一篇《MinIO 已死》。

2026年2月12日,MinIO 在 GitHub 首页将状态从"维护模式"更新为 “不再维护”,随后正式将仓库归档(Archived)。Read-only,不接受 PR Issue,不接受任何贡献。 一个拥有六万 star、超过十亿次 Docker 拉取的项目,变成了一座数字墓碑。

archived.webp

如果说12月是 临床死亡,那 2月的这个提交就是 正式开具了死亡证明

今天(2月14日),一篇题为《How MinIO went from open source darling to cautionary tale》的长文引发了广泛传播,详细复盘了 MinIO 从开源宠儿到反面教材的完整堕落时间线。

mermaid-timeline.webp

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

peter.webp

不是 “不更新了” —— 是 彻底的、不可逆的、官方盖棺定论的死了

回顾这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 的备份仓库默认跑在生产环境里。

minio-doc.webp

这不是一个 “要不要做” 的问题,而是 不做不行。 早在2025年12月 MinIO 宣布维护模式时,我就已经自己动手创建了修复了 CVE 的二进制。

releases.webp

pgsty/minio RELEASE.2025-12-03T12-00-00Z


我们做了什么

截至今天,我们做了三件事。

1. 复活管理控制台

这是社区最愤怒的一刀。

2025年5月,MinIO 把完整的管理控制台(Admin Console)从社区版中移除,只留下了一个残废的对象浏览器。 用户管理、桶策略、权限配置、生命周期管理…… 一夜之间全没了。想要?掏钱买企业版。

我们把它弄回来了。

gui.webp

讽刺的是,这甚至不需要逆向工程。你只需要把 minio/console 子模块的版本号改回去就行了。 也就是说,MinIO 当初做的事情就是 改了一个依赖版本号,把完整控制台换成了残废版。功能都在那,代码都在那,他们只是给你关上了门。

console.webp

他们拆了门窗,我们给装回去了。

2. 重建二进制分发

2025年10月,MinIO 停止分发预编译的二进制文件和 Docker 镜像,只留源码。“请用 go install 自己编译” —— 这是他们给用户的交代。

对于绝大多数用户来说,开源软件的价值不只是一份源码副本 —— 供应链的稳定性才是命脉。 你需要的是一个可以写进 Dockerfile、放进 Ansible Playbook、塞进 CI/CD Pipeline 的稳定制成品,而不是每次部署前先装个 Go 编译器。

我们重建了完整的分发渠道:

Docker 镜像
pgsty/minio 已上线 Docker Hub,docker pull pgsty/minio 即可使用
RPM / DEB 包
为主流 Linux 发行版构建了与原版规格一致的安装包。
CI/CD Pipeline
GitHub 上全自动化构建流程已经搭建完毕,确保供应链持续稳定。

如果你在用 Docker 镜像,把 minio/minio 简单换成 pgsty/minio 就好了

喜欢原生 Linux 安装的朋友,可以直接从 GitHub Release 页面下载 RPM/DEB 包。 老冯的 pig (PG扩展包管理器)也可以简单的免翻墙安装。你也可以自己配置启用 pigsty-infra APT/DNF 软件仓库来安装。

curl https://repo.pigsty.cc/pig | bash; 
pig repo set; pig install minio

一切照旧。

3. 复活社区版本文档

MinIO 的官方文档同样面临风险 —— 原本的链接已指向它们的商业产品 AIStor。

所以我们基于 minio/docs 进行了 Fork,修复了失效链接,恢复了被删除的控制台文档,部署在:https://silo.pgsty.com/zh/

文档采用与原版相同的 Creative Commons Attribution 4.0 协议,完整保留了所有内容,并持续进行必要的维护更新。

doc.webp

我们的承诺与原则

一些话需要提前说清楚,免得产生误解。

我们不做新特性,只保障供应链

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.3 - 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

minio.png

不过,安全漏洞和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.4 - MinIO已死

2025年12月3日,是个值得在开源软件历史上记一笔的日子。 MinIO 官方在 GitHub 上更新项目状态,宣布 MinIO 开源项目进入“维护模式” 。 这基本上宣告了 MinIO 作为一个开源项目的死亡。

MinIO 这家公司,终于完成了从“屠龙少年”到“恶龙”的华丽转身。

maintenance-mode.png

从屠龙勇者到新的恶龙

民主化时代(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/miniohelm install 的标准 CI/CD 流程瞬间失效。 对于那些缺乏 Go 语言编译环境或内部容器镜像仓库维护能力的团队而言,这实际上等同于不可用。

维护模式(2025-12)

2025年12月3日,MinIO, Inc. 在其官方渠道及 GitHub 仓库中正式更新了项目状态,宣布 MinIO 开源项目进入“维护模式”。 README 上写到:以后不再提供功能更新改进,不再审Issue合 PR,重大安全问题看情况。 不再提供 RPM/DEB 包与 Docker 镜像,不再加新功能,需要维护的企业用户请切换到商业版本 AIStor 上。

aistor.png

技术影响:对开源生态的破坏

MinIO 进入维护模式对现有技术栈造成了即时且深远的破坏。

CI/CD 管道的断裂与自动化危机

成千上万的 Helm Charts、Ansible Playbooks 和 Terraform 脚本依赖于 minio/miniobitnami/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.png

作为开源创业者,老冯不是不能理解这种改变的动机。 但是站在开源贡献者与用户的立场 —— 老冯也知道很多兄弟现在心里只有一句话:“我从未见过如此厚颜无耻之人。”

开源协议虽然不是卖身契,但它是一种 社会契约。 开发者贡献代码、用户贡献测试场景和口碑,大家一起把项目捧红。 MinIO 享受了十年的社区红利,靠着“全球下载量第一”的虚荣指标拿到了融资, 转头就对这就帮把它捧上去的用户说:“你们是搭便车的,滚蛋。” 这种行为破坏了开源社区最底层的信任。

这种“养套杀”的手段,比币圈的 Rug Pull 还要恶心。币圈割的是钱,MinIO 割的是全球数万家企业的技术栈 —— 用户的选择其实不只是一个二进制,而是一个软件生态和设计哲学。等大家都上车了,把迁移成本堆高到无法承受,然后突然抽走梯子。 这种模式,开源专家 Tison 在《诱导转向的伪开源战略》已经聊的很透聊。

诱导转向的核心问题在于 欺骗,既然 MinIO 背叛了社区,社区也会抛弃它。GarageSeaweedFS 甚至 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 - 发布注记

SILO 各个正式版本的详细发布说明,按时间从新到旧排列。

每个 SILO 正式版本均有独立页面,记录发布日期、主要变更、安全修复、依赖更新与相关提交。

2.1 - Silo Console 2.1.0 发布

双语控制台:零依赖的中英文界面覆盖全部页面,仪表盘迁移到 MinIO Metrics V3 并显式处理零值语义,另有一批正确性修复——包括转义安全的占位符替换,以及不会说谎的全选框。

发布日期: 2026-08-06 · 版本: v2.1.0 · 仓库: pgsty/silo-console

SILO Console 2.1.0 是独立发布 2.0.0 之后的第一个功能版本,做了三件事:

  1. 会说两种语言——全部控制台页面、帮助条目与文档链接都能以中文或英文呈现,切换按钮出现在每一页,且不引入任何新的运行时依赖;
  2. 读对了指标——仪表盘从 MinIO Metrics V2 名称迁移到 V3,并针对 V3 在底层改变的语义逐条做了显式处理;
  3. 在边界情况下不再说谎——全选框与批量操作作用于同一批对象,占位符能扛住包含 $& 的对象名,时间戳带上了时区,空指标显示"无数据"而不是编造出来的 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,而"零依赖"是本次实现相对它唯一一处有意的偏离。

实现方式

设计约束是:不加新依赖、不加构建步骤、不引入字符串抽取流水线,并且 部分覆盖绝不能让页面出错

  • 英文原文就是字典的 keyt("Create Bucket") 查找对应的中文条目;查不到则原样返回英文。因此覆盖度可以增量生长,写错 key 的后果是退化成英文,而不是暴露 console.bucket.create 这样的裸标识。
  • 三份字典,一次合并zh.ts(165 条界面框架)、zhHelp.ts(247 条帮助内容)、zhScreens.ts(1373 条功能页面)合并时以框架优先,合计约 1785 条。
  • 语言偏好完全复用暗色模式的模式localStoragesystemSlicesetLanguage不做浏览器语言探测,默认英文,选择完全显式。
  • 集中拦截而非逐点改写:页头包装器、确认对话框、帮助条目、路由定义、仪表盘面板渲染器各自在输出的最后一步翻译。这正是 220 个页面文件能够在不触碰业务逻辑的情况下完成本地化的原因。
  • 模块拆分是必要的i18n/lang.ts 只存放纯函数原语(translatelocalizeUrl)且不导入 store——systemSlice 依赖它,反向导入会形成循环依赖。Hooks(useTuseLanguageuseLocalizedLink)与 interpolate() 放在 i18n/index.tsx

切换控件是一个描边风格的 文/A 图标,挂载在所有页面的页头,登录页复用同一控件。

覆盖范围

登录与 SSO 流程、导航与命令面板、仪表盘与全部指标面板、存储桶与完整对象浏览器(上传、预览、分享、版本、回溯)、用户/用户组/策略/访问密钥、配置与事件目标、IDP 与 KMS、日志、健康报告、性能测试、性能剖析、对象检查、跟踪、监视,以及许可证页面。

除可见文本之外:

  • 文档链接会本地化silo.pgsty.com 的链接在中文下加 /zh 前缀;Pigsty 站点做域名对调(pigsty.iopigsty.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 特性会让"照名字改写"的迁移出错,每一条都需要明确的应对:

  1. 集群组指标由每个节点重复导出/cluster/* 指标不带 server 标签,也不做 leader 门控,因此 N 节点的抓取会得到 N 条重复序列。查询统一用 max()/min() 聚合——绝不能用 sum(),那会把集群总量乘以节点数。
  2. 零值根本不导出。任何取值 ≤ 0 的指标都会被跳过。离线磁盘数、修复中磁盘数、纠删集健康状态不是报 0,而是直接消失——统计卡片会渲染成空白面板。所有受影响的查询都配了伴随守卫,让面板读出真实的 0
  3. 完全不存在 minio_heal_* 命名空间。V2 的修复活动信号本身就是内存态的:重启即清零,任何扫描都会刷新它。它被两张语义可辩护的卡片取代:纠删健康(以写入法定人数为基线)和 用量数据时效(扫描器用量快照的陈旧程度)。

零值语义

这次迁移经过了一轮对抗性审查,产出 8 项发现,全部在发布前修复。它们共享同一个主题——区分 无数据尚未扫描

  • 容量 的空闲/已用以恒定存在的总量为基线,因此写满的集群显示 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 处 字典占位符替换现已改为传入函数形式的替换值,函数形式不做任何此类解释。这是原本英文界面里就存在的潜在缺陷,并非 i18n 引入;只是 i18n 审计把它找了出来。

名副其实的全选

vendored 数据表格在缺少 onSelectAll 时会渲染一个无法翻译的纯文本 “Select” 表头——七张可选表格全都如此。更麻烦的是,直觉的修法是错的:直接用可见行替换整个选择集,会 丢掉被当前过滤条件隐藏的行,于是表头复选框与随后的批量操作可能作用于不同的集合。实现只切换当前可见行,并保留被过滤隐藏的选择,因此表头状态不可能再暗示一个与实际操作对象不同的集合。

带时区的时间戳

桶、对象、版本、回溯与访问密钥的时间戳此前混用冗长英文格式,并且有几处使用 不带 AM/PM 的 12 小时制——这根本是歧义的。现在它们在两种语言下统一渲染为 yyyy-MM-dd HH:mm[:ss] (ZZZZ)

扛得住实时数据的翻译运行时

t() 同样会收到运行时字符串:User-Agent、RSS 标题、对象名。由此做了两项加固:

  • 未命中时 无条件原样返回——隐式的 @context 后缀剥离被移除,因为它会悄悄改写恰好含有 @ 的实时数据;
  • 字典查找加上 hasOwnProperty 守卫,因此恶意输入即便命中继承自 Object.prototype 的成员(constructortoString)也无法把函数泄漏到界面上。

交互与可访问性

  • 会话过期后打开深链会在 /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 单元、二进制名称与数据布局均无变化。

install -m 0755 silo-console-linux-amd64 /usr/local/bin/silo-console

有两点值得知道:

  • 仪表盘现在要求 Metrics V3。如果你的 Prometheus 只抓取 V2 端点,仪表盘面板会显示无数据。请把抓取目标指向 /minio/metrics/v3;由 Pigsty 管理的部署已经如此。
  • 语言默认为英文,按浏览器选择并存入 localStorage。不存在服务端默认值,也不做浏览器语言探测,因此现有部署升级后外观不会发生变化。

验证范围

打标签之前,完整变更集经过审阅,并针对最终代码树执行了以下门禁:go buildgo vetgolangci-lint(0 问题)、全部 Go 包的单元测试、gofmt、TypeScript 类型检查、前端生产构建、全量 Prettier 检查、字典重复 key 检查,以及对完整 diff 的调试残留扫描。

29 个过程提交通过纯树操作重组为 20 个逻辑提交,重建后的分支顶端与重写前的代码树验证为 逐字节一致。嵌入负载从干净目录重建两次并确认字节一致——这正是发布流水线零差异门禁所依赖的性质。重写前的历史保留在备份引用中。

Metrics V3 迁移另外接受了一轮由独立模型执行的对抗性审查,8 项发现全部修复(见零值语义);其查询在含真实集群数据的线上指标库上做过验证。

已知限制

  • 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 redirects
  • 437c56c — fix(ui): make the dashboard and bucket list usable on narrow screens
  • 85fc0c6 — fix(a11y): name collapsed sidebar controls and credential fields
  • e3fed07 — fix(metrics): rebuild dashboard cards, chart controls, and layout
  • fa11576 — feat(login): polish controls and legal attribution
  • 9fc17c1 — feat(i18n): add hand-rolled EN/ZH core, dictionaries, and language toggle
  • 622c02e — feat(i18n): localize login, navigation, and the help system
  • 6a03719 — feat(i18n): localize dashboard and metrics screens
  • 14b1c2d — feat(i18n): localize bucket and object browser screens
  • 0298062 — feat(i18n): localize identity, configuration, and event destinations
  • 41094f6 — feat(i18n): localize observability, admin tools, and shared components
  • e964992 — feat(metrics): migrate the dashboard to MinIO Metrics V3
  • 0b2251f — fix(i18n): harden the translation runtime for live data and chart legends
  • 9b60148 — fix(console): unify timestamps on a timezone-carrying standard format
  • bf110ae — fix(console): give selectable tables a visible-rows select-all
  • 5fc8f22 — fix(i18n): escape-proof all placeholder substitutions
  • fef8fab — fix(console): polish speedtest, sidebar, and help chrome
  • c4911e8 — chore(console): drop SUBNET remnants from health reporting
  • 1d631c4 — docs: record the SILO Console v2.1.0 changelog
  • 912d847 — build: regenerate optimized embedded web assets

相关链接:

2.2 - Silo Console 2.0.0 发布

SILO Console 首个独立主版本:完成品牌与发布链路迁移,全面重塑登录页与控制台界面,嵌入资产从约 10MB 压缩到 3.5MB,清零已知依赖漏洞,并修复多处上游遗留缺陷。

发布日期: 2026-08-04 · 版本: v2.0.0 · 仓库: pgsty/silo-console

SILO Console 2.0.0 是这套对象存储管理控制台以独立项目身份发布的第一个主版本。它从 georgmangold/console 的 v1.9.1 维护线继续演进,完成了三件事:

  1. 建立独立身份——产品名称、视觉系统、文档入口、源码归属与发布链路全部迁移到 SILO 项目体系,同时刻意保留 Go 模块路径、环境变量等兼容契约;
  2. 重塑用户界面——登录页、主题系统、仪表盘与全站细节按统一的设计语言重新打磨,配套全新的品牌图标集;
  3. 强化工程质量——嵌入式前端资产从约 10MB 压缩到 3.5MB,已知依赖漏洞清零,并修复了包括运行时数据竞争在内的一批上游遗留缺陷。

发布前,本版本经过了两轮独立审查:一轮完整的代码审查与提交历史重组,以及一轮对抗性复核(穷举资产校验、HTTP 语义探测、全站路由回归与发布产物冒烟测试)。

警告

升级前先看兼容边界

2.0.0 的主版本变化发生在 公共身份和交付契约 上,而不是对象数据格式或 S3 协议上。使用旧仓库、旧发布二进制名或旧容器镜像名的安装脚本必须更新;使用 CONSOLE_MINIO_SERVERCONSOLE_MINIO_REGIONgithub.com/minio/console 或 MinIO-compatible Admin API 的现有集成则不应做全局替换。

为什么是 2.0.0

这套控制台最初源自 MinIO Console,随后由 Alevsk/consolegeorgmangold/console 两条社区维护线继续发展。SILO Console 在此基础上由 Pigsty 社区接续维护,为 SILO 提供浏览器管理界面。

版本号从 v1.9.1 提升到 v2.0.0,主要是因为以下对外契约同时发生变化:

  • 产品名称统一为 SILO Console,主仓库迁移到 pgsty/silo-console
  • 正式发布二进制从 console 改为 silo-console,容器镜像迁移到 ghcr.io/pgsty/silo-console
  • 发布资产、校验和、软件包元数据、命令行说明与项目链接全部切换到 SILO;
  • 界面中的项目身份、帮助入口、版权归属、源代码供应与商标说明重新建立。

2.0.0 采取的是"对外身份清晰、对内兼容克制"的迁移策略:需要运维人员明确感知,但不对底层兼容接口做机械式改名。

名称与交付契约

范围 旧名称或旧位置 2.0.0 契约
产品 Console / MinIO Console 的遗留表述 SILO Console
源码仓库 georgmangold/console pgsty/silo-console
正式二进制 console silo-console
容器镜像 ghcr.io/georgmangold/console ghcr.io/pgsty/silo-console
二进制资产 console-<os>-<arch> silo-console-<os>-<arch>
校验和文件 console_<version>_checksums.txt silo-console_<version>_checksums.txt
网站与文档 上游或前维护者入口 silo.pgsty.comsilo.pgsty.com/docs/

命令行程序的作者、用途、帮助文本和项目描述已切换为 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 → siloconsole → silo-console 替换。未来若要迁移这些兼容接口,需要提供别名、弃用周期和明确的双读策略;2.0.0 不做这件事。

全新界面

2.0.0 不是换个 Logo 的品牌迁移,而是对整套界面的重新设计。

登录页

登录页完全重写:左侧品牌面板以纯 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 已固定到具体提交,并在构建前强制执行"干净检出 + 资产重建零差异"门禁。

升级指南

独立二进制

install -m 0755 silo-console-linux-amd64 /usr/local/bin/silo-console
/usr/local/bin/silo-console server

从源码构建时,make console 仍生成 ./console;用于正式服务前按发布名称安装。

DEB/RPM/APK 与 systemd

软件包继续安装 /etc/systemd/system/minio-console.service,单元内部启动 /usr/local/bin/silo-consoleEnvironmentFile=/etc/default/consoleconsole-user 与既有 CONSOLE_* 变量不变。这个保留让软件包升级继续作用于原有服务,而不是平行创建一个新服务。

配置与集成

  • 不要重命名 CONSOLE_MINIO_SERVERCONSOLE_MINIO_REGION
  • 不要修改 Go import 中的 github.com/minio/console
  • 自建版本目录优先迁移到 SILO_RELEASE_SERVICE_HOST
  • 依赖 console update 的脚本改为显式下载、校验并部署发布资产;
  • 按进程路径识别服务的监控更新为 /usr/local/bin/silo-console

本版本不改变对象数据布局,也不要求对存储桶和对象执行迁移。

双重审查与验证范围

2.0.0 在发布前经过两轮独立审查。第一轮完成了全量代码审查、缺陷修复与提交历史重组(13 个过程提交整理为 8 个逻辑提交),并执行了 Go 全包 -racego vetgolangci-lintgovulncheck、前端类型检查、生产构建、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 compatibility
  • 23ae6e8 — feat: redesign and harden the SILO Console web app
  • 7a83a77 — build: update Go toolchain and dependencies
  • 1330d25 — fix: eliminate logger shutdown and test mock races
  • 06b3a34 — docs: publish the SILO Console v2.0.0 guide
  • 4b24372 — build: regenerate optimized embedded web assets
  • c38eb64 — ci: package and publish SILO Console v2 releases
  • b952a12 — brand: regenerate the icon set from the official silo.svg emblem

相关链接:

2.3 - Silo Pkg 3.11.0 发布

本分支的首个定版:恢复被打破的 IAM 桶/对象资源边界——一条只该管对象的授权曾能触及桶级写操作;同时包含策略条件键绕过与三个 LDAP 连接缺陷的修复;并在发现旧标签与上游同号版本撞车后,重新编号到上游的 3.11 线。

发布日期: 2026-08-04 · 版本: v3.11.0 · 提交: d8b1fa7 · 仓库: pgsty/silo-pkg

这是本分支的 第一个定版。它恢复了上游 minio/minio#20449 所报告的 IAM 桶/对象资源边界:策略条件键绕过修复、三个 LDAP 连接缺陷、证书监听器泄漏、有种子 RNG 的缺陷,以及模块真实的最低 Go 版本。

警告

升级前需要确认两件事

  1. 本版本收紧了鉴权。 十二个桶级写操作不再能通过 arn:aws:s3:::bucket/* 这样的对象级资源模式被授权。如果你自己编写桶级策略,请阅读 IAM 桶/对象边界——受影响者只需改一行策略,而 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 可完整恢复原有行为。
  2. 条件键修复仍需服务端另一半。 本版本的策略解析改动与服务端"保留内部条件键名"的改动各自覆盖问题的一半。服务端配套改动位于 pgsty/minio 提交 2f55347f7,但尚未进入公开的 origin/master,也没有任何已发布的 Silo 服务端版本包含它。请确认后续服务端发布说明明确包含该改动。

这个仓库是什么

silo-pkgminio/pkg 的维护分支,为社区版 MinIO 分支提供上游(现由闭源产品驱动)不会再接纳的修复。仓库于 2026-08-02 由 pgsty/minio-pkg 改名而来。

模块路径刻意保持不变,仍然是 github.com/minio/pkg/v3,因此所有 import "github.com/minio/pkg/v3/..." 无需改动,只有 replace 指令的右侧需要更新:

replace github.com/minio/pkg/v3 => github.com/pgsty/silo-pkg/v3 v3.11.0

/v3 后缀是模块的主版本号,不是目录名,不可省略。这也正是本次发布编号为 v3.11.0 而非 v4.0.0 的原因:Go 要求标签的主版本必须与 go.mod 中声明的主版本后缀一致,因此在一个 .../v3 模块上打 v4.0.0 会被工具链直接拒绝。真要发布 v4,就必须修改模块路径,并改写服务端、mc 与 Console 中约 395 处 import——那等于放弃"保留上游路径"所换来的直接替换能力,而这正是保持上游路径的意义所在。

IAM 桶/对象边界

每一个桶级 S3 操作,鉴权时对象名都是空的。IAM 匹配器把它拼成资源串,而对空对象名的情况补了一个尾斜杠:

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

"bucket/" 会被通配模式 "bucket/*" 命中,因为 * 匹配空串。于是一条把 s3:* 授在 arn:aws:s3:::bucket/* 上的策略——读起来是 “任意操作,但只作用于这个桶里的对象”——也连带授予了桶级操作。在多租户集群里,只持有这条授权的租户可以调用 PutBucketPolicy 装上 {"Principal":"*"},把桶变成匿名公网可读或可写,或者给自己授予桶级控制权;它也可以直接把桶删掉,而这正是上游 issue 中的复现步骤。

匿名访问所走的桶策略评估路径从来没有这个斜杠,是正确参照。只有 IAM 这条路径错了,而且只错在一个地方。

为何不修正整条边界

对所有桶级请求都不再补斜杠,是显而易见的修法,上游也试过:那次改动当天就因打破依赖旧行为的策略而被回滚。有两个性质让"完整修正"成为一次迁移,而不是一个补丁。

它会撤销真实部署所依赖的授权。 它撤销的不只是危险的桶写入——通过 bucket/* 授予的 ListBucketGetBucketLocationListBucketMultipartUploads 同样会被撤销。证据就是上游自己的测试套件:11 个 STS 集成测试把 s3:ListBucket 授在 bucket/* 上,然后断言列举能成功。连写服务端的项目都这么写,生产策略里只会更多。

它切的是两个方向。 匹配器对 AllowDeny 拼的是同一套资源串,所以删掉斜杠在收紧过度授予的 Allow同时,也放松了过度阻断的 Deny。一个用 Deny s3:* on bucket/* 锁死某个桶的管理员,会悄无声息地失去那层保护。

保护集合是如何确定的

范围由一个问题决定:够到这个动作,能让调用者拿到它的对象级授权本来就给不了的东西吗?

之所以该问这个问题,取决于缺陷的触发条件。资源匹配发生在动作匹配 之后,所以这个 bug 只有在语句本身已经授予了那个桶级动作时才会咬人——现实中这意味着 s3:*。因此受影响的主体本就对这个桶里的每个对象拥有完整的读、写、删权限。有用的问题不是"这个动作抽象地看有多危险",而是"在一个已经握有全部数据的位置上,够到它还能多拿到什么"。

不再接受对象级授权的十二个动作:

动作 入选理由
PutBucketPolicyDeleteBucketPolicy 能把访问权发给别的主体(包括匿名),也能给调用者自己补上从未授予的桶级动作。自我提权与公开暴露。
PutBucketObjectLockConfigurationPutBucketVersioning 击穿的恰恰是专门用来"防住有写权限的人销毁数据"的保护。
PutReplicationConfigurationPutLifecycleConfiguration 以服务端凭据运行,并在调用者权限被吊销后继续生效。
DeleteBucketForceDeleteBucket 不可逆地销毁桶实体及其配置。上游 issue 的原始复现动作。
PutBucketCorsDeleteBucketCorsPutBucketQOSPutInventoryConfiguration 在今天的服务端没有挂任何行为——要么根本没有 handler,要么 handler 在鉴权之后直接返回 NotImplemented。收走它们不影响任何能用的东西,并且提前覆盖。

刻意不予保护,并且有测试对此做出断言——于是把其中任何一个加回去,都是一次带可见代价的明确决定,而不是往列表里添一行:

  • PutBucketTaggingPutBucketEncryptionPutBucketNotification 它们都是桶级写入,早期草案确实纳入过保护。三者都不给调用者任何它还没有的访问权——受损的是所有者的合规姿态,不是访问边界;而一个拿到 s3:* on bucket/*、并被告知"这个桶归你"的租户,完全合理地会去给它打标签、设默认加密、配事件通知。用很低的安全收益去换实打实的兼容成本,在维护版本里是个错误的交易。
  • CreateBucket 它作用于一个还不存在的桶,没有东西可篡改、可摧毁;而供应流程常用租户自己的 bucket/* 凭据去创建租户的桶。
  • 读/列举族ListBucketGetBucketLocation、各类配置读取)。打破它们正是上游那次完整修复被回滚的原因,它们要等一次带迁移路径的发布。

改动只作用于 Allow 语句。Deny 语句保持历史资源串,因此任何桶级封锁都不会被削弱,NotResource 排除也保持完整覆盖范围。

单调性,以及那个错了两次的论断

上面这一切都建立在一条性质上:这个变更可以收走权限,但绝不能新增权限。 而这条性质被断言过两次,两次都是凭推理而非凭测试,也两次都是错的。把"怎么错的"记录下来,比只记录最终状态更有价值。

第一次尝试让省略的斜杠同样作用在了 NotResource 匹配上——而 NotResource排除Allow s3:* NotResource bucket/* 这样的语句,历史上不会作用于该桶的桶级请求;把排除拿去和裸桶名匹配,排除就不再命中,于是它所限定的那条 Allow 反而变宽了,而且恰恰是在受保护的那些写入上。

第二次尝试修好了这一处,并在发布时写着结论"可证明地单调"。对该版本做的独立对抗性复核给出了反例。省略斜杠并不只是"少了一次匹配"——它改变了 模式所匹配的那个字符串,而一个模式完全可能匹配 "mybucket",却从来匹配不上 "mybucket/"。定长通配是最干净的例子:

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

? 恰好匹配一个字符。对九字符的 "mybucket/" 它匹配不上,所以这条语句从来没有授予过那个桶级写入;而对八字符的 "mybucket" 它匹配上了,于是这次加固 授予了 有缺陷的匹配器都拒绝的东西。

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

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

证据

这条性质是被验证的,不是被论证的。我们生成了一份 27000 条鉴权判定 的语料——15 种资源模式 × 3 个桶 × 5 种对象名 × 20 个动作 × 6 种语句形式——分别在加固前基线与本版本上执行,并逐条比对:

迁移方向 条数
false → true(变宽) 0
true → false(收窄) 144
不变 26856

那 144 条收窄全部落在设计意图之内,没有一条溢出:动作恰好是受保护的十二个;语句形式只有三种 AllowDenyNotResource 排除与 deny-NotResource 三种形式 零变化;资源模式只有四种对象级模式;请求只有桶级请求,对象级请求完全未被触碰。12 × 4 × 3 = 144,严丝合缝。

两个层次都有回归测试。本仓库有十二条匹配器测试钉死每个方向,其中包括一条不变量测试,确保受保护动作个个都是纯桶级动作——ResetBucketReplicationState 名字唬人,实为对象动作,不入集合。服务端有三条端到端测试驱动真实 handler,分别位于客户端、内联会话策略与 S3 路由三个层次;三条在修复前的构建上全部失败,在本版本上全部通过。

需要改什么

只有当你的存量策略把十二个动作之一(或 s3:*)授在含 / 的资源模式上、且同一个桶没有裸桶 ARN 时,你才会受影响。修法是在对象模式旁边补上裸桶 ARN:

"Resource": ["arn:aws:s3:::bucket", "arn:aws:s3:::bucket/*"]

这种配对写法是惯例形式,也是上游自己的测试所采用的写法,在本版本之前同样有效。内置策略不受影响——readwritereadonlywriteonlydiagnostics 全部使用 Resource: "*"

MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 在启动时读取一次,可完整恢复历史匹配行为——过度授予与过度阻断两个方向一并恢复。它目前是单一的全局开关,按动作粒度的作用域已列入待办

策略条件键解析顺序

getValuesByKey() 此前会先按规范 MIME 拼写(http.CanonicalHeaderKey)查找策略条件键,再尝试原始名称。而它读取的那张表里,混放着服务端为当前请求计算出的值(SourceIpSecureTransportCurrentTimeusername 等,以条件键拼写存储)和请求自带的 HTTP 头(以规范 MIME 拼写存储)。

先查规范拼写,就让客户端的请求头能够覆盖服务端算出来的值。

对 MinIO 服务端而言这是一次策略绕过。最简单的例子是 s3:prefix:一个 Prefix 请求头可以满足家目录前缀条件,而真正的 ?prefix= 查询参数仍然列举整个桶。同一条路径还能触及 aws:SourceIpaws:SecureTransportaws:CurrentTimeaws:EpochTimeaws:usernameaws:useridaws:principaltypeaws:UserAgentaws:groupsldap:usernameldap:groupsjwt:groupss3:versionids3:signatureversions3:signatureAges3:authTypes3: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_INSECUREMINIO_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#228certs/ 部分。在 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

兼容性影响

  • 十二个桶级写操作不再能通过对象级资源模式获得授权。需要改什么。对象访问、ListBucketCreateBucket、桶标签、默认加密与事件通知均不受影响,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。
  • 同时开启 ServerInsecureServerStartTLS、且 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: 动作(DistJobStatusGet/SetBucketCompression、两个 TablesReplication*),以及 13 个覆盖函数 CRUD 与打标签的 s3tables: 动作。它们属于本分支刻意不采纳的 AIStor 词表,因为社区版服务端并不实现这些功能。

有 3 个动作在两侧名称不同。 上游对它们做了改名与拆分,本分支保留较早的名称:

silo-pkg v3.11.0 上游 minio/pkg v3.11.0
s3tables:TagResource s3tables:TagTables3tables:TagWarehouse
s3tables:UntagResource s3tables:UntagTables3tables:UntagWarehouse
s3tables:ListTagsForResource s3tables:ListTagsForTables3tables: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:prefixs3:delimiters3:max-keys 只来自查询参数。内容哈希、拷贝源、元数据指令、SSE 与对象锁条件只来自对应的请求头。校验预签名请求时消费的 X-Amz-Content-Sha256 查询值不会成为策略条件。
  • s3:x-amz-storage-class 保留其兼容的查询形式,PutObjectCreateMultipartUpload 上的请求标签同样保留。这两个字段都是 Header 出现即胜出,只有 Header 缺失时才使用查询参数。
  • s3:ExistingObjectTag/* 只来自从已存储对象加载的标签,因此请求自带的 X-Amz-Tagging 不能再冒充既有对象状态。PutObjectCreateMultipartUploadPutObjectTaggings3: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/amd64linux/arm64darwin/arm64windows/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-gominio/muxetcd client/v3go-oidclestrrat-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(上游声明了却从未读取)、replicationAdminDistJobStatusAction
  • 两项先采纳、复核后又移除的改动:consolereadonly 内置策略与 GetAllGlobalCertificates,两者都没有消费者。一旦运维把内置策略名绑定到用户身上,再撤回就格外危险:策略映射按名字持久化,而无法解析的名字会并入一个拒绝一切的空策略;它继承的 admin:CreateUser Deny 也无法与 iamAdmin 组合使用。那个证书辅助函数清点的是社区版服务端从不填充的缓存。
  • 上游的 golangci-lint tool 指令,它会给每个下游消费者的模块图增加约 200 个 linter 依赖。

刻意推迟的部分

minio/minio#20449 的一般性问题——bucket/* 仍会触及 ListBucketGetBucketLocation、各类配置读取、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.4 - mcli 20260806 发布

客户端全面切换为 Silo 身份:所有 MinIO SUBNET 外联路径在构建期永久关闭,移除内置的厂商诊断加密公钥,贡献政策改为无 CLA + 强制 DCO 签署。

发布日期: 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 registermcli support uploadmcli support proxy setmcli 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|statusmcli support proxy show|removemcli license infomcli license unregister 照常工作 —— 它们只读取或清除本地与服务端配置。
  • 全新生成的配置不再自动写入指向 MinIO 公共演示集群的 play 别名,默认别名为 locals3gcs。已有配置文件永远不会被修改,旧版配置迁移逻辑仍识别历史条目。
  • 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/checkout v7、actions/setup-go v7、goreleaser-action v7 及 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#5139mirror --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.5 - Silo 20260806 发布

首个以 Silo 之名发布的版本:品牌切换完成而兼容面原样保留、原生健康检查、Distroless 镜像试点、捆绑 mcli 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)、软件包(silo rpm/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、curlmc
  • Distroless 镜像试点:pgsty/silo:<RELEASE>-distroless 基于 gcr.io/distroless/static,恰好装载一个程序——silo 二进制——内置 exec 形式 HEALTHCHECK/data 在镜像层内以可写方式创建。
  • 经典镜像行为不变: 同样的入口脚本、同样的捆绑工具,mc ready local 继续可用,也没有给它添加 HEALTHCHECK。捆绑客户端升级为 mcli 20260806
  • 合规补齐: LICENSE 与 NOTICE 装入每个软件包与镜像,CREDITS 按实际链接的模块集合(291 个模块)重新生成并纳入 CI 守卫,项目采用无 CLA、基于 DCO 的贡献政策。
  • 组件刷新: 内嵌 SILO Console 2.1.1、silo-pkg 3.11.0mcli 20260806、Go 1.26.5。
  • 以溯源为门禁的发布: 容器镜像只从已发布、校验和与 attestation 双重验证过的发布产物构建;镜像 SBOM 与溯源 attestation 现在同样覆盖 Distroless 变体。

改名

改了什么,与刻意没改什么:

已重命名(交付表面) 已保留(兼容表面)
仓库:github.com/pgsty/silo(main 分支) S3 API、管理 API 与请求签名行为
二进制:/usr/bin/silo /minio/* 路由,含 /minio/health/* 与指标端点
软件包:silo-*.rpmsilo_*.debsilo_*.apk MINIO_* 环境变量与 x-minio-* 响应头
镜像:docker.io/pgsty/silo(另有 -distroless 磁盘格式(.minio.sys)、纠删码、版本控制
服务:silo.service(与 minio.service 冲突并取而代之) Go 模块与导入路径(github.com/minio/...
默认配置目录:~/.silo(存在旧 ~/.minio 时回退兼容) 捆绑 mclimc 兼容别名

服务器呈现自己的身份——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 镜像的依赖所在:

silo healthcheck [FLAGS] [live|ready|cluster|cluster-read]
  • 检查词汇与 /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 或 Kubernetes runAsUser)、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/silosilo.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.<架构>.rpmsilo_<版本>-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 保持属主不动:

# /etc/systemd/system/silo.service.d/10-legacy-user.conf
[Service]
User=minio
Group=minio
警告

分布式迁移必须全体节点一起切换。

集群引导会(按校验和)验证每个节点运行相同的二进制。混合集群——部分节点 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-pkg 3.11.0 —— 保留策略/LDAP/证书修复,含 #15 跟踪的 LDAP-over-TLS 修复。
  • mcli 20260806 —— 镜像内捆绑并已单独发布;见其发布说明
  • 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。

兼容性与升级注意

  1. 软件包升级是接管而非原地更新。 安装 silo,保持 /etc/default/minio 原样(会被继承),启用 silo.service;启动它会经冲突关系停掉 minio.service。数据不动。
  2. 用上述 legacy-user drop-in 保持数据属主稳定;迁移期间不要 chown 存储、不要移动证书。
  3. 分布式集群:只能全停切换。 见上方警告——Silo/MinIO 混合节点无法组网。
  4. 容器用户: 镜像现为 docker.io/pgsty/silodocker.io/pgsty/minio 冻结在 20260804 作为存档。经典镜像行为不变——包括 mc ready local 健康检查——Distroless 变体严格自愿选用。
  5. Distroless 的差异是刻意的: 无 shell、无镜像内 mc、无 MINIO_USERNAME 路径;健康检查原生;CLI 参数配置的服务器需为内置探针设置 MINIO_HEALTHCHECK_URL
  6. Helm 用户: chart 7.0.1 的默认值现在能拉取本版本;若钉版本请显式覆盖 image.tag
  7. 已知且未变: 经典镜像仍不在层内创建 /data,因此完全非 root 的 docker run 配 Docker 管理卷会一如既往失败(#55,Distroless 变体已修复);20260804 说明中继承的 Postgres/MySQL 遗留通知迁移限制仍然适用(#53)。
  8. 客户端配套使用 mcli 20260806;旧客户端在未变的线上协议下继续可用。

验证

本版本分阶段验证,每一步留有证据记录:

  • 健康检查命令的单元与端到端矩阵:目标推导与优先级链(flag/env/派生)、真实 TLS 自动探测、退出码契约、JSON schema、超时有界性、用法错误;
  • 四节点集群上的语义验证:停掉 4 节点中的 2 个后,cluster 报告 503 且 write-quorum=5,而 cluster-readlive 保持 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 服务(readylive 分叉的唯一情形未对真实 KMS 演练);amd64 软件包做了交叉构建与 payload 检查但未在物理 x86-64 主机上安装;改名后的 Docker 发布工作流(含新增的 SBOM/attestation 通道)在本版本发布时才有首次生产运行;Windows 与 Intel macOS 未测试。

交付物

  • pgsty/silo 的 GitHub Release RELEASE.2026-08-06T00-00-00Z:带校验和的平台归档、溯源 attestation、RPM/DEB/APK 软件包(RPM 带 GPG 签名);
  • docker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Zlatestdocker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Z-distrolessdistroless——从完成的 Release 按需发布;
  • 配套发布:mcli 20260806silo-pkg 3.11.0、内嵌 SILO Console 2.1.1;
  • 设计记录:原生健康检查与 Distroless 镜像

选定变更

  • 15def34dc77bdc4c0c:清除上游交付残留;呈现 Silo 身份并关闭继承的上游服务
  • 15ab10833:交付物改名为 silo 并补全软件包载荷
  • 30749911b:镜像装载 silo 二进制并翻译 legacy argv 命令
  • e071bb77e:以保持身份的 silo chart 替换 minio chart
  • bd8df5166:以兼容性、打包与溯源证据为 rebrand 设门禁
  • 6613c2a3c:钉住外部测试固件并针对 silo 二进制运行套件
  • fd2ca1c6dc46b16ec6c47733abcf1c77d5a2:切换到 pgsty/silo 与 main;记录归档分支
  • 6740e6978:工作流迁移到 Node 24 运行时
  • b57275be3:采用无 CLA + DCO 政策并修正版权条款
  • 62717d7bfa6d6d9b02:内嵌 Console 升级到 2.1.0、再到 2.1.1
  • 6bd9cf77e:按实际链接模块集合重新生成 CREDITS 并纳入 CI 守卫
  • 219670d31:LICENSE 与 NOTICE 装入每个软件包与镜像
  • 2ff594f4b:新增原生 silo healthcheck 子命令
  • 4c34d2309:新增 Distroless 镜像变体试点
  • b6d47b7399462cce16:按对抗性评审加固;lint 清理
  • 16b78eb4e:捆绑 mcli 20260806 并将 Helm 默认值指向本版本
  • 062a91bee:将 CREDITS 模块闭包钉定到实际发布的 linux 目标
  • 467931455:统一 rpm 与 deb 的 release 段为 1PGSTY
  • b14ea22aa:镜像发布通道精确匹配校验和清单条目
  • 3be10fcc1:新增手动 finalize 通道,为已签名的 Draft 软件包刷新 SBOM 与校验和

致谢

已有四位贡献者的代码合入本分支,Git 历史中保留着他们的署名:@ZouhairCharef 修复了 go-jose 中的 CVE-2026-34986(#18),@mfredenhagen 修复了 OpenTelemetry 中的 CVE-2026-39883(#19),@pinginfotrackingResponseWriter 实现了 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.6 - Silo 20260804 发布

节点间存储边界加固、S3/IAM 策略修复、分片上传正确性、流式响应可靠性、通知修复、Go 1.26.5,以及重建并签名的发布流水线。

版本: 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/compress 1.18.7、Apache Thrift 0.24.0、SILO Console 2.0.0、silo-pkg 3.11.0 与 mcli 20260804。

安全加固

节点间 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:prefixs3:delimiters3: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 语句上,将这十二项敏感写操作从该模式中排除:

PutBucketPolicyDeleteBucketPolicyPutBucketObjectLockConfigurationPutBucketVersioningPutReplicationConfigurationPutBucketLifecycleDeleteBucketForceDeleteBucketPutBucketCorsDeleteBucketCorsPutBucketQOSPutInventoryConfiguration

DenyNotResource 行为不变。读取/列举操作、CreateBucket、bucket 标签、默认加密与通知配置保持兼容。内置策略使用 Resource: "*",不受影响。

警告

自定义 bucket 授权需要迁移策略

如果某个自定义策略仅用 arn:aws:s3:::bucket/*(常常通过 s3:*)授予这十二项操作之一,请补上裸 bucket ARN:

"Resource": ["arn:aws:s3:::bucket", "arn:aws:s3:::bucket/*"]

MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 在策略迁移期间恢复历史匹配器。它同时也恢复了历史上的过度授权,因此只应作为临时回滚控制使用。

可信客户端地址边界

MINIO_API_TRUSTED_PROXIESaws: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_credentialsnkey_seedtls_handshake_first 键;
  • 将旧 NATS 环境变量拼写与存储的配置键分离;
  • 修复 NATS 迁移往返与 AMQP immediate/internal 映射;
  • 增加一项机械审计,把通知代码读写的键与各子系统注册的 schema 做比对;
  • 引用 libpq 连接串参数,使空白、引号与反斜杠保持其预期取值;
  • 阻止非法键诊断回显 secret 值。

通知键空间注册

警告

已知的旧迁移限制

继承的 Postgres 与 MySQL 旧迁移函数仍然写入未注册的 hostportusernamepassworddatabase 字段。因此迁移后的配置可能在下次加载时校验失败,而通知目标加载是跨子系统 fail-fast 的。此问题早于 20260804,但从连接串之前的数据库通知配置升级时,必须在重启前审查并转换。存储的 password 字段可能包含明文数据库密码。

组件与依赖

  • Go 1.26.5: 包含 crypto/tlsos 的安全修复,以及编译器、运行时、网络与 syscall 的修正。
  • klauspost/compress 1.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-pkg 3.11.0: 提供配套的策略、LDAP、证书、RNG 与时间格式修复,同时保留 github.com/minio/pkg/v3 模块路径。
  • mcli 20260804: 嵌入式客户端来自 pgsty/mc;发布镜像将其暴露为 mcli 并保留 mc 兼容别名。

配套发布说明见 silo-pkg 3.11.0mcli 20260804SILO 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 产出平台归档、校验和与软件包;多架构镜像从完成的发布按需发布。本地快照不能证明公开发布或镜像已存在。

兼容性与升级说明

  1. 集群滚动升级期间保持所有节点在同一发布版本。 节点间校验在 storage-REST 与 Grid 面上发生了改变;混合二进制未经生产测试。
  2. 审查自定义 IAM 策略。 为这十二项受保护的 bucket 写操作补上裸 bucket ARN。仅将 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on 作为临时迁移控制。
  3. 有意识地配置客户端地址信任。 如果 aws:SourceIp 或审计归属重要,请设置 MINIO_API_TRUSTED_PROXIES 并封闭代理周围的直连网络路径。
  4. 审查旧的数据库通知设置。 在重启前把 Postgres/MySQL 的 host/user/password 字段转换为受支持的连接串格式。
  5. 预期重复的 multipart 补全条目会失败。 多次发送同一分片编号的客户端现在会收到 InvalidPartOrder,而不是一个损坏但成功的对象。
  6. 使用匹配的 mcli 20260804 客户端禁用了自更新,必须通过软件包或 GitHub Releases 升级;mcli update 保留为兼容命令但以非零码退出。
  7. 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);该包未被编译进这些二进制。

验证边界

以下内容未被本次发布证明,也不应从交叉编译或单元测试推断:

  • 原生 Windows 执行与 Windows 文件系统语义;
  • Intel macOS 与物理 Linux ARM64 主机;
  • 生产环境的多节点滚动升级、站点复制或生命周期过期运行;
  • 真实的反向代理链与直连入口隔离;
  • 外部 LDAP、OIDC、KMS、STS、Postgres、MySQL、NATS、AMQP 服务;
  • 在真实 systemd 主机上安装并升级签名软件包。

发布产物

  • 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.0silo-pkg 3.11.0mcli 20260804 引用。

重点提交

  • ca7baa67080e8eaa42b6f70ab08:校验节点间路径、纠删元数据与分配大小
  • a36fd8fff:遏制 deadline-bounded 存储任务中的 panic
  • 2f55347f7:将 S3/IAM 策略条件绑定到真实请求值
  • 744a9dcd7:将 s3:versionid 绑定到有效对象版本
  • 97b7d2804:强制 bucket/object 资源边界
  • fe6dc4780:新增可信代理客户端地址边界
  • 22c1e41fd:拒绝重复的 multipart 分片编号
  • c8590413f3e14733f1:恢复完整对象与零长度 multipart 校验和行为
  • 8069a32ac65795ee1f:恢复响应提交与流式 flush 语义
  • 162ded3430c14d8151:修复通知键注册与 libpq 引用
  • 92471792689d346bf5:恢复安全的 buffer 池化与返回 buffer 所有权
  • 3b8a55dee:exec 进入降权进程使信号抵达服务端
  • 2ca4971d9:停止把构建机路径烧入二进制
  • 4c185d5a6e064b5555:加固发布工作流并移除影子配置
  • aa5139369:将 systemd unit 安装到 /usr/lib
  • 11d79fddcca674a696021110b45d88f46cce:对构建、vet、测试、lint、生成、race 与交叉编译把关,并对发布镜像做烟测

2.7 - mcli 20260804 发布

禁用自更新、修复 SUBNET 调试日志泄密、容器改用分支源码构建、打包体系迁移到 nFPM 并启用 RPM 签名。

发布日期: 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/selfupdateaead.dev/minisign 依赖及全部二进制替换逻辑,删除更新通知器与 FIPS/非 FIPS 更新分支。update 命令保留为兼容壳,运行时辅助函数(Docker / DCOS / Kubernetes / 源码构建检测)迁移至独立的 cmd/runtime-info.go。此前该客户端会在每次执行时静默访问上游发布源并提示升级,现已不再有任何出站发布探测。
  • 容器与制品全部本地化:默认镜像改为从检出的分支源码构建,hotfix 二进制从本地构建上下文拷贝,不再下载任何上游预编译产物。移除上游发布用的 Dockerfile.releaseDockerfile.release.old_cpudocker-buildx.sh,并禁用已失效的 MinIO hotfix 上传目标。
  • 打包体系迁移至 nFPM:从 MinIO 的 pkger 迁移到标准 nFPM。产物结构与安装路径保持完全一致(/usr/local/bin/mcli、包名 mcli、版本方案 YYYYMMDDHHMMSS.0.0),但供应商信息改为 PGSTY,许可证改用 SPDX 标识 AGPL-3.0-or-later,Debian Section 由空值改为 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/mcminio-gomadmin-gominio/pkg 四个包本身从未有过任何安全公告。

  • Go 构建基线由 1.26.2 升级至 1.26.5(截至发布时 1.26 线最新版),纳入 1.26.3–1.26.5 各批安全修复,包括 GO-2026-4970os 包符号链接根逃逸)与 GO-2026-5856crypto/tls ECH 隐私泄漏)—— 对一个通过 TLS 下载对象并写入本地文件的 S3 客户端而言最直接相关的两项。
  • github.com/klauspost/compressv1.18.5 升级至 v1.18.7(关闭 GO-2026-5841)。
  • github.com/prometheus/prometheusv0.310.0 升级至 v0.311.3(关闭 GO-2026-5264、GO-2026-5381、GO-2026-5710)。
  • google.golang.org/grpcv1.79.3 升级至 v1.82.1(关闭 GO-2026-6061),同步刷新 genproto 系列。
  • golang.org/x/* 系列整体刷新:crypto v0.49.0v0.53.0(GO-2026-5005…5033 共 14 项公告)、net v0.52.0v0.56.0(GO-2026-5025…5030 及 GO-2026-5942)、sys v0.42.0v0.46.0(GO-2026-5024)、text v0.35.0v0.39.0(GO-2026-5970),termmodsynctools 同步升级。
  • 移除 aead.dev/minisigngithub.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#5139mirror --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.8 - Silo 20260618 发布

加固 LDAP STS、补齐 S3 Select 记录限制、移除 ReadMultiple、升级 Go 1.26.4 并刷新安全依赖。

发布日期: 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 失效。该问题没有 workaround。修复已于 2026-07-29 合入 master,但尚未随公开服务端版本交付;实现见 PR #34

主要变更

  • 移除废弃的 ReadMultiple storage-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 7239 Forwarded,并明确记录 X-Real-IP 的部署约定。
  • 刷新 Go 运行时与模块基线:release、hotfix、goreleaser 与 old-CPU Docker 构建现在都使用 golang:1.26.4-alpinego.mod 更新到 Go 1.26.4;依赖刷新覆盖 NATS、Prometheus、Azure SDK、Apache Thrift、gRPC、OpenTelemetry、Google API/auth、Go x/* 以及相关间接依赖。

直接安全修复

  • CVE-2026-42600:移除废弃的 ReadMultiple storage-REST API,关闭 /rmpl 暴露的旧节点间文件读取路径。
  • CVE-2026-39414:补齐 JSON Lines 输入的 S3 Select 超大记录限制,并保留正确的 S3 Select 错误语义。
  • CVE-2026-33419:进一步强化 LDAP STS 限流记账与 trusted-proxy 源 IP 处理。

依赖安全更新

  • github.com/Azure/go-ntlmsspv0.1.0 升级到 v0.1.1,修复 CVE-2026-32952,避免畸形 NTLM challenge 触发 Go 进程 panic。
  • github.com/apache/thriftv0.22.0 升级到 v0.23.0,修复 Go TFramedTransport 实现中的 CVE-2026-41602
  • github.com/nats-io/nats-server/v2v2.11.1 升级到 v2.11.15,吸收 NATS 2.11.x 安全补丁线。重点覆盖 pre-auth WebSocket 与 leafnode 拒绝服务、MQTT 授权、JetStream 管理 API 授权、凭据暴露和请求身份伪造等问题,包括 CVE-2026-27889CVE-2026-29785CVE-2026-33217CVE-2026-33218CVE-2026-33222CVE-2026-33247
  • github.com/prometheus/prometheusv0.310.0 升级到 v0.311.3,吸收 Prometheus 关于 remote-read 拒绝服务、Web UI 存储型 XSS、remote-write 配置 secret 暴露等安全修复,包括 CVE-2026-42154CVE-2026-44903CVE-2026-42151CVE-2026-40179
  • 将发布构建基线升级到 Go 1.26.4,并刷新 golang.org/x/cryptogolang.org/x/netgolang.org/x/sysgolang.org/x/textgoogle.golang.org/grpc 与 OpenTelemetry 等模块族。即便此前锁定版本已经越过特定公开 advisory 的修复范围,这些更新仍让社区分支继续贴近上游已修复依赖基线。
  • 5e40665:fix: harden LDAP STS rate-limit source bucketing
  • fd69c89:fix: complete CVE-2026-39414 S3 Select record limit enforcement
  • 73ac524:fix: CVE-2026-42600 remove ReadMultiple storage-REST API
  • df627ff:fix: bump Go toolchain to 1.26.4
  • 3e61b1d:chore: update Go module dependencies

2.9 - Silo 20260417 发布

针对 OIDC、LDAP STS、S3 Select、复制元数据、unsigned-trailer 与 Go 工具链的集中安全加固。

发布日期: 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 TokenHS256 等对称签名 token 不再被接受;LDAP STS 统一隐藏“未知用户”和“密码错误”的区别,降低用户名枚举风险。
  • LDAP STS 限流行为更新:限流现在同时按源 IP 与归一化用户名生效,成功请求不再错误消耗额度;默认仅使用 socket peer address 作为源地址,不再信任 X-Forwarded-ForX-Real-IPForwarded,如需按真实客户端 IP 限流,需显式配置 MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES
  • 上传与写入路径更严格:presigned query 参数不能再与 unsigned-trailerPUT / multipart 上传组合使用;Snowball auto-extract 在 unsigned-trailer 路径下也会执行完整签名校验,匿名或伪造签名请求将被拒绝。
  • 复制元数据不再可伪造:普通 PUT / COPY 请求夹带的 X-Minio-Replication-* 内部复制头现在会被拒绝或忽略,只有可信复制链路才能写入相关内部元数据。
  • S3 Select 错误语义更明确:CSV 和 line-delimited JSON 遇到超大记录时会直接返回 OverMaxRecordSize,不再笼统返回 InternalError;依赖旧错误码的客户端或告警规则需要同步调整。
  • 运行时与依赖基线升级:修复 ldaps:// 未正确应用 TLS 配置的回归问题,替换 minio/pkg/v3pgsty/minio-pkg/v3,并锁定若干易引入 breaking changes 的关键依赖;同时升级 go-josego.opentelemetry.io 与 Go 1.26.2,统一构建与发布基线。
  • 文档与安全说明同步更新:刷新 SECURITY.mdVULNERABILITY_REPORT.mddocs/sts/ldap.md 等文档,新增安全通告索引,并将安全说明中的上游 minio/minio 引用统一切换为 pgsty/minio

修复的 CVE

  • CVE-2026-34986:升级 go-josev4.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-32280CVE-2026-32281CVE-2026-32283:升级 Go 到 1.26.2,吸收上游工具链与标准库安全修复。
  • c878ca0:fix: pin deps with breaking changes and fix LDAP TLS regression (#15)
  • e970ec5:fix: upgrade go-jose to v4.1.4 to patch CVE-2026-34986
  • a206510:fix: CVE-2026-39883 upgrade go.opentelemetry.io
  • fd65f11:merge: PR #18 upgrade go-jose to v4.1.4 for CVE-2026-34986
  • bc087e4:merge: PR #19 upgrade go.opentelemetry.io for CVE-2026-39883
  • f1f2239:fix: CVE-2026-33322 restore JWKS-only OIDC JWT verification
  • 6619d0c:fix: CVE-2026-33419 harden LDAP STS auth
  • fcb8f24:fix: CVE-2026-34204 reject untrusted replication metadata
  • c5765dc:fix: CVE-2026-39414 reject oversized S3 Select records
  • fa7c579:fix: GHSA-hv4r-mvr4-25vw block unsigned-trailer query auth bypass
  • b50ab58:fix: GHSA-9c4q-hq6p-c237 harden Snowball unsigned-trailer auth
  • 9a4b3cd:fix: CVE-2026-32280/CVE-2026-32281/CVE-2026-32283 upgrade Go to 1.26.2
  • c55b52c:fix: CVE-2026-33419 preserve LDAP STS rate limits on success
  • 817a457:fix: CVE-2026-33419 harden LDAP STS rate-limit source IP
  • 084a154:fix: CVE-2026-33419 tighten LDAP STS rate-limit accounting
  • 16e34f9:docs: refresh security guidance and fork references

2.10 - Silo 20260325 发布

围绕打包、稳定性、LDAP TLS、Docker 镜像与依赖安全的维护版本。

发布日期: 2026-03-25 · 版本: RELEASE.2026-03-25T00-00-00Z

这是一个以打包、稳定性与安全公告为主的维护版本,重点是完善交付镜像、修复 LDAP TLS 回归,并把已经锁定到安全版本的依赖明确纳入发布说明。

主要变更

  • mcli/mc 打入 Docker 镜像并增加校验流程,改善镜像开箱体验。
  • 修复 ldaps:// 场景中的 LDAP TLS 回归,确保 TLS 配置能够正确透传。
  • 移除从上游继承但社区分支不再使用的 CI/CD 工作流,减轻维护负担。
  • 固定若干关键依赖,避免上游 breaking changes 继续向下游扩散。

修复的 CVE

  • CVE-2026-24051:发布说明明确将 go.opentelemetry.io/otel/sdk 锁定在安全版本 v1.42.0,规避 macOS 上因 PATH hijacking 导致的任意代码执行风险。
  • 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,修复 ssh GSSAPI 认证请求可触发的无界内存消耗问题。
  • f2f9a40:add mcli/mc from pgsty/mc to Docker image
  • ce1c537:fix: pin deps with breaking changes and fix LDAP TLS regression (#15)
  • ee55e53:remove upstream CI/CD workflows inherited from minio/minio

2.11 - Silo 20260321 发布

升级 Go 1.26.1、适配更严格的编译与 lint 检查,并完成大范围安全依赖刷新。

发布日期: 2026-03-21 · 版本: RELEASE.2026-03-21T00-00-00Z

这是一次围绕 Go 1.26.1 与依赖收敛展开的维护版。除了适配更严格的编译与 lint 检查外,这个版本也完成了本轮最关键的一次安全依赖刷新。

主要变更

  • 将构建环境从 Go 1.26.0 升级到 Go 1.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/x509 panic 的问题。
  • 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,修复 osFileInfo 可逃逸 Root 边界的问题。
  • CVE-2026-27142:Go stdlib 从 1.26.0 升级到 1.26.1,修复 html/templatemeta refresh 场景下 URL 未正确转义的 XSS 风险。
  • CVE-2026-26958filippo.io/edwards25519v1.1.0 升级到 v1.2.0,修复 MultiScalarMult 可能产生错误结果或未定义行为的问题。
  • CVE-2025-10543github.com/eclipse/paho.mqtt.golangv1.5.0 升级到 v1.5.1,修复超长 UTF-8 字符串编码错误导致的 MQTT 报文异常。
  • CVE-2026-24051go.opentelemetry.io/otel/sdkv1.38.0 升级到 v1.42.0,修复 macOS 上通过 PATH hijacking 触发任意代码执行的风险。
  • CVE-2026-33186google.golang.org/grpcv1.77.0 升级到 v1.79.3,修复 HTTP/2 :path 缺少前导斜杠时的授权绕过问题。
  • 5abd9a8:bump golang to 1.26.1 and update deps
  • 377fc61:fix: satisfy stricter Go 1.26.1 linter checks

2.12 - Silo 20260314 发布

切换到社区维护的 Console 分支,并完成大范围兼容性与依赖刷新。

发布日期: 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.gogo vet 格式化指令问题,并调整部分测试以适配 Go 1.26 的 HTTP 行为变化。

修复的 CVE

  • CVE-2025-47913golang.org/x/cryptov0.37.0 升级到 v0.46.0,修复 ssh/agent 在处理异常响应时可能导致客户端 panic 的问题。
  • CVE-2025-58181golang.org/x/cryptov0.37.0 升级到 v0.46.0,修复 ssh GSSAPI 认证请求可触发无界内存消耗的问题。
  • CVE-2025-47914golang.org/x/cryptov0.37.0 升级到 v0.46.0,修复 ssh/agent 对畸形身份消息缺乏边界校验导致的 panic 问题。
  • CVE-2025-47911golang.org/x/netv0.39.0 升级到 v0.48.0,修复 html.Parse 在特制输入下的二次复杂度解析问题。
  • CVE-2025-58190golang.org/x/netv0.39.0 升级到 v0.48.0,修复 golang.org/x/net/html 可能陷入无限解析循环的问题。
  • 68521b3:add github ci/cd pipeline
  • 00f3cf7:RELEASE.2026-03-14T12-00-00Z with go 1.26.0

2.13 - Silo 20260214 发布

恢复内嵌 Console,建立 GitHub CI/CD,升级 Go 1.26.0,并补齐社区交付入口。

发布日期: 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-68121crypto/tls 在会话恢复时可能错误接受已变更 CA 配置。
  • CVE-2025-61730:TLS 1.3 在跨加密级别 record 中可能错误处理握手消息。
  • CVE-2025-61726net/url 查询参数解析可导致内存耗尽。
  • CVE-2025-61728archive/zip 构建索引时可能触发过高 CPU 消耗。
  • CVE-2025-68119cmd/go 在调用外部 VCS 工具链时可能触发意外代码执行。
  • CVE-2025-61731#cgo pkg-config: 指令可导致任意文件写入。
  • CVE-2025-61732cmd/cgo 文档注释解析差异可能导致代码走私。
  • 8630937:Restore embedded console and update README for community fork
  • 5d57938:add github ci/cd pipeline

2.14 - Silo 20251203 发布

首个社区打包与分发基线,提供 APK、DEB 与 RPM 制品。

发布日期: 2025-12-15 · 版本: RELEASE.2025-12-03T12-00-00Z

这是目前可追溯到的首个社区版发布,主要目标是建立社区维护分支的打包与分发基线,而不是在此前社区版本之上做增量修复。

主要变更

  • 基于 minio/pkger 建立社区版打包流程。
  • 选择 MinIO 进入 maintenance mode 后的一版上游基线作为社区分支起点。
  • 首次产出 apkdebrpm 等多种平台包,为后续持续发布建立基础。

修复的 CVE

  • 这是首个社区版发布,GitHub Release 未单独声明相对更早社区版本的安全修复清单;本页不追溯其相对上游维护模式基线的全部历史 CVE 差异。
  • d4cd4b4:RELEASE.2025-12-03T12-00-00Z with go 1.25.5

3 - SILO 安全编年史

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

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

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

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

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

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

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

初始判断为什么有问题

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

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

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

研判过程

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

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

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

为什么不做“保险升级”

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

最终决定是:

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

验证边界

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

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

这次事件留下的原则

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

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

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

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

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

漏洞不在“有没有验签”

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

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

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

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

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

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

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

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

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

最终不变量

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

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

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

验证与发布

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

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

兼容性代价

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

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

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

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

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

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

初始威胁模型

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

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

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

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

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

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

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

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

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

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

第三轮:删掉 username bucket

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

最终修复因此:

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

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

被否决的方案

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

验证与发布

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

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

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

最终代价与残余风险

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

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

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

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

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

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

威胁模型

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

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

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

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

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

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

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

为什么 CopyObject 必须提前清洗

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

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

实现与验证

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

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

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

代价与残余风险

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

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

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

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

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

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

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

威胁模型

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

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

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

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

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

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

第二轮:硬件相关的绕过

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

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

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

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

同一轮修正的流语义

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

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

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

刻意没有塞进本 CVE 的问题

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

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

验证与发布

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

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

最终代价

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

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

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

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

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

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

编号为什么变过

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

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

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

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

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

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

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

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

实现

最终改动包括:

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

验证

历史 commit 与会话记录覆盖:

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

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

兼容性与残余风险

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

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

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

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

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

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

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

编号说明

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

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

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

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

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

为什么不逐 handler 打补丁

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

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

实现与验证

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

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

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

兼容性与残余风险

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

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

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

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

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

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

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

威胁模型

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

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

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

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

最初补丁在 xlStorage.ReadMultiple 中:

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

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

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

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

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

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

最终决定:删除 API

进一步调用链审计确认:

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

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

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

验证与发布

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

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

兼容性与结论边界

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

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

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

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

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

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

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

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

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

结论先行

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

「N 个端点」是错的框架

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

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

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

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

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

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

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

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

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

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

实测向量

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

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

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

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

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

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

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

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

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

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

两个细节值得记录:

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

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

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

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

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

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

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

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

两次假绿

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

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

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

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

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

一个高度集中的模式

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

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

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

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

护栏比补丁重要

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

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

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

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

关于对抗审查

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

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

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

后续状态

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

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

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

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

尾声

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

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

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

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

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

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

结论先行

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

缺失不是空

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

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

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

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

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

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

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

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

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

隔壁的 fail-open

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它影响了什么

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

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

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

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

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

我们如何定级

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

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

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

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

我们没有越过的边界

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

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

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

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

证伪

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

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

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

起因与来源

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

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

结语

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

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

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

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

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

先说结论

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

斜杠,与那个空对象名

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

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

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

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

它到底能做什么

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

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

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

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

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

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

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

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

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

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

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

修复

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

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

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

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

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

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

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

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

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

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

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

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

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

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

那个错了两次的论断

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

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

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

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

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

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

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

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

你会察觉到什么

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

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

我们刻意留下的口子

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

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

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

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

收尾

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

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

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

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

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

结论先行

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

这个地址究竟被用在哪

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

代理不是唯一入口

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

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

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

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

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

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

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

设计

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

一个设置选定模式:

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

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

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

利弊权衡

要不要改默认值

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

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

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

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

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

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

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

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

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

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

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

环境变量还是配置子系统

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

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

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

loopback 作为 peer 永远可信

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

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

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

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

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

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

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

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

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

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

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

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

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

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

多节点内部转发

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

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

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

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

兼容性

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

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

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

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

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

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

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

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

对抗式审查发现了什么

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

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

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

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

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

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

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

重构之后的第二轮

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

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

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

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

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

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

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

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

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

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

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

推荐做法

按拓扑:

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

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

没有做的事

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

关于定级的判断

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

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

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

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

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

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

结论先行

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

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

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

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

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

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

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

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

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

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

一个常量,两种含义

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

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

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

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

四个表面,没有握手

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

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

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

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

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

放大器

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

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

修复

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

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

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

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

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

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

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

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

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

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

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

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

报错里的秘密

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

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

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

让这一类绝种的护栏

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

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

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

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

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

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

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

审计接着抓到的东西

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

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

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

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

评审记录

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

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

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

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

拒绝了的,和留着的

刻意拒绝:

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

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

结语

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

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

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

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

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

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

结论先行

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

问题在动词,不在比较符

继承下来的代码:

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

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

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

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

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

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

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

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

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

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

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

它实际做了什么

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

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

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

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

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

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

为什么这不是 CVE

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

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

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

边界选择,以及它的代价

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

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

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

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

我们刻意没有加的约束

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

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

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

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

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

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

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

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

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

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

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

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

三次验收,零阻断发现

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

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

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

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

被否决的与留待处理的

刻意否决:

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

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

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

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

结语

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

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

4 - 设计归档

SILO 分支的产品需求、兼容性决策与实现契约。

这里归档 SILO 维护决策背后的完整思考:要解决的问题、兼容性边界、被否决的方案、实现要求,以及进入发布版本前必须取得的验证证据。

4.1 - CopyObject Checksum 必须覆盖逻辑对象字节

本文是 SILO #63 的设计与验证归档。

状态: checksum 数据域修复已通过 PR #66 合并;公开发布待完成。
相关修复: metadata-only transform state #67 已通过 PR #69 合并,CopyObjectResult checksum 字段 #68 已通过 PR #70 合并;公开发布仍待完成。 上游客户端: minio-go #2295
发布边界: 合并不代表公开 release、软件包或容器镜像已经包含修复。

缺陷本质

CopyObject 先把源对象读成逻辑数据,再按目标配置压缩并加密存储流。旧处理器把 server-side checksum 挂在了已经代表压缩字节的 reader 上:

逻辑对象 -> S2 压缩 -> checksum -> 可选加密 -> 存储

digest 在数学上有效,却覆盖了错误的数据域。客户端下载对象后对逻辑字节独立计算,结果自然不同。API 级复现是确定性的:

修复前持久化 CRC32:hN7ytg==
逻辑对象 CRC32:      1WxbLg==

当前基线实现的 CRC32、CRC32C、CRC64NVME、SHA1、SHA256 都会受影响。压缩叠加加密时,S2 加密流填充包含随机值,错误 checksum 甚至会变成非确定值。

采用的不变量

逻辑 checksum reader 现在与存储变换 reader 分离:

逻辑对象
    -> server-side checksum
    -> 可选 S2 压缩
    -> 存储流 hash
    -> 可选服务端加密
    -> 纠删码与提交

处理器必须在压缩 goroutine 启动前安装 hasher。即使压缩或加密替换活动存储 reader,PutObjReader 仍保存逻辑 reader。读到 EOF 后,对象层要求 checksum 存在、有效并与预期 base algorithm 一致,随后才允许提交 metadata。

该设计直接复用 multipart checksum 的 checksumReader 契约,不创建第二套抽象,不重读对象,也不改变盘上格式。

验证边界

永久 API 测试覆盖五种算法与默认 CRC64NVME;不压缩、压缩、仅加密和压缩加密;SSE-C 与 SSE-S3;加密源和压缩源;版本化桶;full 与 multipart-composite 来源;原地复制;零长度、压缩阈值与带索引 S2 流;ETag、正文 round trip、HEAD/GET checksum mode,以及内部不变量失败。

同一条回归在未修复基线上失败,在修复树上通过。合并前还要求定向 race、随机顺序重复运行、全量 cmd、禁用 CGO 的 kqueue/dev CI 形态、lint、vet、交叉编译、兼容性 guard 与远端 CI 全部通过。

刻意拆开的邻接缺陷

对抗审查又发现两个继承缺陷:

  1. metadata/reference-only self-copy 可能在没有重写引用数据时改变压缩标记;版本化 SSE-C 密钥轮换还可能落入非法重写。该问题独立收敛在 #67
  2. 目标 checksum 已提交后,成功的 CopyObject XML 仍不返回 checksum 元素。服务端由 #68 跟踪;minio-go 还会丢弃字段,对应 #2295

旧 federation 的 UploadPartCopy checksum 恢复属于另一个 API,继续由 #64 跟踪。

归档的上游 minio/minio 仍保留原始 reader 放置方式。silo-pkg 不拥有该 reader 链。MCLI 在指定 –checksum 时会从 server-side copy 切换为下载再上传,SILO Console 只通过 minio-go 透传 CopyObject,因此两者不需要复制一份服务端修复。

已存在对象

修复只影响此后的 CopyObject,不会自动扫描或重写旧版本已经保存的 checksum metadata。

由 CopyObject 创建、当时目标 key 或内容类型命中压缩配置、并带有额外 S3 checksum 的对象值得核验。使用 checksum mode 取回对象与 checksum,再用同一算法独立计算下载后的逻辑字节并比较 Base64 值。

修复时可显式指定 checksum algorithm,把对象复制到新 key。也可以带 x-amz-metadata-directive: REPLACE 做原地复制,但这会重写对象:未版本化桶替换当前值,版本化桶创建新版本。批量处理前必须确认 retention、legal hold、metadata、tag、加密密钥、剩余空间和回滚要求。

SILO 不做自动在线回填,因为那意味着在没有显式 S3 操作的情况下读取并重写用户数据。

4.2 - 只预览文本,绝不执行:SILO Console 文本预览 PRD

状态: 设计已接受,实现待完成 · 归属: pgsty/silo-console · 跟踪: pgsty/silo#17 · 审阅: 产品、安全与前端架构三方共识

SILO Console 可以预览图片、PDF、音频和视频,却不能直接查看运维中最常见的小型日志、纯文本、JSON 与 XML。即使对象保存了完全正确的 Content-Type,前端也会在选择渲染器之前把它判为不支持。

恢复旧版浏览器原生预览很容易,却不是正确修复。对象内容由上传者控制;如果把它作为同源 HTML/XML 文档加载,一个便利功能就会变成代码执行边界。

因此最终设计给出一个更强的承诺:

SILO 只把符合条件的对象作为有界 UTF-8 文本预览,绝不让浏览器把其中的标记、MIME 或内容解释成文档。

本文固定产品边界、资源上限、安全不变量、实现形态,以及功能进入发布版本前必须取得的证据。

最终决策

第一版增加独立的 text 预览类型和 PreviewText 组件。

契约如下:

  1. 完整保留现有 image、PDF、audio、video 判定。
  2. 只有旧分类器返回 none 时,才考虑文本 fallback。
  3. 由四种目标扩展名或四种精确被动文本 MIME 触发。
  4. 通过普通鉴权下载路径获取字节,不传 preview=true
  5. 在应用层强制执行 1 MiB 读取硬上限。
  6. 只做严格 UTF-8 解码,并拒绝疑似二进制内容。
  7. 在可滚动 <pre> 中只渲染一个 React 文本节点。
  8. 永不使用 iframe、HTML/XML 解析器或 HTML 注入接口。
  9. 要么显示完整对象,要么完全不显示;不展示截断 JSON/XML。
  10. 文件超限、编码非法或加载失败时,始终保留 Download。

不新增 Console API 或 S3 API,也不扩大后端 inline MIME 白名单。

当前状况

撰写本文时,SILO 当前锁定的 SILO Console v2.1.1 仍存在这个问题。

前端预览联合类型只有:

image | pdf | audio | video | none

扩展名表包含媒体格式,却没有 .log.txt.json.xml;MIME 分类器也不识别 text/plainapplication/jsonapplication/xmltext/xml

运行时验证得到的分裂状态如下:

对象 前端结果 Console 下载响应
.log / text/plain none inline,SAMEORIGIN
.txt / text/plain none inline,SAMEORIGIN
.json / application/json “Preview unavailable” inline,SAMEORIGIN
.xml / application/xml none attachment,DENY

对象详情页判断 Preview 是否禁用时还使用了错误的与条件:有权限用户可以点开一个不支持对象,最后只看到 unavailable;另一些组合则会先提供按钮,再由服务端拒绝。

预览组件中仍残留一个通用同源 iframe fallback。按当前类型联合,这条分支实际上不可达,所以当前缺陷本身不是可利用的文本预览 XSS。但它很危险:如果只把 text 加入联合类型并让它落入旧 fallback,就会重新激活本文明确否决的同源文档加载。

根因

这是三个独立演进层之间的契约漂移。

分类契约漂移

浏览器端根据文件名和对象元数据决定资格,但封闭类型联合中根本没有文本。再正确的元数据也无法选择一个不存在的渲染器。

响应策略漂移

Console 服务端又独立判断响应能否 inline:它仍把纯文本与 JSON 视为被动安全 MIME,而 XML/HTML 保持 attachment。这个服务端决定没有映射到前端分类。

渲染器漂移

当可达预览类型已经只剩媒体时,旧通用 iframe 仍留在组件里。代码看起来保留了一项能力,类型系统却不可能再调用它。

修复必须重新对齐三层契约,同时绝不能把 MIME 元数据提升成安全边界。

为什么拒绝同源 iframe

X-Frame-Options: SAMEORIGIN 不是 sandbox。它只控制谁能嵌入响应,不限制同源 frame 中的代码能做什么。

一旦上传者控制的 HTML、XHTML、SVG 或主动 XML 被作为同源 inline 文档加载,它就可能获得 Console origin。HttpOnly Cookie 可以阻止脚本直接读取 Cookie,却不能阻止浏览器携带 Cookie 发出鉴权同源请求。只要 MIME 规则被错误放宽,存储对象就可能变成存储型应用代码。

nosniff、CSP 与 Content-Disposition 仍然是有价值的纵深防御,但都不能替代核心不变量:

不可信对象字节
      |
      v
严格文本解码器
      |
      v
React textContent

永远不进入:
iframe / innerHTML / DOMParser / XML parser / 可执行文档

产品契约

这是一个只读文本查看器,不是网页预览器,也不是在线编辑器。

用户应该能够:

  • 从列表或对象详情打开小型、符合条件的对象;
  • 在现有预览弹窗里阅读保留空白的源码文本;
  • 使用浏览器原生选择和复制;
  • 分清失败来自大小、编码、权限、对象被替换还是网络错误;
  • 随时下载原始字节。

系统绝不能让用户误以为:

  • 格式化后的 JSON 就是存储原文;
  • 截断 XML 是完整文档;
  • 替换字符本来就存在于对象;
  • 不支持的编码已经被忠实解码;
  • 主动 HTML/XML 经“消毒”后可以安全执行。

目标与非目标

目标

  1. 无需本地下载即可查看小型日志、纯文本、JSON 与 XML。
  2. 无论扩展名、MIME 与载荷如何,对象内容始终保持惰性。
  3. 把保留的响应字节与渲染文本限制在 1 MiB。
  4. 忠实显示存储文本,不做静默格式化。
  5. 列表与详情页按照相同权限和类型契约提供 Preview。
  6. 支持当前对象版本和显式选择的历史版本。
  7. 保持匿名访问和子路径部署行为。
  8. 先独立发布 Console,再由 SILO 精确消费该 Console 修订。

非目标

  • HTML/XHTML 渲染。
  • XML 解析、XSLT、外部实体与 Schema 校验。
  • Markdown 渲染。
  • JSON 自动格式化。
  • YAML/CSV 专用行为。
  • 编辑与保存。
  • 语法高亮、行号、搜索、折叠、ANSI 渲染与自动链接。
  • 大对象 head、tail 或截断预览。
  • 有损解码,以及 GBK、UTF-16、Latin-1 等编码自动探测。
  • 新增后端文本预览接口。
  • 修改现有 SVG、媒体、PDF、下载、分享或存储契约。

类似 notes.md 的对象如果精确 MIME 为 text/plain,仍可能作为原始文本显示,但不会获得 Markdown 语义。

资格判定契约

资格判定刻意分为两阶段。

第一阶段:保留旧媒体结论

完全不变地运行当前 image、PDF、audio、video 分类器。只要结果不是 none,直接返回。

这样可以保留文件名与 MIME 冲突时的历史行为。

第二阶段:文本 fallback

只有旧结果为 none 时:

  1. 最终扩展名为 .html.htm.xhtml 时明确拒绝;

  2. 按大小写不敏感方式匹配最终扩展名:

    • .log
    • .txt
    • .json
    • .xml
  3. 去掉参数、裁剪空白并转成小写,规范化 Content-Type;

  4. 精确匹配:

    • text/plain
    • application/json
    • application/xml
    • text/xml

允许扩展名或精确 MIME 任意一项命中。本版禁止 text/、子串匹配与 application/+json 等宽泛规则。

以下矩阵是强制契约:

文件名与 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 命中原始文本,不渲染 Markdown。
image.svg + image/svg+xml 现有 image 路径 不进入新 text/iframe 路径。

文件名和 MIME 只影响产品资格,永远不能选择可执行渲染模式。

资源契约

二进制上限定义为:

MAX_TEXT_PREVIEW_BYTES = 1,048,576

正好 1 MiB 可以预览,多一个字节就不可以。

已知大小

  • 选中版本的已知大小超过上限时,不请求正文;
  • 已知大小为零时,显示空文件状态;
  • 已知大小不超过上限时,开始有界请求;
  • 缺失大小不等于零,必须进入有界未知大小路径。

因此当前从列表向弹窗传值时,不能再用 truthy fallback 把 undefined 强制变成零。

有界请求

对于小型或未知大小对象,请求:

Range: bytes=0-1048576

额外一字节用于探测超限。

客户端必须:

  1. 在存在时检查 Content-RangeContent-Length
  2. 以 stream 读取响应,禁止调用 response.text() 或先构造完整 Blob;
  3. 最多保留上限加一字节;
  4. 观察到探测字节后立即取消;
  5. 服务端忽略 Range、返回 200 时仍执行同一限制;
  6. 只有 EOF 证明完整对象未超限后才开始渲染。

超限对象进入说明状态:显示已知大小、1 MiB 策略和 Download,不展示任何前缀片段。

请求身份与取消

预览请求身份是:

bucket + object name + version ID

请求必须复用现有生成 API 客户端或等价的 base-path-safe helper,从而保持:

  • same-origin credentials;
  • 当前 Console 子路径;
  • version_id
  • 匿名模式 X-Anonymous: 1
  • 当前错误处理和权限边界。

关闭、对象变化、版本变化、bucket 变化和组件卸载都必须中止活动请求并清空旧内容。

仅依靠 abort 不够。还要使用 generation token 或失效标记,防止已经读完或解码完成的旧响应更新新的预览。

被取消的请求不是错误,不应产生错误 Toast。

编码与内容保真

第一版只支持严格 UTF-8:

new TextDecoder("utf-8", { fatal: true })

要求:

  • 正确处理 UTF-8 BOM,不显示 BOM;
  • 保留 Unicode、emoji、TAB、LF、CRLF;
  • 非法 UTF-8 直接拒绝,不插入替换字符;
  • 解码后存在 NUL 时,按二进制或不支持内容拒绝;
  • 不猜测其他编码;
  • 不把对象正文写入日志或持久化;
  • 永远保留下载原始字节的出口。

不支持编码状态应解释:

该对象不是有效的 UTF-8 文本,或包含二进制内容。请下载后检查原始字节。

JSON 与 XML 都按解码后的原始源码显示。第一版不得执行 JSON.parseJSON.stringify:这会改变不安全整数、重复 key、空白、字面形式以及用户复制的文本。

安全渲染器

成功状态只渲染一个文本节点:

<pre>{content}</pre>

禁止:

  • iframe、object、embed;
  • dangerouslySetInnerHTMLinnerHTML
  • DOMParser 或 XML parser;
  • Markdown/HTML 渲染;
  • HTML data/blob URL;
  • 按行或 token 生成大量 span;
  • 自动链接、ANSI escape 与语法标记。

单个有界文本节点让 DOM 成本可预测,也让安全性质容易审计。

预格式化区域使用等宽字体、保留空白、默认不换行、独立承担横纵滚动、可键盘聚焦,并支持原生选择和复制。不换行是刻意选择:它能保留日志列对齐,也能避免一条 1 MiB 长行触发昂贵折行布局。

UI 状态与权限

只有同时满足以下条件时,Preview 才可用:

预览类型符合条件
AND 有对象读取权限
AND 不是 delete marker
AND 不是 prefix

对象详情页当前的与条件错误必须修复;列表与详情页必须共享同一资格函数。

符合格式但超限的对象仍然提供 Preview。弹窗负责解释正文为何没有加载;如果直接禁用按钮,用户无法区分大小、权限和类型问题。

弹窗必须区分:

状态 必要表现
Loading 可访问 busy 状态,不显示旧文本。
Success 可滚动原文和 Download。
Empty 明确“文件为空”。
Too large 对象大小、1 MiB 上限、Download;已知超限时正文请求数为零。
Invalid UTF-8 / binary 独立解释和 Download。
Forbidden 权限专属提示,不保留正文。
Not found / replaced 对象变化提示,不保留正文。
Network / server error 可操作的重试/下载状态。
Aborted / closed 静默清理。

HTTP 错误响应正文绝不能被解码后当作对象内容展示。

所有新增用户文案都必须走现有翻译层,并同时提供中英文。内容区和控制项必须在明暗主题、窄屏宽屏下保持可用。

功能与安全要求

功能要求

  • FR1: 现有媒体与 PDF 分类不变。
  • FR2: 文本 fallback 严格遵守规范扩展名/MIME 矩阵。
  • FR3: 不超过 1 MiB 的完整合格对象按严格 UTF-8 源码显示。
  • FR4: 超限对象不显示部分内容。
  • FR5: 空对象具有独立成功空状态。
  • FR6: 当前版本与选定历史版本的元数据、大小和正文使用同一 version ID。
  • FR7: 匿名访问与子路径部署保持当前请求行为。
  • FR8: 列表与详情页采用相同类型/权限结论。
  • FR9: 下载、分享、媒体、PDF 与存储行为不变。

安全要求

  • SR1: 对象字节只能通过文本内容进入 DOM。
  • SR2: Text Preview 不得包含文档渲染器或解析器。
  • SR3: 最多保留 1 MiB 加一个探测字节。
  • SR4: 关闭或身份变化后,全部旧响应失效。
  • SR5: 非法 UTF-8 与 NUL 内容不得冒充忠实文本。
  • SR6: 错误、Redux、local storage、日志和遥测不得保存预览正文。
  • SR7: 直接请求仍以服务端鉴权为最终权威。
  • SR8: 不放宽 CSP 或后端 inline MIME。

实现范围

预计 Console 改动:

  1. 重构预览分类:完整保留当前媒体结论,显式增加文本 fallback;
  2. 在预览类型联合中加入 text
  3. 新增 PreviewText:流式上限、严格解码、请求取消和明确状态;
  4. 把文本对象显式路由到该组件;
  5. 删除不可达的通用 iframe fallback;
  6. 修复对象详情页 Preview 禁用表达式,并与列表共享资格逻辑;
  7. 保留 unknown size,不再把它强制变成零;
  8. 增加中英文文案;
  9. 增加分类、组件、资源、安全、权限、版本与浏览器测试。

预计保持不变:

  • Console 与 S3 API 路径;
  • 后端 safeMimeTypes
  • CSP;
  • 对象存储与元数据格式;
  • 图片、PDF、音频、视频、下载和分享 handler;
  • 外部前端依赖。

如果未来需要 tail、服务端转码、组织级策略,或者必须穿过不支持 Range 的代理链稳定工作,可另行设计专用服务端接口。

被否决的方案

继续禁用文本预览

优点: 没有新代码和浏览器内存成本。
拒绝原因: 日志与配置对象是日常对象存储工作流,强制下载查看是可以避免的 Console 能力退化。

复用同源 iframe

优点: 代码最少,浏览器原生展示。
拒绝原因: 它把上传者控制内容与可变 MIME 元数据变成同源文档边界,同时也不限制资源使用。

现在新增后端预览 API

优点: 服务端统一上限与文本响应。
第一版拒绝原因: 用户本来就有对象读取权限,现有下载端点已经提供版本、鉴权与 Range;新 API 会重复契约,却没有建立新的数据访问边界。

显示大对象前 1 MiB

优点: 大日志更方便。
拒绝原因: 部分 JSON/XML 在结构上会误导,UTF-8 边界还需要额外处理,而且同一个 Preview 动作不再意味着完整内容。

用替换字符解码非法 UTF-8

优点: 损坏或旧日志仍可能部分可读。
拒绝原因: 用户复制的文本不再忠实对应存储对象。有损查看和其他编码应建立独立、显式产品模式。

自动格式化 JSON

优点: 缩进更易读。
拒绝原因: parse/stringify 会改变数字、重复 key、字面形式和复制内容。未来可以增加可选格式化视图,但绝不能替代原文默认。

引入 Monaco 或其他代码编辑器

优点: 行号、搜索、高亮与折叠。
拒绝原因: Bundle、Worker、CSP 与维护成本超过有界只读预览所需;原生 <pre> 更小、更容易审计。

验收与测试计划

分类矩阵

自动化测试必须锁定规范矩阵全部行、扩展名大小写、MIME 参数剥离、HTML/XHTML 显式拒绝,以及媒体冲突行为不变。

资源测试

覆盖:

  • 0 字节;
  • 1 字节;
  • 正好 1,048,576 字节;
  • 1,048,577 字节;
  • 已知超限且正文请求数为零;
  • 未知大小;
  • 206 且 Content-Range 已暴露总大小;
  • 服务端忽略 Range 并返回 200;
  • Content-Length 缺失或错误;
  • 流式读取期间关闭和切换身份。

任何情况都不得保留或渲染超过允许的完整对象。

编码与保真测试

覆盖 UTF-8 中文、emoji、TAB、LF、CRLF、BOM、非法字节序列、NUL、JSON 不安全整数、重复 key、原始空白、XML 声明、DOCTYPE、CDATA 与 stylesheet 指令。

成功视图必须保留解码原文;非法与二进制情况必须进入独立状态。

安全测试

包含 <script>、事件属性、iframe 标签、SVG handler、XML stylesheet、外部实体与可疑 URL 的载荷必须:

  • 逐字出现在 <pre>.textContent
  • 不创建对应 DOM 元素;
  • 不执行脚本或弹窗;
  • 不发出由对象正文触发的请求;
  • 在 Text Preview 中接触不到 iframe、object、embed、HTML parser 或 XML parser。

权限与竞态测试

验证:

  • 没有 GetObject 时没有可用动作,也不保留正文;
  • 历史版本遵守对应权限;
  • 元数据与正文使用同一 version ID;
  • 迟到旧响应不能覆盖新对象;
  • 401、403、404、416、5xx 正文不成为预览内容;
  • 匿名访问和 Console 子路径不回归。

浏览器回归

使用真实 SILO/Console 测试实例检查中英文路由、明暗主题、窄屏与桌面宽度;新文本状态之外,还要对媒体、PDF、下载、分享与版本工作流进行冒烟验证。

交付与完成门槛

虽然用户报告记录在 SILO 服务端仓库,修复本身归属 pgsty/silo-console

交付分阶段进行:

  1. 合入边界明确的 Console 源码与测试;
  2. 通过 TypeScript 检查、生产构建、自动矩阵与真实浏览器安全回归;
  3. 更新 Console 发布说明并重新生成实际嵌入的 Web 资产;
  4. 发布 Console 版本;这项新增可见能力适合 minor 版本;
  5. 更新 SILO 中 github.com/minio/console => github.com/pgsty/silo-console replacement 到精确新 pseudo-version;
  6. 用精确依赖构建 SILO 候选版本并重复集成验证;
  7. 发布 SILO 二进制与镜像,注明第一个包含此功能的版本。

这些是不同状态:

门槛 含义
Console PR 合入 实现存在于源码。
Console 资产/tag 发布 Console 可以被独立消费。
SILO 更新依赖 SILO 主线已集成。
SILO 正式发布 用户可以获得功能。

不能因为本地预览或 Console 源码 PR 已存在,就对用户宣称 issue #17 已经修复。

利弊取舍

最终方案选择:

  • 明确范围,而不是通用浏览器查看器;
  • 完整小文件,而不是部分大文件;
  • 原文保真,而不是自动格式化;
  • 严格 UTF-8,而不是静默有损解码;
  • 单个惰性文本节点,而不是完整编辑器;
  • 复用下载 API,而不是新增后端契约;
  • 可验证安全不变量,而不是便利的同源渲染。

代价真实存在:大型日志和旧编码仍需下载,第一版也没有搜索、行号、换行开关和高亮。这些缺失是刻意的,它们让功能足够小,可以审计;也足够强,可以信任。

审阅记录

本设计从三个视角进行独立审阅:

  • 产品范围、交付与验收;
  • 安全与前端架构;
  • 兼容性与当前源码验证。

评审者最初在“仅 MIME 是否可触发”和“非法 UTF-8 是否有损回退”上存在不同意见。交叉审阅后,三方达成唯一契约:

  • 现有媒体分类优先;
  • 文本 fallback 接受四种目标扩展名或四种精确规范化 MIME;
  • HTML/XHTML 扩展名显式排除;
  • 必须严格 UTF-8 并拒绝 NUL;
  • 有损查看另立独立方案。

当前没有待裁决设计项,可以依照本文进入实现。

4.3 - 数据库通知统一连接串:#53 的兼容性边界

本文是 SILO #53 的产品需求文档与设计决策归档,用来在实现开始前固定 PostgreSQL/MySQL 桶通知目标的最终兼容性边界。

最终决策

SILO 保留 PostgreSQL 与 MySQL notification target,但每种数据库只支持一种当前配置方式:

  • PostgreSQL 必须提供完整的 connection_string
  • MySQL 必须提供完整的 dsn_string

旧的五字段形式——hostportusernamepassworddatabase——继续作为当前 KV 配置系统不支持的格式。SILO 不重新注册这些 key,也不在旧配置迁移时自动把它们拼成 DSN。

旧配置迁移契约刻意保持狭窄:

旧 target 状态 处理结果
未启用 忽略,不生成 target。
已启用,且已有非空 connection_stringdsn_string 只迁移规范连接串和其他已注册设置。
已启用,只有离散连接字段 在新配置生效前拒绝迁移并使服务器启动失败;错误必须可操作、指出子系统与 target 名称,但绝不能打印凭据。

这是配置边界决策,不是删除数据库通知功能。

状态: 设计已接受,实现待完成。
归属: SILO 服务端仓库。
跟踪: pgsty/silo#53
目标: 实现并验证后进入下一个 SILO 补丁版本。

背景

SILO 从 MinIO 继承了两代数据库通知配置。

KV 时代之前的 JSON 配置既可以保存完整连接串,也可以使用五个离散字段:

host
port
username
password
database

当前 KV 配置只暴露驱动原生形式:

notify_postgres  -> connection_string
notify_mysql     -> dsn_string

这不是新方向。MinIO 在 RELEASE.2020-04-10T03-34-42Z 就废弃了五个离散字段,并要求迁移到 connection_stringdsn_string。SILO 当前的帮助表、环境变量文档与示例也已经把完整连接串作为正式接口。

SILO 是一个迁移步骤显式的新社区分支。它优先保证 S3/Admin API、当前 MINIO_* 设置、盘上数据格式和当前 KV 配置的兼容性;当一个规范形式已经存在多年时,没有必要永久保留 2020 年以前的每一种配置拼法。

问题本质

当前旧配置迁移器 SetNotifyPostgresSetNotifyMySQL 会把两种形式一起写入新 KV 配置。即使旧 target 已经有完整连接串,迁移器仍会附带五个离散 key,通常只是写入空值。

新解析器会拒绝这些 key,因为 DefaultPostgresKVSDefaultMySQLKVS 都没有注册它们。合法性检查只看 key 是否存在,不看值是不是空。因此两种旧来源都会失败:

旧完整连接串 -> 规范连接串 + 五个空的未知 key -> 拒绝
旧离散字段   -> 空规范连接串 + 五个有值的未知 key -> 拒绝

通知初始化又放大了这个错误。FetchEnabledTargets 对所有通知子系统采用 fail-fast:第一个非法子系统会返回错误和空 target list。上层只记录错误并继续启动对象存储服务,于是健康的 Webhook、Kafka、NATS 等 target 也全部不可用。

仅仅让两个迁移 helper 返回错误还不能修复这个行为。错误会经过 readConfigWithoutMigrateinitConfig 向上传播,但 initConfigSubsystem 当前会把不可重试的配置错误降级成 “some features may be missing” 日志并返回成功。服务器随后在没有设置 globalServerConfig 的情况下继续启动;通知失败只是其中一个后果,区域、存储类、压缩、身份与其他持久化设置也可能全部缺失。因此实现必须把类型化数据库迁移错误传到启动边界,并在那里按致命错误处理。把它标记为可重试同样不对,因为在没有外部状态变化时,服务器只会无限重试,配置永远不会自行修复。

这个行为格外危险,因为对象读写仍然正常。操作者看到的是健康的 S3 服务,但全部事件管道已经停止。target 根本没有建立,所以不能假定故障期间产生的事件日后还能投递或补放。

此外还有诊断信息暴露问题。未注册的 password 没有敏感字段元数据,可能被原样复制到健康检查或诊断材料中;正式注册的 connection_stringdsn_string 已经按敏感值处理。

为什么第一版修复被回滚

第一版修复注册了五个离散 key,并让解析器读取它们。这样迁移结果确实能通过 CheckValidKeys,而且 target 参数结构和构造器中也仍然保留着旧字段,看起来是很自然的接线方式。

但它破坏了文档明确支持的完整连接串路径。

共享的 mc admin config set 分词器通过查找已注册 key 来识别字段边界,并不能完整理解引号。一旦 port 成为已注册 key,下面这条合法输入中就出现了一个看似新的顶层字段:

connection_string="host=db port=5432 dbname=events user=app"

分词器会在引号内部的 port= 处切开,把 connection_string 截断,再把剩余部分交给 port 解析器,最终报出 invalid port

在当前分词器下,注册 hostportpassword 这类常见词,会让连接串语法与顶层 KV 语法发生直接冲突。因此第一版注册方案被回滚;重新注册这些字段不是可接受的修复。

产品判断

数据库 notification target 是一个专业但有价值的能力。它可以直接提供数据库中的对象命名空间视图或访问流水,不要求用户额外部署事件总线;对于小型部署以及本来就在运行 PostgreSQL/MySQL 的用户仍然有意义。

旧连接参数写法的价值则低得多。五字段模型无法表达常见驱动能力:TLS 模式与证书、连接超时、应用名、Unix socket、PostgreSQL 多主机配置、MySQL 驱动参数,以及未来新增的驱动选项。同时支持两种形式还会制造优先级、合并、脱敏与测试问题;单一规范值不存在这些歧义。

完整连接串才是正确的抽象边界:SILO 负责通知语义,数据库驱动负责连接语法。

因此产品决策是保留能力、删除兼容假象。不支持的旧 target 必须被明确拒绝,不能再被“接受”后转换成一个随后拖垮无关 target 的非法配置。

目标

  1. connection_stringdsn_string 固定为数据库通知唯一受支持的在线配置接口。
  2. 允许已经含有规范连接串的旧 JSON target 跨过迁移边界,不改变其连接语义。
  3. 在离散字段旧 target 产生半成品或非法 KV 配置之前明确拒绝。
  4. 把 #53 当前“服务看似健康、全部通知静默失效”的运行时故障模式,替换为操作者必须先解决才能启动的显式启动期失败。
  5. 确保迁移错误、日志、健康报告与诊断包都不会暴露数据库密码。
  6. 从未注册写入源代码审计中删除 Postgres/MySQL 的十条例外。
  7. 在发布与迁移文档中明确兼容性边界和操作者修复路径。

非目标

  • 在当前 KV 接口中同时支持 DSN 与数据库离散字段;
  • 自动从旧离散字段生成 DSN;
  • 重写共享 KV 分词器;
  • 在本补丁中改变 FetchEnabledTargets 的 fail-fast 语义;
  • 静默跳过已启用的数据库 target,再以残缺通知覆盖继续运行;
  • 删除 PostgreSQL 或 MySQL notification target;
  • 删除为解码和识别不受支持输入所需的旧结构体字段。这些字段仍位于在线构造器共用的 target 参数结构上;构造器中的离散字段连接串合成代码无法从当前 KV 配置到达,但这些字段不能重新成为受支持的配置 key。
  • 修复其他八个旧通知 setter 被忽略的错误。它们原有的静默跳过行为在这次狭窄的数据库迁移补丁中保持不变,必须另做审计和设计决策。

功能需求

当前配置

  1. notify_postgres 接受 connection_stringnotify_mysql 接受 dsn_string
  2. 五个离散 key 继续保持未注册,并被当前配置命令拒绝。
  3. 现有完整连接串必须继续支持数据库驱动语法,包括值内部出现 hostportuserpassworddatabase 等词的情况。
  4. 不增加新的公共环境变量或 KV key。
  5. 已声明的旧变量 MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASE 及其 MySQL 对应形式没有接入当前解析,继续作为不受支持的形式,也不得在文档中被描述成完整连接串变量的可用替代。

旧配置迁移

  1. 旧 target 未启用时,SetNotifyPostgres 必须直接返回,不生成 target。
  2. 对已启用 target,SetNotifyPostgres 必须要求非空 ConnectionString,并且只写已注册的 Postgres key。如果规范连接串与离散字段同时存在,以规范连接串为准,所有离散值都被丢弃。
  3. SetNotifyMySQLDSN 执行同样规则。
  4. 两个 helper 都不得写出 hostportusernamepassworddatabase
  5. 缺少规范连接串时,必须返回带类型或包装上下文的迁移错误,指出子系统与 target 名称。
  6. cmd/config-migrate.go 必须检查并传播两个 helper 的错误,禁止忽略。
  7. 任一 helper 失败后,都不得启用或持久化半迁移配置。
  8. 错误可以指出所需 key 和修复动作,但不得包含任何连接字段值。
  9. 传播的类型化迁移错误必须中止服务器启动,尤其不得落入 initConfigSubsystem 中 “some features may be missing” 的非致命日志路径,也不得进入可重试错误循环。
  10. 已提供规范连接串的校验错误同样遵守启动致命和保密规则;包装错误只能增加 target 上下文,不能重复 DSN 或其组成部分。

推荐错误形式:

notify_postgres:archive uses unsupported legacy discrete connection fields;
set connection_string before migrating to SILO

操作者修复路径

遇到错误的操作者必须选择一条明确修复路径。这既适用于首次切换到 SILO,也适用于升级已经运行 SILO 的部署:旧配置迁移结果不会持久化,因此同一份旧 JSON 来源可能在每次启动时重新进入迁移。一个当前仍能启动、但通知已经静默失效的部署,在升级到修复版本后会直接启动失败,直到来源配置被修正。

  1. 使用兼容的中间 MinIO 版本,把旧字段替换成 connection_stringdsn_string,验证 target 后再迁移到 SILO;
  2. 禁用或删除旧数据库 target,迁移服务器,再用规范连接串重建 target;
  3. 对全新 SILO 安装,直接使用规范连接串创建 target,不经过旧配置迁移。
  4. 对仍在读取旧 JSON 文件的现有 SILO 部署,先停留在上一个可运行版本,备份来源配置,再转换、禁用或删除数据库 target,然后启动修复版本;不要删除或改写无关配置。

文档不得暗示离散字段 target 会被自动转换。

可用性权衡

这个决策有意把一种不受支持配置的“降级启动”变成“启动硬失败”。可用性代价是真实的:一台此前仍能提供对象读写、但全部通知已经静默死亡的服务器,在修复后可能拒绝启动。

我们接受这个代价,因为对象服务表面健康、已配置事件出口却全部消失,会造成静默且可能无法补救的下游数据丢失。SILO 是一个迁移边界显式的新 fork,而离散形式从 2020 年起就已废弃。一个致命、可操作的迁移前置条件,比一次看似成功却缩减通知覆盖的升级更安全。发布注记必须突出这个启动行为,不能把它藏在内部迁移清理里。

安全要求

  1. 不支持输入的错误不得格式化输出旧参数结构或其中任何值。
  2. 测试必须使用哨兵密码,并断言返回错误和捕获日志中都不存在它。
  3. 迁移输出只能包含已注册的敏感连接串 key,不能出现独立 password key。
  4. 如果受影响部署曾在修复前导出并分享诊断包,应将数据库密码视为可能泄露并进行轮换。

备选方案

注册并解析离散字段

优点: 保留旧来源形式,并复用现存参数字段。
拒绝原因: 注册会把常见字段名暴露给共享分词器,破坏引号内的完整连接串;而且这些字段早在 2020 年就已废弃,重新注册等于反向扩大公共配置面。

迁移时自动生成规范连接串

优点: 兼容仅使用离散字段的旧安装。
拒绝原因: 这会为过时输入建立永久代码与测试责任,包括 PostgreSQL 引用、MySQL DSN 格式、socket/IPv6 行为、默认值与未来驱动漂移。对于迁移边界显式的新 fork,这个收益不足以覆盖长期维护面。

只跳过不支持的 target

优点: 对象存储服务与其他通知 target 可以继续运行。
拒绝原因: 静默丢弃已经配置的事件出口可能造成不可见、不可恢复的事件丢失。清晰的迁移失败,比一次通知覆盖缩水却看似成功的升级更安全。

修改全局通知 fail-fast 行为

优点: 限制未来非法 target 的故障半径。
本次拒绝原因: 它既不能修复数据库 target,也不能关闭凭据暴露路径,还会改变全系统错误语义。可另立独立设计和运维契约评估。

删除数据库通知 target

优点: 删除全部数据库专用维护面。
拒绝原因: 这些 target 仍然有用且相对自洽。缺陷属于过时配置形式,不属于通知能力本身。

实现范围

服务端改动应保持狭窄:

  1. 修改 internal/config/notify/legacy.go:两个数据库 setter 只输出规范已注册 key;已启用但没有规范连接串时明确拒绝。
  2. 修改 cmd/config-migrate.go:传播两个数据库 helper 的错误,并补充子系统与 target 上下文。
  3. 定义类型化数据库迁移错误,修改 cmd/server-main.go,让 initConfigSubsystem 将其作为致命错误返回,而不是记录后忽略;该错误必须保持不可重试。
  4. 本补丁不改变其他八个旧通知 setter 错误被忽略的现状;将其留给独立审计,不能暗中扩大 #53。
  5. knownUnregisteredWrites 删除 Postgres/MySQL 十项;除非存在另一个独立且有充分理由的旧例外,否则这个棘轮应当归零。
  6. 增加聚焦的迁移、启动、校验、保密和共存测试。
  7. 更新 silo.pgsty.com 的数据库通知与迁移文档。

补丁不得注册旧 key、修改通用分词器,也不得重构无关通知 target。

验收标准

只有以下证据全部成立,才算实现完成:

  1. 含完整连接串的旧 PostgreSQL target 可以迁移,通过 CheckValidKeys,并由 GetNotifyPostgres 原样返回连接串。

  2. 含完整 DSN 的旧 MySQL target 完成同等验证。

  3. 两类已启用离散字段 target 都在 target 初始化前失败;错误包含子系统与 target 名称,给出可操作修复建议,且服务器启动中止。

  4. 缺少连接串和畸形连接串的错误都不包含哨兵 host、用户名、密码、数据库或 DSN 值。

  5. 未启用的离散旧 target 不生成配置项,也不阻塞迁移。

  6. 迁移后的 KVS 不含十个离散 key,包括空值形式。

  7. 旧 target 同时包含规范连接串与冲突离散值时,只迁移规范连接串,所有输出 KVS 值中都不存在离散哨兵值。

  8. 使用真实 DefaultPostgresKVSDefaultMySQLKVS key 集的 SetKVS 回归测试,能够接受引号内包含 port=host=password= 的完整连接串。

  9. 包含健康 Webhook、Kafka、NATS target 的配置不能再带着非法迁移数据库 target 进入 FetchEnabledTargetsreadConfigWithoutMigrate 返回错误,不返回、不持久化、也不启用任何半成品配置,启动路径随后因该类型化错误中止。

  10. initConfigSubsystem 返回类型化迁移错误,既不能记录后继续,也不能进入可重试循环。

  11. knownUnregisteredWrites 不再包含 Postgres/MySQL 例外。

  12. 以下验证全部通过:

    go test ./internal/config/notify ./internal/config ./internal/event/target -count=1
    go test -v ./cmd -run 'Test(ReadConfigWithoutMigrate|InitConfigSubsystem)' -count=1
    git diff --check

    cmd 的详细输出必须显示两个前缀的测试确实执行;零匹配警告视为验收失败。服务端常规 CI 测试也必须通过;文档仓库执行 make check

发布与兼容性声明

发布注记必须把它描述为一个被正式执行的兼容性边界:

SILO 数据库通知要求 PostgreSQL 使用 connection_string、MySQL 使用 dsn_string。2020 年前的离散 host/port/username/password/database 形式不会被迁移;请在切换到 SILO 前转换或重建这些 target。

仍使用旧格式来源配置、但已经运行 SILO 的部署同样受影响:从这个版本开始,只要存在已启用的旧数据库 target,服务器就不会启动,直到它被转换、禁用或删除。

只有当修复进入已发布的服务端 tag 后,Issue 才能关闭。补丁合入、本地网站构建、正式发布是三个不同的完成门槛。

审阅记录

Claude Fable 5 于 2026-08-23 使用 xhigh effort 审阅初稿,结论为 approve with required changes。必需校准已经吸收:启动致命错误传播扩展到 initConfigSubsystem;覆盖已经运行 SILO 的部署;明确可用性代价;补充规范连接串优先级、无效旧环境变量、其他 helper 错误范围和可执行测试。

同一模型随后完成了基于当前源码的最终复核。最终结论:approve,没有 blocking finding。复核确认中英文记录语义对齐,需求可以在当前服务端代码树上实现,验收标准覆盖启动、迁移、解析器回归和凭据保密边界。

4.4 - 总量未知时,进度条应该说什么

文件夹流式 ZIP 下载显示 NaN% 的修复 PRD:不改变服务端 API 与普通文件下载,用诚实的不确定进度替代非法百分比。

状态:已在本地实现并验证;提交、Console 发布与 Silo 依赖更新待办 · 优先级:P1 · 归属pgsty/silo-console · 关联问题pgsty/silo#62 · PRD 复核:Claude Fable 5(xhigh)— APPROVE · 实现复核:Claude Fable 5(xhigh),2026-08-23 — APPROVE,无 P0/P1/P2 发现

SILO Console 下载文件夹时,Downloads / Uploads 面板会显示 NaN%。ZIP 通常仍在正常传输,存储对象也完好无损,但进度条已经从“总量未知”错误地跨进了一个非法的确定进度状态。用户看到一条近乎满格的进度条,以为下载失败或已经完成,于是重复点击。

建议的修复刻意保持狭窄:

只有当下载拥有一个有限、正数、并且适用于当前响应字节的总量时,才能进入 determinate 状态;否则必须保持 indeterminate,直到完成、失败或取消。

服务端继续流式生成 ZIP,普通文件继续显示百分比。前端只增加一道安全计算边界,复用已经存在的 indeterminate 渲染,再补齐一条缺失的取消状态转换。本文说明为什么这套方案既充分,又是最小且诚实的修复。

已观察到的故障

这个缺陷存在于当前 silo-console v2.1.1,Silo RELEASE.2026-08-06T00-00-00Z 内嵌的正是这一版本。

复现步骤:

  1. 在某个 prefix 下放入若干对象,例如 folder/
  2. 停留在父目录,选择 folder/ 并点击 Download
  3. 在传输完成前打开 Downloads / Uploads
  4. 任务行显示 NaN%,而 ZIP 请求仍在继续。

运行时验证使用了一个约 88.7 MiB 的 prefix,并对 Chromium 限速以保留观察窗口。两次独立下载都进入了相同的 NaN% 状态。

这是前端正确性问题,不代表对象损坏、磁盘格式变化或 S3 GET 失败。

实际发生了什么

可见的 NaN% 是三层契约错位的最终结果。

Prefix 没有对象大小

S3 的文件夹是 common prefix,不是实际存储的目录对象。在列表模型里,prefix 以 / 结尾并携带 size=0。Console 已经把这种大小显示为 -,正确地表达了“不适用”。

生成的 API 模型为 size 标记了 omitempty,所以逻辑上的零不会出现在列表 JSON 中。单选下载 thunk 却把 object.size 原样传给辅助函数:prefix 与零字节对象在运行时提供的是 undefined(人工构造的 prefix 记录也可能提供 0)。两者都不是有效分母。

流式 ZIP 没有事先可知的网络长度

服务端通过末尾的 / 识别文件夹,递归列出对象,再把 zip.Writer 接到 io.Pipe 上。对象一边读取、一边 Deflate、一边复制进 HTTP 响应,档案生成多少就发送多少。

这是一项有价值的行为:服务端不用把完整 ZIP 全部放进内存或临时磁盘,就能尽早发出首字节。它也带来一个同样刻意的结果:发送响应头时,最终压缩字节数尚不存在,因此响应只有 Content-Type: application/zip 和文件名,没有 Content-Length

源对象大小之和不能替代这个总量。对象大小是压缩前字节;ProgressEvent.loaded 统计的是 ZIP 压缩与封装后的响应字节。它们不是同一个单位。

收到 progress 事件,不代表百分比可计算

客户端当前对每个事件都执行:

Math.round((event.loaded / fileSize) * 100)

Prefix 的分母为零或缺失。根据实际值与事件,JavaScript 会产生 NaNloaded / undefined0 / 0)或 Infinity(正数字节除以零)。

progress callback 随后把非有限值写入 Redux,同时设置 waitingForFile=false。第二个操作才是决定性的状态错误:任务仅仅因为“来了一个事件”就离开了现有 indeterminate 分支,而不是因为事件真的提供了可用总量。确定进度组件拿到非法值,最终渲染出非法标签。

完整链路如下:

common prefix: size = 0
        |
        v
download(..., fileSize = 0)
        |
        v
流式 Deflate ZIP,没有 Content-Length
        |
        v
event.loaded / 0 => NaN 或 Infinity
        |
        v
非法百分比进入 Redux;waitingForFile 变成 false
        |
        v
determinate ProgressBar 渲染 NaN%

普通非空文件之所以不出问题,是因为服务端可以 stat 对象、设置 Content-Length,列表中的大小也为正数。如果浏览器为空响应触发 progress 事件,零字节文件虽然是真对象,却会抵达与 prefix 相同的算术边界,因此必须纳入回归契约。

产品契约

UI 只需要诚实地区分两种情况:

  • Determinate:已传输字节与总字节都已知,而且单位相同。
  • Indeterminate:请求正在进行,但总量未知。

由此得到四条承重不变量:

determinate  => total 有限且 total > 0
determinate  => percentage 有限且 0 <= percentage <= 100
unknown total => indeterminate
terminal state => 非 indeterminate

这些不变量比 objectPath.endsWith("/") 更一般:无需发明对象类型特例,就能同时覆盖 prefix、零字节文件、异常元数据和未来任何未知长度响应。

目标与非目标

目标

  1. 文件夹下载不再显示 NaN%Infinity% 或伪造的确定百分比。
  2. 总长度未知的传输使用现有 indeterminate 动画。
  3. 总长度已知的普通文件保留当前百分比体验。
  4. 完成、失败与取消都必须离开 indeterminate。
  5. 零字节文件不得产生非有限百分比,并且仍能成功完成。
  6. 非有限或越界下载百分比不得进入 Redux。
  7. 修复可以先在 Console 独立发布,再由 Silo 更新依赖。

非目标

  • 不在服务端预生成或缓存完整 ZIP。
  • 不把文件夹内对象的未压缩大小之和冒充网络传输总量。
  • 不重构整个 Object Manager 状态模型。
  • 不把文件夹切换到当前“点击即完成”的 BrowserDownload 路径。
  • 不在这里解决 XMLHttpRequest.responseType="blob" 的浏览器内存占用。
  • 不改变取消记录是否保留到用户手动清理的现有产品行为。
  • 不重新设计 HTTP 响应头发出之后,流式 ZIP 中途失败的错误表达。
  • 不改变 S3 API、Console API、对象布局或 ZIP 内容。

这些都是合理的后续工作,但把它们绑进当前缺陷会扩大风险,却不是恢复诚实进度所必需的。

最终决策

最小生产修复由四部分组成。

D1. 只使用有效总量计算

增加一个不依赖 DOM 和 Redux 副作用的小型纯函数:

type DownloadProgressEvent = Pick<
  ProgressEvent,
  "loaded" | "lengthComputable" | "total"
>;

export const calculateDownloadPercent = (
  event: DownloadProgressEvent,
  objectSize: number,
): number | null => {
  let total: number | null = null;

  if (Number.isFinite(objectSize) && objectSize > 0) {
    total = objectSize;
  } else if (
    event.lengthComputable &&
    Number.isFinite(event.total) &&
    event.total > 0
  ) {
    total = event.total;
  }

  if (
    total === null ||
    !Number.isFinite(event.loaded) ||
    event.loaded < 0
  ) {
    return null;
  }

  return Math.min(
    100,
    Math.max(0, Math.round((event.loaded / total) * 100)),
  );
};

总量来源的优先级用于保持兼容:

  1. 有限且为正的 objectSize 保留普通文件当前算法。
  2. 当对象大小不可用,但浏览器声明响应长度可计算,且 event.total 有限为正时,使用响应总量。
  3. 其余情况返回 null:此时还不存在诚实的百分比。

辅助函数的输出契约是闭合的:要么是 null,要么是 [0,100] 内的有限数。

D2. 未知总量保持 indeterminate

XHR handler 只 dispatch 真实百分比:

req.addEventListener("progress", (event) => {
  const percent = calculateDownloadPercent(event, fileSize);

  if (percent !== null) {
    progressCallback(percent);
  }

  // 没有有效总量:保留 waitingForFile=true,让现有 UI 继续保持
  // indeterminate,而不是制造一个 determinate 数字。
});

下载任务本来就以 waitingForFile=true 创建,ObjectHandled 也已经把这个状态渲染成 variant="indeterminate"。没有必要把 Redux 扩成 number | null,也不用再加一个布尔值或修改 MDS。

首次获得有效百分比时,现有 updateProgress 会写入数值并设置 waitingForFile=false。如果整个请求始终没有有效总量,任务就保持 indeterminate,直到终态 action 到来。

D3. 让取消成为真正的终态

完成和失败路径已经会清除 waitingForFile,取消路径没有。需要在 cancelObjectInList 中补上:

item.waitingForFile = false;

没有这一行,修复后的 prefix 下载会在 abort 后继续进入 indeterminate 渲染分支,遮住 Cancelled 状态。任务行继续遵循现有产品行为:保留一条已取消记录,由用户手动移除。本次不要求自动清理。

XHR 边界还需要一条事件顺序守卫。abort() 会先触发 readystatechange(DONE, status=0),随后才触发 abort 事件;如果不提前返回,通用 DONE 分支会先把请求标成失败,onabort 再把它标成取消。DONE/status zero 因此交给专用的 onerroronabort handler 处理,onabort 同时删除已存储的请求引用。

D4. 还原被省略的零字节大小

单选下载 thunk 改为传递 object.size || 0,与另一个下载入口保持一致。这样会在 Blob.size === fileSize 完成校验之前,还原 API 模型省略的逻辑零,使 HTTP 200 的零字节对象以 100% 完成,而不是被误报为 incomplete。

D5. 服务端流式行为保持不变

文件夹 handler 继续通过 io.Pipe 生成 Deflate ZIP,并且不设置 Content-Length。API、档案、存储和资源管理契约均不变化。

状态机

状态 waitingForFile percentage 终态标志 表现
排队 / 尚无有效进度 true 0 indeterminate
未知总量传输中 true 0 indeterminate
已知总量传输中 false 0..100 确定百分比
完成 false 100 done=true 成功
失败 false 最后有效值 failed=true, done=true 错误
取消 false 0 cancelled=true, done=true 已取消

状态不从 determinate 回退到 indeterminate。如果取得过有效百分比,之后某个事件又没有有效总量,handler 保留最后一个有效值即可。

现有 reducer 会在 Failed 与 Cancelled 时同时设置 done=trueObjectHandled 依据 done 把关闭按钮从“中止请求”切换为“移除记录”;本次保持这一行为。取消后的 Redux 数值仍为 0,但现有 ProgressBarWrapper 会因为 ready=true 渲染一条满格橙色终态进度条并显示 Cancelled 标签;这种既有表现不属于本次修复范围。

waitingForFile 并不是“没有可计算进度”的理想长期命名。重命名它,或用 discriminated union 替代当前多个布尔值,都能改善模型,但那属于独立重构。本次所需的状态和渲染已经存在,复用它的兼容风险最低。

为什么这套方案充分

可以按情况验证修复的闭合性。

普通非空文件

objectSize > 0,辅助函数继续使用当前分母。结果有限且经过边界限制,updateProgress 进入 determinate,完成时仍为 100%。

当前流式文件夹

objectSize 被归一化为 0,同时 lengthComputable=falseevent.total=0。辅助函数返回 null;没有非法 action 被 dispatch,因此任务保持 indeterminate。完成时现有 reducer 设置 waitingForFile=falsepercentage=100done=true

未来提供真实长度的响应

如果代理或未来服务端实现提供了可信响应总量,lengthComputable=trueevent.total>0。同一份代码会自动给出真实百分比,不需要再次修改产品逻辑。

零字节文件

列表中被省略的大小先还原为零,此后两个总量都为零,中间百分比在数学上未定义。任务在通常极短的生命周期里保持 indeterminate;零字节 Blob 与归一化后的预期大小相等,成功响应随即切换到 100%。整个过程不会计算 0/0

失败与取消

失败路径本来就会离开 indeterminate;新增的取消转换让 abort 也同样进入终态。终态任务不会仅仅因为总量未知而继续表现得像正在运行。

从数学上说,只有当 total 属于 (0, +infinity) 才会执行除法,结果随后被限制到 [0,100]。因此 NaNInfinity 都不可能穿过计算边界进入 Redux 或确定进度组件。

被否决的替代方案

缓存 ZIP 以获得 Content-Length

服务端可以先在内存或临时文件中生成完整档案,测量以后再发送。这样能得到精确网络总量,但代价是内存或磁盘压力、首字节延迟、清理复杂度与更差的并发下载表现。一个可观测性缺陷不足以成为放弃流式行为的理由。

对 prefix 下对象大小求和

这个和是未压缩逻辑数据;event.loaded 是压缩响应加 ZIP 封装后的字节。单位不同,进度条可能停在 100% 以下、提前超过 100%,或随着压缩率而不是传输完成度移动。否决。

把非法进度变成 0%

这只会隐藏字符串,却会撒另一个谎:determinate 0% 表示总量已知,只是还没有传输。用户仍然会把它理解为下载卡死。未知就应该保持未知。

只特判以 / 结尾的路径

它能修报告中的 prefix,却会漏掉真实零字节对象、非法元数据与其他未知长度响应。正确边界是 denominator 是否可用,而不是对象类型。

把文件夹交给 BrowserDownload

当前大文件路径创建 <a> 并在点击后立刻调用完成回调。它无法报告真实完成、Console 内取消或后续 HTTP 失败。它可以成为未来流式下载设计的基础,但今天使用它只会用另一个谎替换当前的谎。

在 ProgressBar 内部吞掉非法值

通用组件守卫可以作为第二道防线,但它会把非法数据留在 Redux,并向所有其他消费者隐藏错误状态转换。主要修复应该位于“进度成为应用状态”的边界。

现在引入 percentage: number | null

如果要重新设计 Object Manager,discriminated progress state 会比当前布尔值组合更干净。但在保留 waitingForFiledonefailedcancelled 的同时再加入 null,只会制造更多矛盾组合。彻底移除旧字段又超过当前缺陷所需范围。现在复用已经能渲染的 indeterminate,状态重构另立任务。

需求与验收

功能需求

  • FR1: 总量未知时,任务保持 indeterminate。
  • FR2: 对象大小有限为正时,普通文件保留确定百分比。
  • FR3: 只有 lengthComputable=true 时,有限为正的 event.total 才能作为回退。
  • FR4: 所有 dispatch 的百分比都必须有限且位于 [0,100]
  • FR5: 零字节文件不显示非有限进度,并且最终成功。
  • FR6: 完成、失败与取消都必须离开 indeterminate。
  • FR7: 版本化对象、匿名下载、预览与长文件名入口保持现有调用契约。

非功能需求

  • 不增加服务端 CPU、内存、磁盘缓存或请求成本。
  • 不增加前端依赖或构建步骤。
  • 不改变 S3 API、Console API、ZIP 内容或存储对象。
  • 计算函数必须能在没有 DOM 与真实 store 的环境中测试。
  • TypeScript typecheck 与生产前端构建必须通过。

验收标准

  1. 没有 Content-Length 的文件夹 ZIP 传输期间,任务行显示 indeterminate 动画且没有百分比文本。
  2. 成功完成后,任务显示成功/100%,ZIP 可以正常打开。
  3. 普通非空文件继续显示有限的确定进度,并以 100% 完成。
  4. 零字节文件不显示 NaN%Infinity%,并且成功完成。
  5. 取消未知总量下载会 abort 请求并显示 Cancelled,而不是继续播放活动动画。
  6. 任何下载路径都不能把非有限或越界百分比放进 Redux。

测试计划

纯计算矩阵

使用现有 @playwright/test runner 测试纯模块,不增加测试框架。这需要在 web-app/playwright.config.ts 中新增一个无依赖的 unit project,例如使用 testMatch: /.*\.unit\.ts/。现有 chromium project 依赖针对 localhost:9090 真实实例的登录 setup,纯计算与 reducer 测试不应被该环境门控。此为纯配置变更,不引入新依赖。

场景 loaded objectSize lengthComputable event.total 期望
普通文件一半 50 100 false 0 50
Common prefix 1024 0 false 0 null
初始零除零 0 0 false 0 null
响应总量回退 50 0 true 200 25
零总量不可用 0 0 true 0 null
loaded 超过总量 150 100 true 100 100
非法对象大小 10 NaN false 0 null
被省略的零大小 10 undefined false 0 null
非法响应总量 10 0 true Infinity null
负 loaded -1 100 true 100 null

状态测试

直接覆盖状态转换契约:

  1. 新下载以 waitingForFile=true 开始。
  2. 没有有效 progress action 时保持 indeterminate。
  3. 有效 progress 产生有限值并设置 waitingForFile=false
  4. complete 产生 done=truewaitingForFile=falsepercentage=100
  5. failure 产生 failed=truedone=truewaitingForFile=false
  6. cancel 产生 cancelled=truedone=truewaitingForFile=falsepercentage=0

浏览器回归

使用真实 Console 测试实例与 Chromium:

  1. 创建临时桶,在 folder/ 下放入多个对象。
  2. 从父目录选择 prefix 并开始下载。
  3. 使用 CDP 限制下载速度,保证中间状态可观察。 限速用例需用 test.setTimeout 放宽默认 30 秒超时。
  4. 打开 Downloads / Uploads,确认任务存在、没有百分比标签,也不存在 NaN%Infinity%
  5. 取消下载并验证 Cancelled 终态。
  6. finally 中恢复网络条件。
  7. 不限速再次下载,等待浏览器下载事件并验证 ZIP。
  8. 对普通非空文件与零字节文件重复相应断言。
  9. teardown 删除桶、对象、下载与临时文件。

当前 Playwright 项目只启用了 Chromium,因此 CDP 是可接受的测试机制。如果以后启用 Firefox 或 WebKit,纯函数和状态测试保持跨浏览器,只让限速观察测试受 Chromium project 门控。

实现边界

预计 Console 变更:

  1. 新增 downloadProgress.ts,承载纯计算逻辑。
  2. 修改 Objects/utils.ts:只 dispatch 非 null 百分比,把 status-zero 终态交给专用 handler,并清理已取消请求。
  3. 在单选下载 thunk 中还原被省略的零大小。
  4. 修改 cancelObjectInList,清除 waitingForFile
  5. 使用现有依赖补充计算、状态与浏览器回归,并在 playwright.config.ts 中新增无依赖的 unit project。

预计保持不变:

  • Go 文件夹下载 handler 与流式 ZIP。
  • ObjectHandledProgressBarWrapper 与 MDS。
  • IFileItem.percentage: number 及现有 thunk callback 类型。
  • S3 与 Console API 路径。
  • 存储对象与档案格式。

交付与回滚

修复归属于 pgsty/silo-console,而不是当前收到报告的 Silo 服务端仓库。

交付顺序:

  1. 把 #62 转移或交叉关联到 pgsty/silo-console
  2. 实现边界明确的 Console 修改。
  3. 通过 typecheck、生产构建、纯函数/状态测试与真实浏览器回归。
  4. 发布新的 Console 版本。
  5. 更新 Silo 固定的 Console pseudo-version 或发布依赖。
  6. 构建 Silo 候选版本,重复文件夹、普通文件、零字节、取消与 ZIP 完整性验证。
  7. 发布 Silo,并在 Issue 中记录受影响与已修复版本。

没有数据迁移。如果前端修改出现回归,Silo 只需回退 Console 依赖;服务端数据与 API 行为保持兼容。

完成定义

  • 计算函数只返回 null 或有限的 [0,100] 数字。
  • 活跃的未知总量文件夹下载渲染 indeterminate。
  • 普通文件保留确定进度。
  • 零字节文件不渲染非法进度。
  • 完成、失败与取消任务都离开 indeterminate。
  • 流式 ZIP 与服务端响应契约保持不变。
  • typecheck、生产构建与自动化回归已在本地通过。
  • Console 发布完成。
  • Silo 更新 Console 依赖并通过候选版本验证。

后续工作

四项相邻改进应该分别建立设计档案:

  1. 把大文件夹直接流式写入浏览器或文件系统,避免在内存中持有完整 Blob。
  2. 用 discriminated progress/terminal state 替代 Object Manager 的布尔值组合。
  3. 改进响应头已经发出后,ZIP 失败的端到端完整性与错误表达。
  4. 为共享进度组件增加通用非有限值守卫,作为第二道防线。
  5. 修复既有的 Blob JSON 错误解码与 HTTP 失败路径请求引用清理问题。

它们都不是停止当前 UI 撒谎所必需的。下一阶段维护迭代应先恢复最小而诚实的契约:已知总量才显示百分比,未知总量就保持未知。