一个连字符,两个租户,一把签名密钥
我们有两个租户其实是同一个租户。它们名字不同、注册记录不同、计费行也不同。可它们却共用一个数据库和一把令牌签名密钥,而我们三方,那两个租户和我们自己,谁都浑然不觉。这是一个关于把它们合并的一行函数的故事,关于为什么在事情发生时系统的每一层看上去都完全正确,也关于为什么上线前的一次审计是你能买到的最便宜的保险。
多租户认证有一件绝对不能搞错的事:把租户彼此隔开。Acme 的用户、Acme 的会话,最重要的是 Acme 的签名密钥,绝不能被任何其他人触及。签名密钥是皇冠上的明珠。谁能用 Acme 的密钥签名,谁就能铸造一个连 Acme 自己的认证服务器都会当作真实接受的令牌,可以是任何用户、任何角色,无需密码。所以我们创建的每一个按租户划分的资源,每一张存储表和每一把密钥,都以租户的 slug 作命名空间。把这个命名空间做对,租户之间就是一座座孤岛。做错了哪怕一点点,它们就会悄无声息地变成同一个地方。
那一行的 bug
下面这个函数把租户 slug 转换成我们用来命名其存储和密钥的前缀。读一读那段文档注释。它把这个 bug 当作一个特性写了进去。
/// Derive the table name prefix from a tenant slug by stripping hyphens.
/// E.g. "acme-corp" → "acmecorp".
public static string GetTablePrefix(string tenantSlug)
{
return tenantSlug.Replace("-", "");
}
Replace("-", "")。它去掉连字符。本意是做些整理:slug 会流入 Azure Table 名称和 Vault 密钥名称,而这两者有各自的字符规则,所以我们对它们做了清洗。麻烦在于,去掉字符是一种有损变换,而对标识符做有损变换并不是单射的。acme-corp 和 acmecorp 出来都是 acmecorp。ac-me-corp 和 acme--corp 也一样。不同的 slug,同一个命名空间。
那个命名空间决定了下游的一切。用户表是 {prefix}-Users。而按租户划分的签名密钥,原封不动,就是这个:
private string GetKeyName() => $"signing-{ShardRouter.GetTablePrefix(_tenantContext.Slug)}";
所以 acme-corp 和 acmecorp 不只是共用一份用户列表。它们用 Vault 中同一把 signing-acmecorp 密钥给自己的令牌签名。为其中一个铸造的令牌,逐字节地看,对另一个也是一个签名有效的令牌。如果你能注册一个折叠成与某个现有租户相同前缀的 slug,你就能给自己签发它的认证服务器完全信任的令牌。跨租户账户接管,而漏洞利用方式不过是"注册时带个连字符"。
为什么没有任何环节拦住它
最令人不安的地方在于,每一层看上去都那么平平无奇。注册环节校验了 slug,看到的是一个全新、未被使用的字符串。预配环节创建了一条以完整 slug acme-corp 为键的租户记录,在控制平面里它和 acmecorp 确实是不同的。令牌校验环节从 slug 推导出密钥,然后愉快地通过了验证。每个组件对自己接到的输入都做对了本职工作。
系统中从来没有任何环节比较过两个 slug 的前缀,因为没有任何单一组件拥有"一个 slug 恰好映射到一个命名空间"这条不变式。这次碰撞就活在 slug 与前缀之间的缝隙里:控制平面以 slug 为键,而存储和 Vault 以前缀为键。没有人站在那道缝隙上。这正是最危险那一类 bug 的标志:不是谁忘了加某个检查,而是一个谁都不知道自己正在做的假设。
顺带送的第二次碰撞
同一个有损变换还有第二个受害者。我们内部的系统租户以后缀命名:{slug}-admin 存放租户的门户团队,{slug}-sandbox 存放其测试环境。把它们丢进同一个去连字符函数,acme-admin 就变成了 acmeadmin。这意味着,一个注册了 slug acmeadmin 的客户会折叠到 Acme 的 admin 租户的前缀上,而那个地方本应比客户更有特权,而不是与某个客户共用。
一个去字符的函数,两条不同的隔离边界岌岌可危:租户对租户,以及租户对它自己的控制平面。当一行代码同时威胁两条互不相干的边界时,这就是根因 bug 而非表面 bug 的破绽。修复必须落在那个变换上,而不是落在任何一个症状上。
修复方案:从构造上保证单射
直觉是把前缀函数做得更聪明。转义连字符,对 slug 做哈希,用 base32 编码。可这些里头的每一个,仍然是一个你必须永远证明其单射的变换,要对抗未来的每一次改动,而做这件事的人可能根本不知道它为什么重要。更便宜也耐用得多的修复,是彻底剥夺这个变换的自由度:约束输入,让这个变换就是恒等变换。
// Lowercase alphanumeric ONLY, no hyphens. Forbidding hyphens makes prefix == slug,
// so distinct slugs can never share a data store or signing key.
if (!slug.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
return false;
slug 现在只能是小写字母和数字,别无其他。没有连字符可去,GetTablePrefix 就无事可做,prefix == slug 从构造上成立,两个不同的 slug 再也不可能共用一个命名空间。我们同时还拒绝保留名称,以及任何以 admin 或 sandbox 结尾的 slug,这在同一处就堵住了系统租户的碰撞。有效性校验正是 slug 第一次变成命名空间的那个狭窄关口,所以这一对一保证就该落在那里。
随后我们在预配的咽喉处再次重申同一条规则。slug 能进来的门有两扇:自助注册和管理员驱动的预配;而一条只在两扇门中的一扇上把守的安全不变式,等于两扇门都没守。
我们为什么要告诉你
我们是在对自己产品做上线前安全审计时发现这个问题的,那时还没有一个付费客户,而那正是发现它唯一可以接受的时机。生产环境中从未有任何租户被合并过。但这是一个让人谦卑的 bug,因为它不是漏掉的检查,也不是脆弱的算法。它是一个做了一件完全合理的事的辅助函数,清洗一个字符串,只是在那个场景里,"合理"和"单射"原来是两个不同的词。
教训比这次修复活得更久。任何把用户可控的输入转换成某条安全边界名称的函数,无论那是一张表、一把密钥、一个命名空间还是一条路径,都必须是单射的,而且你应该在这个映射被创建的最狭窄的那一点上强制它,而不是寄望它能在下游的每一层都安然存活。一个规范化步骤,小写化、去空格、剥离、合并,恰恰就是两个身份悄悄合而为一的地方。
这正是整个产品赖以建立的同一条原则。安全不是一个你逐级晋升才能进入的档位,它是地板。我们交付的每一项隔离保证、每一把签名密钥、每一个安全特性,SSO 和 SAML、SCIM、MFA、审计导出,在每一个套餐里都默认开启,因为另一种选择,是你会在凌晨两点而不是在审计中遇到的那类东西。看看都包含了什么。