毕设脚手架 bishe-kit 前两天刚加上「按当前筛选条件导出 CSV」:UTF-8 BOM、Excel 直接开中文不乱码、文本单元格以 = + - @ 开头时前面加单引号防公式注入,单次最多 1 万条。单元测试里也特意写了 =HYPERLINK(...) 和带逗号引号的书名,看着挺完整。
今晚回头用真实接口再打一遍,还是翻出 4 个洞。有的是防护面没盖全,有的是搜索和导出共用的 LIKE 通配符没转义。环境:Node 24.22.1,Express 5,sql.js,仓库 HEAD 3093142,实体是自带的 book(书名/作者可搜,另有 publish_date 日期字段和 cover 图片字段)。下面每条都能用仓库自带的 admin token + CRUD 接口复现,不依赖前端页面。
原来的防护长什么样
server/src/routes/crud.js 里核心就这两段:
export function csvCell(v, isText = false) {
if (v === null || v === undefined) return ''
let s = String(v)
if (isText && /^[=+\-@\t\r]/.test(s)) s = "'" + s
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
}
const isText = (c) =>
!['id', 'created_at', 'updated_at'].includes(c.name) &&
!['number', 'int', 'bool', 'date'].includes(c.type)思路没问题:只对「文本型」单元格做公式前缀检查。但 date 被排除在文本之外,而 SQLite 里日期本质是 TEXT,接口又原样 String(v) 写入——也就是说,日期列完全可以存 =1+2。
搜索条件则是直接拼:
args.push(`%${kw}%`) // 直接拼进 LIKE,没有 ESCAPE列表接口和导出接口共用 buildWhere,所以搜索框的坑会一路传到导出文件。
1. date 字段漏了公式防护
POST 一条书,publish_date 填 =1+2,再 GET /api/books/export。对应单元格是裸的 =1+2,前面没有 ':
6,日期公式书,涛,,,,=1+2,,2026-11-04 ...同一次导出里,封面字段(image 类型,走文本分支)的 =HYPERLINK("http://evil") 会被写成 '=HYPERLINK(...)——说明防护代码本身是生效的,只是 date 被 isText 排除了。
Excel / WPS 打开这份 CSV 时,出版日期列会按公式算成 3。轻则数据错,重则(配合 HYPERLINK、DDE 一类载荷)变成安全问题。毕设答辩不一定有人攻击你,但老师随手双击打开,看到日期变成 3,已经够尴尬。更别提你后面真把这套脚手架接到生产后台——「导出当前筛选」往往是运营同学每天都点的按钮。公式注入这类问题,一旦进生产,修起来成本远高于今天加两行正则。
2. 前导空格绕过前缀检查
正则锚定的是字符串开头:/^[=+\-@...]/。前面多一个空格就失效:
8, =1+1,涛,,,,,,...有些表格软件会 trim,有些不会;更稳的做法是先剥掉前导空白(以及 BOM、零宽字符)再判断,需要加前缀时仍然加在原始字符串前面,这样 Excel 看到的是 ' =1+1,显示为以空格开头的文本,而不会当公式。
3. 全角 =+-@ 没覆盖
半角 = + - @ 在名单里,全角 = + - @(U+FF1D / U+FF0B / U+FF0D / U+FF20)不在:
9,=CMD,涛,,,,,,...中文输入法切到全角、从网页复制标题时,很容易带上这些字符。WPS / Excel 对全角公式前缀的行为因版本而异,但「名单里只有半角」本身就是缺口。补丁里把四个全角码点一并挡住。
作为对照:半角的 +票务、@官方,原版已经能正确加 ',说明半角路径是通的——问题只在「名单覆盖不全」和「空白 / 零宽没先剥」。
4. 搜索框输入 % 能扫全表
keyword 直接塞进 LIKE %kw%,没有转义,也没有 ESCAPE 子句。于是:
| keyword | 结果 |
|---|---|
% | 命中全部记录(本机验证 11/11) |
_ | 同样命中全部(_ 匹配任意单字符,夹在两个 % 里等于「至少一字」) |
唯一%XYZ | 仍能命中 唯一书名XYZ(用户本意是字面百分号,却被当成通配符) |
因为列表和导出共用 buildWhere,导出也被影响:本想导出「标题里带百分号的书」,结果可能把整表倒出去。还好有 EXPORT_LIMIT(默认 10000)兜底,但「按筛选导出」的语义已经破了。
测试里原先只覆盖了 ' OR 1=1 -- 这种 SQL 注入姿势(参数化查询下确实是 0 条),没覆盖 LIKE 通配符。参数化防的是「注入成另一句 SQL」,防不住「模式本身被用户控制」——这是两类问题,不要混成一个勾。
EXPORT_LIMIT 和前端 blob 错误处理是对的
把环境变量 EXPORT_LIMIT=3 再启动,全表 5 条时导出会 400:共 5 条,超过单次导出上限 3 条,请先筛选再导出;带上能把结果缩到 1 条的 keyword 就 200。这条没问题,记一笔免得读者以为「上限也是摆设」。
前端下载用的是 responseType: 'blob'。出错时,http 拦截器会把 Blob 里的 JSON 错误解析出来弹 Message,而不会把报错内容存成 .csv——这点也是对的,今天没踩坑。答辩现场如果点导出失败,至少不会下载到一份「看起来像表、打开全是报错 JSON」的文件。把错误和数据文件分开,本身就是导出功能该有的基本礼貌。
怎么在本机复现(不改仓库)
仓库里已经放了独立脚本 daily/2026-11-05-csv-export-repro.mjs(也可以从今天的工作目录拷过去)。它会:
- 用
DB_PATH=:memory:起一份临时 Express app(复用createApp,不碰你本地的 sqlite 文件); - 用内置 admin 账号拿 token;
- POST 四条特制书:
publish_date=1+2、标题=1+1、标题=CMD、标题唯一书名XYZ; - 调
/api/books/export和带keyword=%/keyword=_/keyword=唯一%XYZ的列表接口; - 断言:date 列裸公式、前导空格与全角无前缀、
%/_命中全表——四条全挂才算「原版漏洞还在」。
跑通后把补丁 git apply 到工作树再跑一遍,四条应变绿(脚本里用环境变量 EXPECT_FIXED=1 切换断言方向)。全程不需要打开浏览器,也不需要动前端的 CrudPage.vue。
打算怎么改(补丁本地,未推远程)
文件:2026-11-05-csv-export.patch,对 HEAD 3093142 跑 git apply --check 通过。改动点:
isText不再排除date——日期列按文本做公式防护。csvCell先剥前导空白 / BOM / 零宽,再判断半角和全角公式前缀;需要时把'加在原始字符串前。buildWhere对 keyword 里的\%_转义,并加ESCAPE '\';列表和导出一起修好。- 测试补上 date / 前导空格 / 全角 /
keyword=%四条断言。
改完后本机验证:publish_date==1+2 导出为 '=1+2; =1+1、=CMD 都会加前缀;keyword=% 命中 0 条;原有导出用例(逗号引号转义、半角 =HYPERLINK、权限、BOM、负数库存不被改写)仍然通过,整文件 node --test 9/9 绿。
没改的:数字列仍然不加 '(stock=-1 保持数字);公式注入的完整攻防远不止前缀名单,这篇文章只把「名单漏了 date / 空白 / 全角」和「LIKE 通配符」说清楚。如果你的后台也有「导出当前筛选」,建议至少摸这三个用例:日期列写 =1+1、标题写 =CMD、搜索框只输入一个 %。