测试全绿的扩展,仍被 AI 找出安全漏洞
作者的 Raycast 发票预览扩展通过了单元测试和类型检查,自动审查却提出四项安全问题。已展示的案例涉及未经运行时校验的 JSON 字段进入 HTML,以及 Markdown 注入形成追踪像素。
AI 深度解读
一个粘贴发票 JSON、生成 HTML 和 Markdown 预览的 Raycast 扩展,渲染逻辑零依赖,7 个单元测试通过,tsc 检查也没有报错。提交审核后,自动审查工具 Greptile 几分钟内留下 9 条评论,其中 4 条被标为 P1/security。
这个案例值得读,不是因为 AI 找到了多复杂的漏洞,而是它揭开了一个常见盲区:测试覆盖了开发者想象中的输入,却没有检查输入越过信任边界后会发生什么。类型、文本和文件名,恰好都可能在这些边界上失效。
TypeScript 的 number,挡不住 JSON 里的字符串
扩展从用户粘贴的内容中调用 JSON.parse 获取数据。作者把 tax_rate 声明为 number,随后便按数字使用,并让它未经转义进入 HTML 模板。
但用户可以提交这样的字段:"tax_rate": "0\"><script>alert(1)</script>"。类型声明不会在运行时把这段字符串拦下来。作者转义了自己认定为字符串的字段,却信任了这个看起来应该是数字的字段。
类型检查通过,不代表外部输入已经通过校验。
同样的误判也出现在数组上。作者只检查了 Array.isArray(data.items) && data.items.length > 0,下面的数据就能通过:
{"items": [null]}
这里的关键是 null:容器确实是非空数组,但读取元素的 .qty 时,computeTotals 仍然会崩溃,整个预览随之失败。
作者增加了 sanitizeItems(),在数据进入渲染器前统一做类型转换和过滤。这个方向成立,不过原文没有给出完整修复代码,尤其没有说明 tax_rate 最终如何校验,也没有交代非法行被过滤后如何提示用户,不能仅凭函数名判断所有边界都已覆盖。
同一个「文本字段」,进入不同渲染器就有不同风险
第二个问题出在 notes 字段。它被直接送入 Raycast Detail 的 Markdown 预览,作者虽然处理了 HTML 特殊字符,却没有处理 Markdown 语法。
当备注包含指向远程地址的 Markdown 图片标记时,预览会把它识别为图片,并向指定地址发起 HTTP 请求。这里不需要执行脚本,单是加载远程资源,就能成为跟踪像素。
HTML 转义解决不了这个问题,因为 Markdown 有自己的特殊字符集合:反引号、* _ { } [ ] ( ) # + . ! | < > -。作者因此增加了与 HTML 转义函数并列的 escapeMarkdown()。
对开发者来说,关键不是记住一张字符表,而是先确定字段允许表达什么。如果备注只应显示普通文字,就要阻止它变成图片、链接等可执行的渲染指令;如果产品允许 Markdown,则需要进一步明确允许哪些语法。原文没有讨论后一种设计。
文件名过滤了,为什么还会丢数据
保存逻辑看起来已经做了防护:把发票编号中的特殊字符替换成下划线,再拼成文件路径。
// before
const file = path.join(dir, `${(data.invoice_number || "invoice").replace(/[^a-z0-9-_]/gi, "_")}.html`);
fs.writeFileSync(file, html, "utf8");
要注意第一行的 .replace()。INV/001 和 INV:001 都会变成 INV_001.html,第二次保存便会静默覆盖第一次的文件。
字符过滤只规定了名字可以包含什么,没有保证不同发票拥有不同名字。
作者在 writeFileSync 前增加了重名检查,并用 try/catch 捕获保存失败,通过 toast 提示用户。四个问题的修复合计大约 40 行。改动不大,漏掉的却涉及脚本注入、远程请求、预览崩溃和文件覆盖,后果并不相同。
审核还在第二轮发现了回归测试的问题:针对空元素添加的测试,把 @ts-expect-error 放错了位置,导致类型检查在本该失败时通过。原文没有提供具体测试代码,无法进一步判断误放位置如何影响检查,但它提醒了另一件事:新增测试也需要审核,绿灯本身不是充分证据。
我的判断
作者最有说服力的地方,是提供了能直接复现的输入。恶意字符串、[null] 和两个冲突编号,都能明确指向代码中的信任假设,比泛泛建议「加强安全意识」有用。
但这仍是一次个人项目的审查记录。Raycast 对进入仓库的 PR 运行 Greptile,这次得到 9 条评论、4 条 P1/security,不足以证明 AI 审查优于人工审查,也不能推导出它的误报率或漏洞检出率。原文没有对照数据,这部分只能视为经验。
我认同把 AI 审查加入流程,但更看重它能否把问题转成可复现用例。运行时校验、针对输出格式的处理、回归测试和人工复核,仍然各有职责。尤其涉及金额的数据,「转换并过滤」还需要解释清楚:错误数据是拒绝、修正还是忽略,不能只以页面不崩溃为验收标准。
能拿来做什么
- 在处理粘贴 JSON、导入文件或接口响应时,把运行时校验放在渲染和计算之前。除了检查数组,还要检查元素是否为空、字段类型是否符合要求。
- 检查前端富文本预览或 Markdown 展示中的普通文本字段,确认它们是否意外支持远程图片;分别验证 HTML 和 Markdown 输出,避免共用一种转义就算完成。
- 给 Node 文件导出补上名称冲突用例,直接用
INV/001、INV:001验证保存行为,并检查写入失败时用户能否看到明确反馈。 - 让 AI 编程助手专门寻找能破坏现有假设的输入,并要求给出复现步骤。采纳修复后,同时审核回归测试和
@ts-expect-error的位置。