02安全5 min

把自己的代码当成别人的来审计,我学到了什么

一个空 ACL 返回了整个语料库,一次不需要一行 JavaScript 的 CSS 注入,还有一把在账号注销后仍然有效的 API key。全都在一天之内,全都在我自己写的代码里。

作者 Salva Castells

封面图: Ildar Sagdejev (Specious), CC BY-SA 3.0.
本文目录

一天之内,我在一个已经跑了几个月的转录 API 上修掉了六个安全漏洞。没有一个是测试发现的。测试在修复前后都是绿的。变的不是工具:而是我坐下来读自己的代码时,问的是"如果我想搞垮它会怎么做",而不是"它有没有做我想的事"。

这个差别看起来像态度问题。其实是方法问题,而且体现在冒出来的漏洞类型上。

测试检查的是你想象过的那条路

测试是从用例出发写的:合理的输入,合理的输出。攻击者不会给合理的输入。他给的是空、零、负数、超大值。这些值很少出现在测试套件里,因为写测试的时候你想不到,等你想到的时候你已经看见那个 bug 了。

最清楚的例子是一个空 ACL。检索器收到一份用户有权访问的标识符列表,用它来过滤语料库。如果列表是空的,代码把它理解成"没有过滤条件要应用",而不是"这个用户什么都无权访问"。于是它返回了整个语料库。

这是 fail open:权限缺失时的默认值是全部放行。任何 happy path 测试都碰不到它,因为在 happy path 里列表总是有内容的。

从那以后我用同一个问题检查所有授权过滤:如果集合是空的,这段代码会怎么样? 不是"如果用户没有权限会怎么样",那个通常覆盖得不错,而是它拿空值怎么办。

这些类别重复得比你以为的多

发现的问题几乎总能归到同样几个家族。

  • 权限没有在该要求的地方要求。 一个导出端点允许在不要求结构管理权限的情况下把数据拿走。一个领域的级联删除只在开头检查了一次权限,而没有对每个受影响的元素检查硬删除权限。级联是经典位置:你授权了这个操作,然后内层循环做了一堆没人授权过的事。
  • 不存在的上限。 管理后台列表把收到的 limit 直接传给查询,没有天花板。从脚本传一个大值,会把整张表在 worker 内存里物化。修复只要一行,Math.min(Math.max(limit, 1), 1000),但它当初不在,因为在 UI 里 limit 永远是十。
  • 只检查一次、之后再也不查的状态。 authenticateApiKey 校验了哈希、过期时间和 key 的 isActive 标志。但 key 那一行比它主人的账号状态活得久:如果注销某人时清掉了计费记录,没人会把这个标志置下来,key 就还能继续认证。现在这个函数会重新校验所有者是否仍有计费记录,只写在一个地方,好让所有用 key 认证的端点都继承它,而不用各自记得去做。
  • 被渲染的第三方内容。 这里有最让我意外的一个。

CSS 也是一种注入

Markdown 清洗器允许 div、span 和 svg 上自由的 style 和 class 属性。链路是 raw 到 sanitize 再到 harden,没有一个阶段会看 CSS 内部:清洗库把 style 当作不透明字符串,加固器只重写 a 和 img 上的 URL。样式原封不动地到达了最终渲染。

我想过写一个带属性白名单的 CSS 解析器。我没写:我确认了没有任何正当视图需要 style,于是把这两个属性从允许列表里去掉了。自建的 CSS 过滤器是要维护的新攻击面,而它覆盖的场景根本不存在。

这个发现重要的不是修复,而是攻击向量的类别。攻击其他用户不需要 JavaScript:用 CSS 你可以盖住一个按钮、把一段文字挪到另一段上面,或者改变一条消息看起来在说什么。而任何会渲染他人内容(不只是自己内容)的视图,都是跨用户向量。

日志里的秘密,和活得太久的链接

有两个发现不是逻辑 bug,而是生命周期问题。

第一个:当 API key 认证因为 key 损坏或格式错误而失败时,catch 把原始 key 放进了日志上下文,而 logger 用 JSON.stringify 序列化这个上下文,不做任何脱敏。一把发到错误端点的有效 key 会以明文落进日志。我加的脱敏从两个方向下手,按字段名(/rawkey|token|authorization|password/i)和按值的形状(前缀 dk_live_ / dk_test_),并且只保留前八个字符后面跟 ***REDACTED***,和 UI 在显示一次之后重新遮蔽 key 的格式一致。

第二个:音频播放器用的 S3 签名 URL 生成时 expiresIn 是七天。对一个只需要活到 <audio> 加载完成的链接来说,七天是永恒。任何拿到这个链接的人——被转发的、在历史记录里的、被企业代理缓存的——在整个窗口内都保有对原始音频的访问权,而删除转录并不会吊销任何东西。我把过期降到 600 秒,客户端在 480 秒时续签,并且在删除转录时加上了针对对象的 DeleteObjectCommand,因为在那之前级联只删了那一行。

用户体验上的代价是零,除了一个流程:把音频链接转发邮件给同事。那个流程从来不是受支持的功能,也完全没有访问控制,我觉得它不能用了是对的。

现在我把它变成流程,而不是靠想起来

审计的问题在于,它取决于你哪天有兴致。我这次是因为在记录设计决策,重读的时候觉得有些地方不对劲。

每个发现都会变成 docs/security/ 里的一个短文件,写清楚攻击向量以及为什么修复是这样而不是那样,从中再产出我用来检查其余部分的清单。当我对我起项目用的 boilerplate 重复这个练习时,冒出了三十九个点,涵盖安全、SEO 和架构,而大多数都是同一小撮问题的变体:空列表会怎么样、内层循环里谁来要求权限、这个上限的天花板是多少、这个链接能活多久。

为了确认代码能用而读它,和为了看它怎么被搞垮而读它,是两种不同的阅读。做第一种的时候,第二种不会自己发生。