LDAP 长 DN:恢复登录可用性并守住 IAM 撤销安全边界
Server PR #250(合入提交 d43adc0f2)修复了 LDAP 用户在规范化可分辨名称(Distinguished Name,DN)超过对象存储 IAM 后端单路径分量 255 字节上限时无法登录的问题,同时严格保留了对真实存储故障与持久化 IAM 撤销记录的 Fail-Closed 错误传播。
原始问题报告、初版补丁(9faeb7a11)及长 DN 回归测试由社区贡献者 @910patin 提交。在合并前的设计复核中,维护者进一步收窄了错误分类守卫条件,确保完整路径超限(PATH_MAX)或多字节 Unicode 文件名读取失败绝不会掩盖磁盘上已有的撤销墓碑记录。
问题背景与故障影响
在企业目录服务中,合法的 LDAP 账号常带有较长的 DN,尤其是当姓名与多级组织单元(ou=)包含中文、俄文等非 ASCII 字符时。即使某个用户完全通过 LDAP 用户组(Group)获取权限、既不需要直接绑定个人策略也没有本地父身份记录,SILO 的登录与鉴权流程仍会尝试读取这些可选的个人 IAM 记录。
在对象存储 IAM 后端上,规范化后的 DN 会直接作为目录名或文件名分量拼接到存储路径中。一旦该分量超过文件系统单分量上限(255 字节),底层读取就会抛出文件名过长错误(ENAMETOOLONG / ObjectNameInvalid)并向上传播,导致原本完全合法的 LDAP 登录、STS 临时凭证签发以及服务账号加载直接失败。
为什么 DN 规范化会让非 ASCII 名称膨胀三倍
DN 标识完整的目录条目(例如 cn=张三,ou=研发,dc=example,dc=com)。根据 RFC 4514 §2.4,特定语法字符必须转义,其余字节允许使用 \xx(反斜杠加两位十六进制数字表示一个字节)格式转义。虽然 RFC 4514 允许直接保留普通 UTF-8 字符,但上游 go-ldap 库的 DN.String() 序列化器选择将所有非 ASCII 的 UTF-8 字节逐字节转义为 \xx。
SILO 的 NormalizeDN 先解析 LDAP 服务端返回的 DN,再调用 DN.String() 生成统一的规范化字符串:
| 表示形式 | 字节长度 |
|---|---|
单个 UTF-8 汉字 张 |
3 字节 |
经 DN.String() 序列化后的 \e5\bc\a0 |
9 个 ASCII 字节 |
cn= + 40 个 张 + ,dc=example,dc=com(原始 UTF-8) |
141 字节 |
同一完整 DN 经 NormalizeDN 序列化后 |
381 个 ASCII 字节 |
由于每个 3 字节汉字都会展开为 9 个 ASCII 字节,原本仅 141 字节的 UTF-8 DN 在序列化后膨胀到了 381 字节。
这条查询结果规范化逻辑由上游 MinIO 于 2024 年 3 月 27 日在 minio/pkg efb73fa5(配套 MinIO PR #19358,作者 Aditya Manthramurthy / @donatello)中引入,目的是强制使用 LDAP 目录返回的权威 DN 作为身份标识,防止用户输入的拼写变体(例如将普通句点输入为 Unicode 变体句点)被当成不同的本地身份。
登录回归是如何形成的
对象存储 IAM 后端使用如下路径存放用户级记录:
主流 POSIX 文件系统对**单个斜杠分隔的路径分量(NAME_MAX)**限制为 255 字节。在 sts-users/<normalized-DN>.json 中,5 字节的 .json 后缀与规范化 DN 共用同一个文件名分量预算:250 个 ASCII 字节加 .json 刚好 255 字节,251 个字节加后缀即达到 256 字节并触发超限。
两处历史实现细节进一步加剧了症状的隐蔽性:
checkPathLength在 Unix 上漏计反斜杠:SILO 的路径长度预检查函数checkPathLength在 Unix 系统上会跳过反斜杠(\)字符。复现样本中 271 字节的转义 DN 被checkPathLength算成了 197 字节,从而绕过预检查,直到真正访问操作系统文件系统时才报错。- 结果随祖先目录是否存在而变化:如果
config/iam/users/前缀目录尚未在磁盘上创建,操作系统内核在解析父目录时就会直接返回ENOENT(文件不存在),根本不会走到后面的超长<normalized-DN>分量——此时登录反而返回HTTP 200成功。一旦集群中曾经写入过任何用户记录、使config/iam/users/目录落盘存在,内核就会继续解析超长子分量并返回ENAMETOOLONG,导致同一用户的登录立刻变成HTTP 500。
| 历史提交 | 在故障链中的作用 |
|---|---|
MinIO ee9077db(2020-05) |
在 checkPathLength 预检查中引入 Unix 下忽略反斜杠的计数逻辑。 |
MinIO 280526ca(2024-03) |
策略缓存未命中时不再忽略磁盘回读错误,而是将读取错误向上传播。 |
MinIO efb73fa5 与 7e45d84a(2024-03) |
将身份与策略映射统一改为规范化 DN,导致非 ASCII 字符按 \xx 展开。 |
SILO 709d50a9(Server 20260916) |
为支持持久化撤销与防重放,在登录流程中新增父身份版本读取(config/iam/users/<DN>/identity.json),使每次 LDAP 登录都会必经该读取路径。 |
修复方案:仅将确定无法落盘的纯 ASCII 超长分量归类为不存在
修复集中在对象存储 IAM 的共享底层读取函数 loadIAMConfigBytesWithMetadata 中。函数首先按原流程尝试读取;仅当读取返回错误,且同时满足以下三个条件时,才将该错误归类为 errConfigNotFound(记录不存在):
- 底层错误为
errFileNameTooLong或ObjectNameInvalid(含标准包装错误); - 完整对象键通过常规的桶名与对象名合法性校验(
isValidBucketName与isValidObjectName); - 至少存在一个按
/分隔的路径分量完全由 ASCII 字符构成(utf8.RuneCountInString(component) == len(component)),且字节长度超过 255(len(component) > 255)。
由于超过 255 字节的纯 ASCII 路径分量在对象存储后端从一开始就不可能写入成功,磁盘上绝不可能存在对应的历史记录。将其转换为 errConfigNotFound 后,上层调用者即可安全进入既有的“无此可选记录”分支——例如继续执行 LDAP 认证并通过用户所属的 LDAP 组策略完成授权——既不会凭空授予任何默认权限,也不会跳过对可存储身份的父版本撤销检查。
写入、删除、etcd IAM 后端以及全局对象路径长度检查均保持不变。核心生产代码仅在 cmd/iam-object-store.go 中新增 25 行(配套新增 318 行回归测试),正常读取路径零额外开销。
为什么要收窄初版补丁的错误分类范围
初版 PR 将任何语法合法键上的 ObjectNameInvalid / errFileNameTooLong 统统归类为 errConfigNotFound。在合并前的设计复核中,我们构造了两个具体反例,证明“读取报长度错误”并不总能推出“该记录从未在磁盘上存在过”:
- 完整路径超限(
PATH_MAX)掩盖已落盘的真实撤销记录:假设管理员先撤销了一个各路径分量均不超过 255 字节的合法身份(撤销墓碑已成功落盘),随后在 macOS 等环境上将数据目录挂载或迁移到了层级更深的父目录之下。此时相对路径下的各个分量依然合法,但完整绝对路径触发了系统的总路径长度上限而导致读取失败。如果把这类错误也吞成errConfigNotFound,就会直接掩盖磁盘上真实存在的撤销墓碑,让已被撤销的身份重新通过检查。限定检查单个/分量len(component) > 255后,完整路径超限仍会按 Fail-Closed 向上报错。 - 按字符数计长的多字节 Unicode 文件名(如 macOS APFS/HFS+):不同文件系统对 255 上限的定义并不相同。在 macOS 实验探针中,由 100 个汉字
界组成的单个路径分量虽然占用 300 个 UTF-8 字节(len(component) == 300,但字符数仅为100),却能够在 macOS 文件系统上成功创建并落盘。如果仅凭len(component) > 255就断定记录不可能存在,就会把这类已经落盘的多字节 Unicode 记录误判为不存在。加上utf8.RuneCountInString(component) == len(component)限制后,该规则仅对纯 ASCII 分量(天然涵盖所有\xx转义后的 DN)生效,而在任何平台上 256 个以上 ASCII 字符都无法存入单分量上限为 255 的文件系统中。
由此确立了严格的安全边界:只有被证明在物理上绝无可能写入成功的路径分量,才允许走 errConfigNotFound 分支;任何可能已经存在但当前无法读取的撤销记录,一律保持报错拒绝。
备选方案与取舍
| 候选方案 | 决策与原因 |
|---|---|
在 loadIAMConfigBytesWithMetadata 中对 >255 字节的纯 ASCII 分量做窄范围缺失分类 |
采用。 一次性覆盖所有共享该读取器的 IAM/STS 调用点,同时对完整路径超限和可落盘的 Unicode 名称保持 Fail-Closed。 |
| 将合法键上的所有名称长度错误一律视作不存在 | 否决。 当数据目录迁移到深层路径触发完整路径上限时,会掩盖磁盘上已有的真实撤销记录。 |
按解码后的字符数计长,或扣除 \xx 转义开销 |
否决。 操作系统文件系统实际存储的就是展开后的字面量字节,修改软件侧计长无法改变内核限制。 |
仅修复 checkPathLength 在 Unix 上漏计反斜杠的问题 |
不充分。 修补预检查只会让同一个长 DN 更早在入口被 ObjectNameInvalid 拒绝,无法恢复登录。 |
修改 NormalizeDN 保留原始 UTF-8,不再做 \xx 转义 |
暂不采用。 会改变存量集群已落盘的身份与策略映射键名,引发兼容性断裂,且依然无法解决更长 DN 的分量超限。 |
对 LDAP 跳过父身份版本检查或回退 709d50a9 |
否决。 会丧失持久化撤销与防重放能力,且依然会在更早的 sts-users/<DN>.json 策略映射读取处失败。 |
| 使用固定长度哈希键存储,将完整 DN 存入文件内容 | 属独立特性范围。 若需支持超长 DN 的持久化写入,需统一设计查找、列举、导入导出、跨站点复制与滚动升级方案。 |
测试验证矩阵
| 验证层级 | 覆盖范围与结果 |
|---|---|
单元与边界回归测试(cmd/iam-revision_test.go) |
覆盖转义后 271/384 字节 DN、255/256 字节临界值、.json 后缀计入、多字节 Unicode 分量、包装错误、非法对象名,以及迁移至深层路径下的真实撤销记录保留报错。 |
端到端 LDAP STS 登录测试(cmd/sts-handlers_test.go) |
验证规范化后长达 384 字节、通过组策略授权的 LDAP 用户在对象后端与 etcd 后端共 8 种配置下的 STS 登录、S3 签名访问与服务账号操作。 |
| 跨平台物理文件系统边界探针 | 在 macOS(可创建 49 组)与 Linux tmpfs(可创建 32 组)上实测 72 组物理文件名:所有能落盘的名称均未被误判为“不可能存在”;并在每个平台上验证了 105 项错误传播断言(5 类父身份名称 $\times$ 7 类存储错误 $\times$ 3 个调用入口)。 |
| CI 与工具链漏洞检查 | PR 提交 3df51549bbbd6e57a6a5f11ac77535390517db65(合并为 a07d655dc69cf04b130c2634d9fa5301078af546)及合并提交 d43adc0f2 通过全部 Go CI 与 VulnCheck 任务(同时将最低 Go 工具链提升至 Go 1.27.3 以修复标准库证书校验漏洞 GO-2026-6809)。 |
运维边界与配置建议
- 读取与写入的能力边界:本次修复恢复了长 DN 用户的登录与只读查询路径,但对象存储 IAM 后端依然不支持写入单路径分量超过 255 字节的文件。直接向规范化后超过 250 字节的个人 DN 绑定策略(写
sts-users/<DN>.json)或写入父身份版本墓碑(写users/<DN>/identity.json)仍会因文件名超限而失败。 - 推荐使用 LDAP 组授权:在使用对象存储 IAM 后端的集群中,对于包含中文或长路径的 LDAP 用户,应通过绑定到 LDAP 组(Group)的策略进行授权(确保组 DN 规范化后不超过 250 字节),避免直接向超长个人 DN 绑定独立策略。
- 无需数据迁移:升级无需执行任何磁盘数据迁移。切勿通过手动删除
.minio.sys/config/iam/下的目录来规避路径报错;多站点升级请遵循 IAM 升级与恢复手册。