起因:一个无聊的下午,我决定查自己的重
我维护着 82 个单文件离线工具(全部开源在 GitHub),每个工具是一个独立的 HTML 文件,零外部依赖。
有一天下午闲得没事干,我写了一个正则脚本扫了一遍所有仓库的代码——不是找 bug,就是想看看「我到底复制了多少次同一个函数」。
结果有些出乎我意料。
第一张图:82 个工具里的函数普查
我用function\s+(\w+)\s*(正则提取了每个工具第一个<script>标签里的纯函数名,然后统计每个函数出现在多少个不同的仓库里。
被复制最多的 TOP 6:
| 函数名 | 出现仓库数 | 不同实现数 |
|---|---|---|
| toast | 10 | 10 |
| esc | 10 | 10 |
| escapeHtml | 9 | 9 |
| init | 8 | 8 |
| render | 6 | 6 |
| uid | 6 | 6 |
注意第二列和第三列的数字完全一样——每个仓库的实现都不相同。这不是共享库,这是 10 个 toast 函数、10 个 esc 函数、9 个 escapeHtml 函数,各自独立生长。
为什么会有 9 种 escapeHtml?
原因很简单:每个工具是我独立写的。写第一个工具时写了escapeHtml;写第二个工具时懒得回去翻第一个,就凭记忆再写一遍;写到第九个的时候,记忆已经漂移了。
我把 9 种实现按转义的字符数分成了三层:
第 1 层:5 字符完整版(4 个仓库)
// PlanForge / MindForge / DeckForge / NanoBox function escapeHtml(s) { s = (s == null) ? '' : String(s); return s.replace(/&/g, '&') .replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, '''); }转义 5 个字符:& < > " '。这是最安全的版本,可以安全地用在 HTML 属性值里(比如title="..."或href="..."内部拼接用户输入)。
第 2 层:4 字符版(2 个仓库)
// FocusForge / FormForge / FlowForge(3个) function escapeHtml(s) { return String(s==null?'':s).replace(/[&<>"']/g, function(c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }注意这里漏掉了单引号'的转义。如果这个返回值被拼进用单引号包裹的属性位(data-value='...'),攻击者可以通过注入单引号逃逸属性。
第 3 层:3 字符版(3 个仓库)
// CronText / MarkForge / RegexLab function escapeHtml(s) { return String(s).replace(/&/g,'&') .replace(/</g,'<').replace(/>/g,'>'); }只转义& < >三个字符。引号原样放行。
这三种写法,看起来只是「少转了几个字符」的区别。但放在正确的调用位置上,区别就是「安全」和「XSS」之间的一根线。
审计:逐个排查 3 字符版的调用位
拿到三层分类后,我做的第一件事是:把 3 字符版的三个仓库全部打开,搜索每一处escapeHtml(的调用,看它被用在了哪里。
HTML 里只有两类位置会渲染用户输入:
- 文本位:
<div>escapeHtml(userInput)</div>—— 内容在标签之间,引号不会逃逸 - 属性位:
<div title="escapeHtml(userInput)">—— 内容在引号包裹的属性值内,未转义的引号可以直接闭合属性
CronText:文本位 ✓
CronText 是一个 cron 表达式解析器。escapeHtml被用来渲染解析结果的文本描述:
fhtml += '<div class="v">' + escapeHtml(val) + '</div>'; descEl.innerHTML = '<div class="big">✗ ' + escapeHtml(err) + '</div>';全部在<div>文本位。即使引号没转义,也不会逃逸。安全。
RegexLab:文本位 ✓
RegexLab 是正则表达式测试器。escapeHtml用于高亮匹配结果和匹配详情:
html += '<mark>' + escapeHtml(text.slice(mt.index, mt.end)) + '</mark>'; $('highlight').innerHTML = escapeHtml(text);同样全部在文本位。安全。
MarkForge:属性位 ✗
MarkForge 是 Markdown 渲染器。它有一个inlineMd函数,先把用户输入通过escapeHtml过滤,再做 markdown 语法替换——包括把[text](url)转成<a href="url">:
function inlineMd(s) { s = escapeHtml(s); // ← 3字符版,引号不转义 // ... 省略 bold/italic/code ... s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); // ← $2 直接进 href 属性位! return s; }问题链:
1. 用户输入[点击](https://a.b/" onmouseover="alert(1))
2.escapeHtml处理后:[点击](https://a.b/" onmouseover="alert(1))
- 等等——"被"转义了?不对,3 字符版不转义"
3.3 字符版处理后实际是:[点击](https://a.b/" onmouseover="alert(1))—— 引号原样保留
4. 正则替换后生成:
<a href="https://a.b/" onmouseover="alert(1))" target="_blank" rel="noopener">点击</a>5. 浏览器解析 HTML 时,href的值到第一个"就结束了,后面onmouseover="alert(1))"变成了<a>标签的一个新属性
这是一个真实的 XSS 向量。不是理论上的,不是 CTF 题里的——它就在我的生产代码里,已经在线上跑了不知道多久。
还有第二个向量:javascript:协议注入
[x](javascript:alert(document.cookie))3 字符版escapeHtml不拦截任何协议。替换后直接生成<a href="javascript:alert(document.cookie)">,点击即执行。
修复:两刀
刀一:补全 escapeHtml 到 5 字符
function escapeHtml(s) { return String(s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, '''); }从 3 字符升级到 5 字符。现在引号会被转义成"和',无法再闭合属性值的引号。
刀二:新增 safeUrl 协议白名单
光补全escapeHtml还不够——因为escapeHtml先执行,URL 里的:不会被碰。如果 URL 本身是javascript:...,协议冒号是合法字符,不在转义范围内。
所以我加了一个独立的safeUrl函数:
function safeUrl(u) { u = String(u).trim(); var proto = (u.match(/^\s*([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1]; if (proto && !/^(https?|mailto|ftp)$/i.test(proto)) return '#'; return u.replace(/"/g,'%22').replace(/'/g,'%27') .replace(/</g,'%3C').replace(/>/g,'%3E'); }逻辑很简单:
1. 用正则提取 URL 开头的协议部分(https:、javascript:、vbscript:等)
2. 如果有协议且不在白名单(http / https / mailto / ftp)里 → 直接返回#,丢弃整个 URL
3. 白名单内的 URL 再做一层字符清洗,防止残留的特殊字符
然后把inlineMd里的链接和图片替换从字符串模板改成回调函数:
// 修复前 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); // 修复后 s = s.replace(/[([^]]+)](([^)]+))/g, function(m, txt, url) { return '<a href="' + safeUrl(url) + '" target="_blank" rel="noopener">' + txt + '</a>'; });图片的src属性同样走safeUrl。
回归测试锁死
修完之后我加了 6 条断言,确保以后没人(包括我自己)能不小心改回去:
ok('href attr escape guard', (function(){ var h = F.mdToHtml('[x](https://a.b/" onmouseover="alert(1))'); return h.indexOf('onmouseover="alert') < 0; })()); ok('javascript: protocol blocked', (function(){ var h = F.mdToHtml('[x](javascript:alert(1))'); return h.indexOf('href="javascript:') < 0 && h.indexOf('href="#"') >= 0; })()); ok('img src protocol blocked', (function(){ var h = F.mdToHtml(''); return h.indexOf('src="vbscript:') < 0; })()); ok('normal link intact', (function(){ var h = F.mdToHtml('[a](https://example.com/p?q=1)'); return h.indexOf('href="https://example.com/p?q=1"') >= 0; })()); ok('quote escaped in text', F.escapeHtml('a"b\'c') === 'a"b'c');21 条断言全绿,smoke 通过,commit 已推送。
复制代码的真实成本
这件事教会我一件事:
DRY(Don't Repeat Yourself)原则的反面,不只是「维护成本高」这么简单。
如果你把同一个函数复制了 9 遍,最大的风险不是「改了一处忘了改另外 8 处」——那个靠 grep 就能解决。
最大的风险是:你会在第 7 遍的时候记错细节。
escapeHtml要不要转义引号?要转义哪些?这看起来是个「常识」,但当你连续写了 82 个工具、跨越了几个月的时间跨度,「常识」就会漂移。第 1 个工具写得完美无缺,第 9 个工具就差一个 XSS。
我的架构选择是故意的:零依赖、单文件、每个工具独立可审计。这意味着我不能抽一个utils.js给所有人引用——那会破坏「单文件」的设计约束。
所以代价就是我必须接受重复,然后用自动化审计来补偿:
1. 写一个指纹脚本,定期扫描所有仓库的函数实现差异
2. 对关键安全函数(escapeHtml、SQL 拼接、shell 命令拼接)做专项审查
3. 每个漏洞修复都配回归断言,锁死不再回退
接受重复,但不要对重复视而不见。
全部 9 种 escapeHtml 实现(附录)
| # | 仓库 | 转义字符数 | null 处理 | 写法风格 |
|---|---|---|---|---|
| 1 | PlanForge | 5 | 三元? '' : | 链式 replace |
| 2 | MindForge | 5 | == null ? '' : | 链式 replace |
| 3 | DeckForge | 5 | === null || undefined | if 分支 + 链式 |
| 4 | NanoBox | 5 | 无(String() 兜底) | 字典 replace callback |
| 5 | HtmlForge | 4 | 无 | 链式(缺') |
| 6 | FlowForge | 4 | ==null?'' | 字典 callback(缺') |
| 7 | FormForge | 4 | ==null?'' | 字典 callback(缺') |
| 8 | CronText | 3 | 无 | 链式(缺" ') |
| 9 | RegexLab | 3 | 无 | 链式(缺" ') |
| 10 | MarkForge | 3→5 | 已修复 | +safeUrl 协议白名单 |
第 10 个就是本文修掉的那个。从 3 字符升级到 5 字符,外加 safeUrl 双保险。
相关链接:
- MarkForge 在线版:MarkForge · Markdown 编辑器
- GitHub 源码:GitHub - wangzifan396-wzf/MarkForge: Markdown 实时编辑器:边写边预览、快捷工具栏、导出独立 HTML、中英文字数统计。 · GitHub
- 全部 82 款工具导航:nano-tools · 单文件零依赖工具集