DOMPurify实战:如何在Node.js项目中安全过滤用户输入的HTML(附最新jsdom配置)

DOMPurify实战:如何在Node.js项目中安全过滤用户输入的HTML(附最新jsdom配置)

最近在重构一个社区项目的评论系统时,我遇到了一个棘手的问题:用户提交的富文本内容在渲染时偶尔会触发一些意想不到的脚本执行。虽然前端已经做了基础的转义,但后端存储前如果没有彻底清理,这些“脏”HTML一旦被其他用户加载,就可能带来安全风险。经过一番调研和踩坑,我最终选择了DOMPurify作为解决方案,并在Node.js环境中与最新版jsdom进行了深度整合。这篇文章就来分享一下我的实战经验,特别是那些官方文档里没细说的配置细节和性能优化技巧。

如果你正在使用Express、NestJS、Koa等框架构建需要处理用户生成内容(UGC)的应用,比如论坛评论、博客文章、商品详情富文本编辑等,那么后端层面的HTML安全过滤就不是一个可选项,而是必须构建的防线。DOMPurify以其在浏览器端的卓越表现而闻名,但在Node.js服务器端的使用,尤其是与jsdom的搭配,有不少需要注意的地方。我会从为什么选它、如何正确安装配置、高级用法到生产环境部署,一步步拆解。

1. 为什么在Node.js后端也需要DOMPurify?

很多开发者认为XSS防护只是前端的事情,在React或Vue里做好转义和CSP(内容安全策略)就够了。这种想法在十年前或许还说得通,但在现代Web应用架构下,尤其是前后端分离、服务端渲染(SSR)或直接将用户内容输出给第三方场景中,后端进行HTML净化同样至关重要。

想象一下这些场景:

  • 你的API接收用户提交的HTML内容(来自富文本编辑器),然后直接存储到数据库。另一个用户通过API获取这些内容时,你的后端可能以JSON形式返回原始HTML字符串。如果前端框架的转义机制存在漏洞,或者内容被嵌入到iframesrcdoc等特殊环境中,恶意脚本就可能被执行。
  • 你提供了邮件模板自定义功能,用户上传的HTML模板会在服务器端被渲染并发送。如果模板中含有恶意脚本,它可能窃取邮件接收者的信息。
  • 你在做服务端渲染,将用户评论连同页面一起输出。如果评论内容未经净化,脚本会在页面加载时直接执行,完全绕过了前端框架的保护。

DOMPurify的核心优势在于它采用了一种“白名单”模型。与简单的正则表达式替换或黑名单过滤不同,它基于一个真实的DOM环境来解析HTML,只允许已知安全的标签和属性通过,其他一律移除或转义。这种方式能有效应对各种复杂的XSS变种攻击,如基于onerrorjavascript:协议、SVG事件处理器等手法的攻击。

注意:DOMPurify在Node.js中运行时,需要一个DOM实现来模拟浏览器环境,这就是jsdom出场的原因。两者版本间的兼容性直接影响到过滤的安全性。

2. 环境搭建与基础配置

让我们从零开始,在一个Node.js项目中集成DOMPurify和jsdom。我假设你使用的是Node.js 18或更高版本,这是目前得到长期支持(LTS)的稳定版本。

2.1 安装依赖

首先,通过npm或yarn安装必要的包。务必安装最新版本,这是安全性的第一道保障。

npm install dompurify jsdom
# 或
yarn add dompurify jsdom

安装完成后,可以通过以下命令验证版本。在我撰写本文时,最新的稳定版本是:

  • dompurify: ^3.0.8
  • jsdom: ^23.0.0

你可以创建一个简单的test-version.js文件来检查:

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
console.log('DOMPurify loaded');
console.log('JSDOM loaded');

2.2 创建净化器实例

在Node.js中使用DOMPurify,不能直接引入就调用sanitize函数。你需要先创建一个JSDOM实例,从中获取window对象,然后用这个window对象来初始化DOMPurify。这是因为DOMPurify内部需要访问DOM API(如document.createElement)。

下面是一个最基本的封装函数,我通常会把它放在一个独立的工具模块中(例如utils/sanitizeHtml.js):

// utils/sanitizeHtml.js
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');

/**
 * 创建一个配置好的DOMPurify实例
 * 采用单例模式,避免重复创建JSDOM环境带来的开销
 */
let purifyInstance = null;

function getPurify() {
  if (!purifyInstance) {
    // 创建一个基础的JSDOM窗口对象
    // 注意:这里传入空字符串作为初始HTML内容即可
    const { window } = new JSDOM('');
    // 将window对象传入,创建DOMPurify实例
    purifyInstance = createDOMPurify(window);
  }
  return purifyInstance;
}

/**
 * 安全净化HTML字符串
 * @param {string} dirtyHtml - 待净化的原始HTML字符串
 * @param {object} [config] - 可选的DOMPurify配置对象
 * @returns {string} 净化后的安全HTML字符串
 */
function sanitizeHtml(dirtyHtml, config = {}) {
  const DOMPurify = getPurify();
  const defaultConfig = {
    // 默认配置:允许HTML5标签
    USE_PROFILES: { html: true },
    // 返回字符串格式的HTML,而不是DOM节点
    RETURN_DOM: false,
    RETURN_DOM_FRAGMENT: false,
    // 禁止将净化后的内容自动添加到DOM(在Node.js中此选项无效,但保留以保持一致性)
    RETURN_DOM_IMPORT: false,
    // 允许data-*属性,这对前端框架(如Vue、React)的绑定很有用
    ALLOW_DATA_ATTR: true,
    // 允许自定义的ARIA属性,保证可访问性
    ALLOW_ARIA_ATTR: true,
  };

  const finalConfig = { ...defaultConfig, ...config };
  return DOMPurify.sanitize(dirtyHtml, finalConfig);
}

module.exports = { sanitizeHtml };

现在,你可以在项目中的任何地方导入并使用这个sanitizeHtml函数:

const { sanitizeHtml } = require('./utils/sanitizeHtml');

const userInput = '<script>alert("xss")</script><p>这是一段正常的文本</p>';
const cleanHtml = sanitizeHtml(userInput);
console.log(cleanHtml);
// 输出: <p>这是一段正常的文本</p>
// <script>标签被安全地移除了

3. 深入DOMPurify配置:应对复杂场景

基础的净化能解决大部分问题,但真实业务场景往往更复杂。比如,你可能需要允许用户嵌入特定的iframe(如来自可信源的视频),或者保留一些自定义的CSS类名。DOMPurify提供了丰富的配置项来满足这些需求。

3.1 自定义白名单:允许特定标签和属性

DOMPurify的默认白名单已经非常全面,涵盖了大多数安全的HTML5元素。但有时你需要放宽限制。切记,每次放宽白名单都必须经过严格的安全评估。

假设你的应用允许用户嵌入来自YouTube和Vimeo的iframe视频,但需要阻止其他所有iframe。你可以这样配置:

const customConfig = {
  ADD_TAGS: ['iframe'], // 将iframe加入允许的标签列表
  ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'], // 添加允许的属性
  // 自定义过滤函数,对iframe的src属性进行严格检查
  sanitizeHtml(node) {
    // 这个函数会在DOMPurify处理每个元素时被调用
    if (node.tagName && node.tagName.toLowerCase() === 'iframe') {
      const src = node.getAttribute('src') || '';
      // 只允许来自youtube.com和player.vimeo.com的嵌入
      const allowedDomains = [
        'https://www.youtube.com',
        'https://youtube.com',
        'https://player.vimeo.com'
      ];
      const isAllowed = allowedDomains.some(domain => src.startsWith(domain));
      if (!isAllowed) {
        // 如果不允许,则移除整个iframe节点
        node.parentNode.removeChild(node);
      }
    }
  }
};

const dirtyHtml = `
  <p>看看这个视频:</p>
  <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
  <iframe src="http://evil.com/steal-data.html"></iframe>
`;

const clean = sanitizeHtml(dirtyHtml, customConfig);
// 第一个iframe会被保留,第二个会被移除

提示:ADD_TAGSADD_ATTR是追加到默认白名单,而不是替换。如果你想完全自定义白名单,可以使用ALLOWED_TAGSALLOWED_ATTR来覆盖默认值,但这非常危险,除非你完全清楚自己在做什么。

3.2 处理SVG和MathML

DOMPurify默认也支持SVG和MathML的净化,这对于学术或图表类应用很有用。但SVG本身可能包含脚本(通过<script>标签或事件处理器),需要特别注意。

// 允许SVG内容,但禁用SVG内的脚本和事件
const svgConfig = {
  USE_PROFILES: { html: true, svg: true, svgFilters: true },
  // 禁止SVG中的<script>标签
  FORBID_TAGS: ['script'],
  // 禁止所有on*事件属性(在SVG和HTML中都生效)
  FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover'] // 这里只是示例,实际应禁止所有on*事件
};

const svgInput = `
  <svg height="100" width="100">
    <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
    <script>alert('恶意SVG脚本')</script>
  </svg>
`;
const cleanSvg = sanitizeHtml(svgInput, svgConfig);
// <script>标签会被移除,但SVG图形元素会保留

3.3 净化后内容的处理方式

DOMPurify默认返回一个HTML字符串。但你也可以让

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值