同一个函数,我复制了 9 遍,写出 9 种版本
2026/7/27 13:19:15 网站建设 项目流程

起因:一个无聊的下午,我决定查自己的重

我维护着 82 个单文件离线工具(全部开源在 GitHub),每个工具是一个独立的 HTML 文件,零外部依赖。

有一天下午闲得没事干,我写了一个正则脚本扫了一遍所有仓库的代码——不是找 bug,就是想看看「我到底复制了多少次同一个函数」。

结果有些出乎我意料。

第一张图:82 个工具里的函数普查

我用function\s+(\w+)\s*(正则提取了每个工具第一个<script>标签里的纯函数名,然后统计每个函数出现在多少个不同的仓库里。

被复制最多的 TOP 6:

函数名出现仓库数不同实现数
toast1010
esc1010
escapeHtml99
init88
render66
uid66

注意第二列和第三列的数字完全一样——每个仓库的实现都不相同。这不是共享库,这是 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, '&amp;') .replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }

转义 5 个字符:& < > " '。这是最安全的版本,可以安全地用在 HTML 属性值里(比如title="..."href="..."内部拼接用户输入)。

第 2 层:4 字符版(2 个仓库)

// FocusForge / FormForge / FlowForge(3个) function escapeHtml(s) { return String(s==null?'':s).replace(/[&<>"']/g, function(c) { return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }

注意这里漏掉了单引号'的转义。如果这个返回值被拼进用单引号包裹的属性位(data-value='...'),攻击者可以通过注入单引号逃逸属性。

第 3 层:3 字符版(3 个仓库)

// CronText / MarkForge / RegexLab function escapeHtml(s) { return String(s).replace(/&/g,'&amp;') .replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

只转义& < >三个字符。引号原样放行。

这三种写法,看起来只是「少转了几个字符」的区别。但放在正确的调用位置上,区别就是「安全」和「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/&quot; onmouseover=&quot;alert(1))

- 等等——"&quot;转义了?不对,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }

从 3 字符升级到 5 字符。现在引号会被转义成&quot;&#39;,无法再闭合属性值的引号。

刀二:新增 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('![x](vbscript:msgbox)'); 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&quot;b&#39;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 处理写法风格
1PlanForge5三元? '' :链式 replace
2MindForge5== null ? '' :链式 replace
3DeckForge5=== null || undefinedif 分支 + 链式
4NanoBox5无(String() 兜底)字典 replace callback
5HtmlForge4链式(缺'
6FlowForge4==null?''字典 callback(缺'
7FormForge4==null?''字典 callback(缺'
8CronText3链式(缺" '
9RegexLab3链式(缺" '
10MarkForge3→5已修复+safeUrl 协议白名单

第 10 个就是本文修掉的那个。从 3 字符升级到 5 字符,外加 safeUrl 双保险。


相关链接:

  • MarkForge 在线版:MarkForge · Markdown 编辑器
  • GitHub 源码:GitHub - wangzifan396-wzf/MarkForge: Markdown 实时编辑器:边写边预览、快捷工具栏、导出独立 HTML、中英文字数统计。 · GitHub
  • 全部 82 款工具导航:nano-tools · 单文件零依赖工具集

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询