起因一个无聊的下午我决定查自己的重我维护着 82 个单文件离线工具全部开源在 GitHub每个工具是一个独立的 HTML 文件零外部依赖。有一天下午闲得没事干我写了一个正则脚本扫了一遍所有仓库的代码——不是找 bug就是想看看「我到底复制了多少次同一个函数」。结果有些出乎我意料。第一张图82 个工具里的函数普查我用function\s(\w)\s*(正则提取了每个工具第一个script标签里的纯函数名然后统计每个函数出现在多少个不同的仓库里。被复制最多的 TOP 6函数名出现仓库数不同实现数toast1010esc1010escapeHtml99init88render66uid66注意第二列和第三列的数字完全一样——每个仓库的实现都不相同。这不是共享库这是 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 / FlowForge3个 function escapeHtml(s) { return String(snull?: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 里只有两类位置会渲染用户输入文本位divescapeHtml(userInput)/div—— 内容在标签之间引号不会逃逸属性位div titleescapeHtml(userInput)—— 内容在引号包裹的属性值内未转义的引号可以直接闭合属性CronText文本位 ✓CronText 是一个 cron 表达式解析器。escapeHtml被用来渲染解析结果的文本描述fhtml div classv escapeHtml(val) /div; descEl.innerHTML div classbig✗ 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 hrefurlfunction inlineMd(s) { s escapeHtml(s); // ← 3字符版引号不转义 // ... 省略 bold/italic/code ... s s.replace(/\[([^\]])\]\(([^)])\)/g, a href$2 target_blank relnoopener$1/a); // ← $2 直接进 href 属性位 return s; }问题链1. 用户输入[点击](https://a.b/ onmouseoveralert(1))2.escapeHtml处理后[点击](https://a.b/quot; onmouseoverquot;alert(1))- 等等——被quot;转义了不对3 字符版不转义3.3 字符版处理后实际是[点击](https://a.b/ onmouseoveralert(1))—— 引号原样保留4. 正则替换后生成a hrefhttps://a.b/ onmouseoveralert(1)) target_blank relnoopener点击/a5. 浏览器解析 HTML 时href的值到第一个就结束了后面onmouseoveralert(1))变成了a标签的一个新属性这是一个真实的 XSS 向量。不是理论上的不是 CTF 题里的——它就在我的生产代码里已经在线上跑了不知道多久。还有第二个向量javascript:协议注入[x](javascript:alert(document.cookie))3 字符版escapeHtml不拦截任何协议。替换后直接生成a hrefjavascript: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里 → 直接返回#丢弃整个 URL3. 白名单内的 URL 再做一层字符清洗防止残留的特殊字符然后把inlineMd里的链接和图片替换从字符串模板改成回调函数// 修复前 s s.replace(/\[([^\]])\]\(([^)])\)/g, a href$2 target_blank relnoopener$1/a); // 修复后 s s.replace(/[([^]])](([^)]))/g, function(m, txt, url) { return a href safeUrl(url) target_blank relnoopener txt /a; });图片的src属性同样走safeUrl。回归测试锁死修完之后我加了 6 条断言确保以后没人包括我自己能不小心改回去ok(href attr escape guard, (function(){ var h F.mdToHtml([x](https://a.b/ onmouseoveralert(1))); return h.indexOf(onmouseoveralert) 0; })()); ok(javascript: protocol blocked, (function(){ var h F.mdToHtml([x](javascript:alert(1))); return h.indexOf(hrefjavascript:) 0 h.indexOf(href#) 0; })()); ok(img src protocol blocked, (function(){ var h F.mdToHtml(); return h.indexOf(srcvbscript:) 0; })()); ok(normal link intact, (function(){ var h F.mdToHtml([a](https://example.com/p?q1)); return h.indexOf(hrefhttps://example.com/p?q1) 0; })()); ok(quote escaped in text, F.escapeHtml(ab\c) aquot;b#39;c);21 条断言全绿smoke 通过commit 已推送。复制代码的真实成本这件事教会我一件事DRYDont Repeat Yourself原则的反面不只是「维护成本高」这么简单。如果你把同一个函数复制了 9 遍最大的风险不是「改了一处忘了改另外 8 处」——那个靠 grep 就能解决。最大的风险是你会在第 7 遍的时候记错细节。escapeHtml要不要转义引号要转义哪些这看起来是个「常识」但当你连续写了 82 个工具、跨越了几个月的时间跨度「常识」就会漂移。第 1 个工具写得完美无缺第 9 个工具就差一个 XSS。我的架构选择是故意的零依赖、单文件、每个工具独立可审计。这意味着我不能抽一个utils.js给所有人引用——那会破坏「单文件」的设计约束。所以代价就是我必须接受重复然后用自动化审计来补偿1. 写一个指纹脚本定期扫描所有仓库的函数实现差异2. 对关键安全函数escapeHtml、SQL 拼接、shell 命令拼接做专项审查3. 每个漏洞修复都配回归断言锁死不再回退接受重复但不要对重复视而不见。全部 9 种 escapeHtml 实现附录#仓库转义字符数null 处理写法风格1PlanForge5三元? :链式 replace2MindForge5 null ? :链式 replace3DeckForge5 null || undefinedif 分支 链式4NanoBox5无String() 兜底字典 replace callback5HtmlForge4无链式缺6FlowForge4null?字典 callback缺7FormForge4null?字典 callback缺8CronText3无链式缺 9RegexLab3无链式缺 10MarkForge3→5已修复safeUrl 协议白名单第 10 个就是本文修掉的那个。从 3 字符升级到 5 字符外加 safeUrl 双保险。相关链接MarkForge 在线版MarkForge · Markdown 编辑器GitHub 源码GitHub - wangzifan396-wzf/MarkForge: Markdown 实时编辑器边写边预览、快捷工具栏、导出独立 HTML、中英文字数统计。 · GitHub全部 82 款工具导航nano-tools · 单文件零依赖工具集