上个月 dev.to 上有篇文章,作者用 Node.js 和 Electron,不沾任何外部 HTML/CSS/布局库,从头写了个能渲染 info.cern.ch 的浏览器。三个月,渲染结果和 Chrome 并排对比难分彼此。这故事本身挺好看,但真正值得看的不是“他做到了”,是他为了做到,亲手踩进去的两个坑。这两个坑放在浏览器里只决定标题加不加粗,换成别的地方,是能拿权限的那种。
先说他踩的第一脚。Courage 渲染 info.cern.ch 时,h1 没加粗,链接只有下划线没有默认蓝色。作者查下来,原因是默认样式规则在计算样式快照之后才应用——快照生成的时候样式表还是空的,于是 computedStyles 拿到一个空对象,而空对象在 JavaScript 里是真值。渲染器读这个空快照,直接把它当成了合法结果。整条逻辑的鬼魂就藏在这一行形状里:
const styles = computedStyles(node);
render(node, styles || defaultStyles);
computedStyles 返回的是 {},{} 为真,|| 后面的默认样式永远轮不到执行。这个 bug 不需要什么精妙的并发问题,就是“早退了一步”加“空值恰好合法”两个常规失误叠出了个看起来莫名其妙的画面。作者后来把管线顺序调了,先应用默认规则再算快照,画面就对了。
但这东西往深看一层,很不妙。渲染器拿一个空快照做判断,程序自认为“我拿到了完整样式”,实际上拿到的是空气——后续每一步都建立在空气上。渲染器是无辜的,画面丑一点而已。换成一个权限判断呢?换成一段“读取当前会话的角色列表,为空则放行”的逻辑呢?
换成攻击者的思路:我不需要攻破你的主流程。我只需要让某个关键判断在某一刻读到一份“恰好合法”的假状态——比如空列表、空对象、未初始化的 scope——然后让整个系统基于这个假状态做后续决策。空值是最难防的伪装,因为大多数代码直接把它当正常值处理了。空数组可以被 include 判过,空对象可以溜过一系列属性访问,null 会崩,[] 和 {} 不会。你的程序在错误的地基上跑得出奇地顺,直到它拿着这个地基去信任别的东西。
权限校验里最经典的一种死法就是这种:allowlist 从配置里读出来,反序列化得到空数组。代码查“这个 IP 在不在白名单里”,空数组当然查不到——但查不到的结果是拒绝,这还好。怕的是查“这个账号在不在封禁名单里”,空数组等于谁都没封。两种情况都是“读到了空快照”,一边安全一边致命,全看空值被解释成哪种语义。从零构建浏览器的人踩的坑,和做权限系统的人踩的坑,是同一种:默认状态失真时,系统不会报错,它只会静默走一条你没想到的路。
第二个坑更有意思。链接没有默认蓝色,查下来是渲染器的颜色填充只检查父节点样式,不检查节点自身。作者把颜色取值逻辑改成优先用节点自身的颜色,再回退到父节点和默认颜色,问题解决。但他修的是症状,评论区有人把病根点出来了——Wren Calloway 那条评论说得很准:颜色是继承属性,只查节点和父节点是捷径,真正的继承得沿整个祖先链解析,而且应该在 computed-styles 阶段完成,不是渲染到一半才现查。作者修的是“我这一个 case 下能用”,Wren 说的是“你这条规则得在整棵树上成立”。
只检查父节点,本质上是个信任问题:你把“最近的那一层”当成了“全部的依据”,中间省掉了整条链。做前端看到这个 bug 会笑——谁会只查父节点啊,inherit 就是沿着祖先链一路往上找的。但把这句话换个主语你就不笑了。agent 读文档时只信任上一段消息的指令来源,不去回溯整条对话链的状态;鉴权函数只验证直接调用者的角色,不查这个角色是从哪继承来的;沙箱只检查当前进程的权限,不理它的父进程挂载了哪些宿主机目录——“只检查父节点”这四个字,翻译成安全语言就是“信任检查错层级”了。攻击者不那么挑剔,你查哪一层他就在哪一层埋伏。
——离题了,收。我想说的是:这两个 bug 的修复方式其实是一类——把“只看最近一层”改成“沿链条找到真正的源头”,把“先算快照再应用默认值”改成“默认值先落地再生成快照”。前者是继承链的问题,后者是默认值时序的问题。这俩恰好是安全分析里最常出事的两个位置。
再看作者下一步计划:支持 CSS 属性选择器,比如 [data-color-mode="dark"],目标是把 GitHub 渲染出来。到这个点上,这个项目的角色悄悄换了——之前它渲染的是 info.cern.ch 这种没有作者自定义样式的静态页,你能拿它测默认样式;一旦它开始解析任意网站的 CSS,它的攻击面就从“作者的代码”扩展成了“整个开放网页”。属性选择器是一种匹配路径,匹配路径是页面作者可以探测的一面镜子:哪条规则被匹配、匹配到什么顺序,对渲染引擎来说是内部逻辑,对页面来说是可观测信号。这个我一开始想按高危处理,后来想想,这项目连 JS 执行引擎都没有,最实际的触发链断在最前面——没执行就没利用,级别降下来。但这条链是迟早会长出来的。“没有 JS”是“还没实现”,不是“已设计好”;哪天他真把执行引擎接进去,需要同时想清楚的就不只是 CSS 怎么匹配了,还有“谁提供的规则可以被信任”这件事。
拆弹清单,就三条:
- 默认值按攻击面审。 凡是“读配置读了个空、然后空值能走通”的路径,把空值当成一个显式状态处理——空列表是合法的“什么都没有”,和“读失败了”必须分开对待。别让
{}在判断里当大爷。 - 继承链不要用捷径。 只查一层就下结论的地方,全列出来过一遍。颜色继承写错最多画面丑,角色继承写错是越权。
- “还没实现”不是“已设计好”。 一段还没接上的能力、一个还没做执行引擎的解析器,看着安全,实际上只是触发链暂时断在最前面——它迟早会接上,但你对它的信任假设不会自动跟着更新。
这不是在说作者的项目有洞。从零构建一个渲染管线,然后把默认样式和继承这两个最基础的问题亲手踩一遍,反而是划得来的——至少他以后知道,哪一步没跑的时候系统会静默往哪走。做 AI 编程的人缺的恰恰是这个:不知道模型默认权限是什么,不知道工具链默认会读哪些文件,不知道 allowlist 没配置时是拒绝还是放行。你不需要自己造一个浏览器,但你总得知道,你手上这套东西的默认快照,是不是空的。
