我提交了一个业余项目扩展供审查,AI 机器人从中发现了 4 个真实的安全漏洞
I submitted a side-project extension for review and an AI bot found 4 real security bugs in it
我们发现了什么
我提交了一个业余项目扩展供审查,AI 机器人从中发现了 4 个真实的安全漏洞。标题:我提交了一个业余项目扩展供审查,AI 机器人从中发现了 4 个真实的安全漏洞。URL 来源:https://dev.to/kleogeos/i-submitted-a-side-project-extension-for-review-and-an-ai-bot-found-4-real-security-bugs-in-it-59f5 发布时间:2026-10-22T23:11:21Z Ma
- 来源:DEV Community(发现于 2026-10-23)
- 证据等级:D · 发现产品或需求信号,暂未获得可核验的商业证据。
- 商业模式:待核验
- 主题:独立产品
- 初筛评分:17.8/100 · 收录 1 次
证据,比故事更重要。
规则清洗与初筛,未经人工商业核验。原文语境、实际客户和付费情况仍需自行验证。
引用与数字披露
来源类型(原作者自述/第三方测算/媒体转引)需采集端标注,本版尚未落字段。
- 作者
- 未标注
- 抓取日期
- 来源类型
- 未标注
- 币种
- 未标注
- 口径
- 未标注
- 披露主体
- 未标注
- 披露日期
- 未标注
中文辅助译文(全文)
我开发了一个小型 Raycast 扩展——粘贴发票 JSON,即可获得格式化的 HTML/Markdown 预览。渲染逻辑零依赖,7 个单元测试全部通过,tsc 检查无报错。在向官方扩展仓库提交 PR 之前,我对这个项目本来相当有信心。
然而,提交 PR 后没过几分钟,一个自动化审查工具(Greptile,Raycast 会对每个新收到的 PR 运行该工具)留下了 9 条评论。其中 4 条被标记为 P1/安全问题。没有一条是我测试能够发现的问题,因为我的测试只覆盖了正常流程和几个“普通”的边缘情况,并没有真正测试恶意输入。
1. 未转义的字段进入 HTML 模板
// before
export function computeTotals(data: InvoiceData) {
const subtotal = data.items.reduce((acc, i) => acc + i.qty * i.rate, 0);
const taxRate = data.tax_rate || 0;
...
}tax_rate 的类型被标注为 number,但运行时没有任何机制强制保证这一点——它来自对用户所粘贴内容的 JSON.parse。粘贴 "tax_rate": "\"> ",该值就会未经转义地进入 HTML 模板;我只对那些“我知道”是字符串的字段进行了转义,却相信了那个我假定始终为数字的字段的类型标注。
2. Markdown 注入被用作跟踪像素
该扩展还会渲染 Markdown 预览(notes 字段会直接进入 Raycast 的 Detail 视图,而该视图会渲染 Markdown)。如果 notes 的值为 ,它就会变成一个图片 Markdown 标签;预览发票的瞬间,它会向攻击者指定的 URL 发起真实的 HTTP 请求。我在所有地方都转义了 HTML 特殊字符,但 Markdown 有自己的一套特殊字符(` * _ { } [ ] ( ) # + . ! | < > - ``),而我完全没有处理这些字符。
3. 数组中的 null 导致崩溃
{"items": [null]}通过了我的单个类型层面保护检查(Array.isArray(data.items) && data.items.length > 0),随后在 computeTotals 内部尝试读取 null 的 .qty 时崩溃。粘贴的 JSON 中只要有一行数据有问题,整个预览就会崩溃,而不是只影响那一行。
4. 保存时静默丢失数据
// before
const file = path.join(dir, `${(data.invoice_number || "invoice").replace(/[^a-z0-9-_]/gi, "_")}.html`);
fs.writeFileSync(file, html, "utf8");两张编号分别为 INV/001 和 INV:001 的发票经过清理后会得到相同的文件名。保存第二张发票时,它会在没有任何警告或提示的情况下静默覆盖第一张。
这些修复本身都很小——增加一次 sanitizeItems() 处理,在任何内容进入渲染器之前先进行类型转换和过滤;增加一个与 HTML 转义器并列的 escapeMarkdown();在 writeFileSync 前检查文件名冲突;用 try/catch 包裹保存操作,并在失败时显示提示 Toast。总共大约 40 行代码。问题不在于技能不足,而在于我编写的每一个测试都向函数提供了格式正确的数据。我测试的是代码能否按照我的设计运行,而不是当有人提供我未曾考虑过的数据时会发生什么。
我诚实的体会是:自己编写的单元测试往往只会探查你原本就在想象的输入形态。真正有用的对抗性用例——格式错误的 JSON、通过 JSON.parse 发生的类型转换、通过一个“纯文本”字段实施的 Markdown/HTML 注入——恰恰是你编写正常流程版本时不会想到的,因为如果你想到了,就意味着你早已处理了它们。这才是让其他人(或其他东西)在真实用户因意外触发问题之前,带着明确的破坏意图来测试代码的实际价值。
这个 PR(raycast/extensions#32665)第二次还发现了一个更隐蔽的非安全问题:修复上述 4 个错误后,我为 null 条目用例添加的新测试把 @ts-expect-error 注释放在了错误的位置,导致类型检查器本应报错时却静默通过。这再次提醒我们,针对某个错误编写的回归测试本身也需要审查——一个因错误原因而通过编译的测试比没有测试更糟糕,因为它看起来是绿色的。
来源(渲染逻辑 + 测试):github.com/kleogeos/freelance-invoice-preview
译文由上游机器翻译生成,可能有误;判断请以英文原文为准。
英文原文(来源本站未改写)
I built a small Raycast extension - paste invoice JSON, get a formatted HTML/Markdown preview. Zero dependencies in the render logic, 7 unit tests passing, tsc clean. I was pretty confident in it before opening the PR to the official extensions repo.
Within a few minutes, an automated reviewer (Greptile, which Raycast runs on every incoming PR) left 9 comments. Four were tagged P1/security. None of them were things my tests caught, because I'd only tested the happy path and a couple of "normal" edge cases - not actually hostile input.
1. Unescaped field reaching an HTML template
// before
export function computeTotals(data: InvoiceData) {
const subtotal = data.items.reduce((acc, i) => acc + i.qty * i.rate, 0);
const taxRate = data.tax_rate || 0;
...
}tax_rate is typed as number, but nothing enforces that at runtime - it comes from JSON.parse on whatever the user pastes. Paste "tax_rate": "0\"> " and it reaches the HTML template unescaped, because I was escaping the fields I "knew" were strings and trusting the type annotation for the one I assumed was always a number.
2. Markdown injection as a tracking pixel
The extension also renders a Markdown preview (notes field goes straight into a Raycast Detail view, which renders Markdown). A notes value like  turns into an image markdown tag that fires a real HTTP request to whatever URL the attacker put there, the moment you preview the invoice. I was escaping HTML special characters everywhere, but Markdown has its own special-character set (` * _ { } [ ] ( ) # + . ! | < > - ``) and I hadn't touched it.
3. A crash from a null in an array
{"items": [null]}passed my one type-level guard (Array.isArray(data.items) && data.items.length > 0) and then blew up inside computeTotals when it tried to read .qty off null. A single bad row in pasted JSON took down the whole preview instead of just that row.
4. Silent data loss on save
// before
const file = path.join(dir, `${(data.invoice_number || "invoice").replace(/[^a-z0-9-_]/gi, "_")}.html`);
fs.writeFileSync(file, html, "utf8");Two invoices with numbers like INV/001 and INV:001 both sanitize to the same filename. Saving the second one silently overwrote the first, no warning, no prompt.
The fixes themselves were small - a sanitizeItems() pass that coerces and filters before anything touches the renderer, an escapeMarkdown() sibling to the HTML escaper, a collision check before writeFileSync, a try/catch around the save with a failure toast. Maybe 40 lines total. The gap wasn't skill, it was that every one of my own tests handed the function well-formed data. I was testing that the code did what I designed it to do, not what happens when someone hands it something I didn't design for.
The honest takeaway for me: unit tests you write yourself tend to probe the shape of input you were already imagining. The useful adversarial cases - malformed JSON, type coercion through JSON.parse, markdown/HTML injection via a "just text" field - are exactly the ones that don't occur to you while you're writing the happy-path version, because if they occurred to you, you'd have handled them already. That's the actual value of someone (or something) else hitting your code with the specific intent of breaking it before real users do it by accident.
The PR (raycast/extensions#32665) also caught a quieter non-security issue the second time around: after I fixed the four bugs above, the new test I added for the null-items case had its @ts-expect-error comment in the wrong spot, which silently made the type checker pass when it should have failed. Small reminder that regression tests for a bug need their own review too - a test that compiles for the wrong reason is worse than no test, because it looks green.
Source (render logic + tests): github.com/kleogeos/freelance-invoice-preview
这条还缺什么证据?
下面每条都由本条已有字段推出(等级、理由、商业模式、来源次数、是否演示), 本站不生成推测性结论;通用验证方法放在方法论页。
- 可核验的收入或付费证据查官网定价页与付费口径;第三方数据源(如 GetLatka)只作旁证,需标注来源与时点。
- 商业模式未定确认按席位/按用量/授权还是开源托管版收费;开源项目另查 LICENSE 与是否存在付费版。
- 只有单一来源找一手站点或其他渠道是否重复出现同一产品;社区热帖数量不等于商业进展。
通用验证清单(谁有这个问题/谁愿意付费/一个人能交付哪一小步)见我们的筛选方法。