让客户端源地址真正可信

一个以单个请求头命名的开关,被当成了防住三个头的手段。关掉 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 处境相同。

最后修改:2026-08-05: 2026-08-04 release (131ac3d)