一次外部安全审查带来的 26 处修改
上线前请人把代码审了一遍,拿到 26 条问题:认证、权限、上传、并发各有一些。这篇挑几条讲清楚问题出在哪、为什么这么改。
上线之前请人把代码审了一遍。拿回来 26 条问题,按严重程度分了三档,逐条处置。
这篇不打算按清单罗列,只挑几条讲清楚问题出在哪、为什么新版这么改——因为能复用的不是结论,是那几步推理。
一、空集合该表示「什么都没有」
访问令牌(PAT)带权限范围(scope),签发时要指定。第一版没定义「不指定 scope」是什么意思。
这个歧义很危险,因为两种读法都说得通:一种认为「没限制就是全给」,另一种认为「没勾就是没给」。前者是很多系统的默认直觉,也是很多事故的来源。
新版的规则是:scope 与用户自己的权限取交集,只能收窄,空 scope 表示没有任何权限。
理由不是「这样更安全」这么笼统,而是一个可复用的判断:在收窄语义下,缺省值必须是空集。 一个用来限制权限的字段,缺省成「全部」意味着任何一次漏填都变成提权。反过来,缺省成空只会让人多填一次。
二、匿名登录也要花同样的时间
这是那一批里最有意思的一条。
登录接口对不存在的账号也得跑一次等价开销的哈希校验——否则「立刻失败」和「算了一会儿才失败」的差别,就是一个账号枚举接口。 这个做法是对的,很多系统都这么做。
问题是它把成本交给了攻击者控制:argon2id 按当前参数一次要吃 64 MiB,而匿名请求即便账号不存在也要走一遍。几百个并发登录请求就是几百份 64 MiB,一个小请求就能把内存打满。
真正的防护是登录限流(单账号 15 分钟 8 次、单 IP 15 分钟 40 次),但限流需要在内存里记账、需要先走到那一步,而限流被绕过或还没触发的那个瞬间,闸门必须还在。
所以加了一个进程级并发额度:按 CPU 核数取、封顶 8。额度用尽时直接返回「繁忙」,返回 503/429,而不是排队等待。
不排队是刻意的:让极少数请求快速失败,比让它们堆在队列里占着连接和内存把整站拖垮要好。 这两种失败的区别是「几个人重试一次」和「所有人都打不开」。
顺带一个同批的细节:密码校验完之后才检查账号是否停用。反过来的话,攻击者用错误密码就能从响应差异里探知哪些账号存在。
三、把「能写 HTML」从「能发文章」里拆出来
原先的模型里,能发文章的人就能写任意 HTML——包括 <script>。
这在多人站点上是说不通的。编辑需要发文章,但让编辑能对管理员执行脚本,等于让团队里权限最低的人掌握最高的执行能力。
修法是新增一条独立权限 content:unsafe_html,默认只给管理员与超级管理员。没有它的角色,正文在保存时按允许列表净化:保留排版、表格、代码块、远程 iframe,去掉脚本与事件属性。
两个细节值得说:
- 净化发生在保存时,不是渲染时。 渲染时净化意味着同样的内容每次请求都要重算一遍,而且只要有任何一个渲染路径漏了这一步就前功尽弃。
- 原稿字段
raw不净化。 因为编辑者要能看到并修改自己写的东西,而净化后的版本是白名单过滤的结果——在它上面继续编辑会不断丢失内容。
四、闸门放错了一层,等于留了一扇后门
邮箱未验证的账号不该能登录。第一版把这个检查放在了前台账户模块的登录处理器里。
问题在于登录有两条路径:前台的 /login,和后台 Console 的 /api/v1/console/auth/login。只在其中一条上设闸门,另一条就是敞开的——不是恶意的绕过,只是「换个入口就进去了」。
修法是把闸门挪进认证 Service,让两条路径都从它下面过。
这条的通用教训:当一个校验逻辑被写在「某个入口」而不是「某个能力」上,它的正确性就取决于你记不记得所有入口。 而这类记忆一定会漏,通常是在加了第三个入口的时候。
五、下线的内容,评论也必须下线
一篇文章设为下线之后,文章页 404 了,但它的评论接口仍然可达。
单看影响不大——一条评论而已。但它构成了一个信息泄露面:你可以通过枚举评论接口,拿到那些「本不打算再对外」的内容上的讨论,包括评论里的用户信息。
修法是下线内容的评论一律 404,不区分「评论不存在」和「内容已下线」。这两种情况给不同的响应,本身就是一种泄露。
六、迁移锁不能用业务连接池
数据库迁移用 PostgreSQL 的 advisory lock 保证同一时刻只有一个实例在跑迁移。第一版用的连接池是共享的。
后果是:迁移在等锁的时候,业务连接池里的连接被占着。 池满之后,迁移在等一个永远等不到的锁,业务在等一个永远拿不到的连接,两边互相卡死,而且日志上看不出关联。
修法是给迁移锁一个容量为 1 的独立连接池。需要长时间持有、且与业务生命周期无关的连接,就不该和业务共用池子——池隔离比调大池子有效,因为问题从来不是池子不够大。
七、停机时邮件既不能丢,也不能无限等
进程收到终止信号时,发信队列里可能还有已入队但没发出去的邮件。
直接退出会丢信,无限等待会卡住部署——systemctl restart 因为一封发不出去的邮件挂十分钟,运维会把整个队列关掉。
做法是分两段:先停止接收新请求并排空 HTTP(有超时),返回之后才开始关闭模块,邮件队列在这个阶段做限时排空。整个预算加起来的耗时,就是容器编排那边 stop_grace_period 该设的值。
八、凭据变了,旧会话必须立刻死
改密码、重置口令、停用账号这三件事发生时,该用户已经签发的会话与访问令牌立即全部作废。
这条不是「更安全」的泛泛之谈,而是针对一个非常具体的场景:账号疑似被盗时,用户的第一反应是改密码。如果改完之后攻击者的会话还活着,那用户做了正确的事却没有得到正确的结果——这比没有这个功能更糟,因为它会让人以为处理完了。
顺带一提,库中只存会话与令牌的 SHA-256,不存明文。库被读走时,攻击者拿到的是哈希而不是可以直接用的凭据。
最后,一个不太好听的事实
这 26 条修复当时每一条都带了回归测试。
但 2026-09-15,仓库里的自动化测试被整体清空了。所以这些修复还在,它们的回归测试已经不在了。
这篇讲的每一条,现在都只有代码本身作为证据。这也是为什么路线图里把「没有自动化测试」列为当前最大的短板——安全修复的价值,很大一部分在于它不会在半年后的某次重构里被悄悄改回去,而这件事只有测试能保证。
评论
还没有评论,来说两句。