浏览器自带XSS消毒:HTML Sanitizer API来了

更新日期: 2026-06-04 阅读: 967 标签: 浏览器

工程师知道XSS,一般就三种途径。

运气好的,是在代码审查或安全lint里撞上的。勤奋的,是在安全审计里发现漏洞,还没上生产就堵住了。还有一种就是被毒打过的:线上被攻击者注入脚本,localStorage里的token被偷了,cookie被劫持了,用户被重定向到钓鱼站了。

我以前做过一个社区项目,用户资料页支持自定义简介。我当时天真地直接innerHTML渲染,直到有人往简介里塞了个onerror才知道事情不对。那天晚上边修边骂,长记性了。

现在浏览器终于出了个新东西,要把HTML消毒这活儿从开发者手里接过去。它叫HTML Sanitizer API。


innerHTML有什么问题?

先说问题。Web早期,innerHTML是把字符串变成DOM元素的魔法棒,用起来顺手,但也真够危险的:

const container = document.getElementById('content');
const userInput = '<img src=x onerror=alert("XSS")>';
container.innerHTML = userInput;

浏览器会尝试加载一张不存在的图片,加载失败后执行onerror里的脚本。恭喜,XSS漏洞到手。

这类payload通常从两个地方来。用户生成内容是重灾区:评论、评测、各种用户输入先存到数据库,之后渲染。没做消毒处理就是存储型XSS。URL参数也常见:攻击者在查询参数里构造恶意payload,应用把参数直接反射到页面,就是反射型XSS。搜索页面直接展示查询参数那种,尤其容易被利用。

一直以来,解决方案就是引入DOMPurify。它是JavaScript里事实上的HTML消毒库,解析输入字符串,移除危险元素和属性,返回安全版本的HTML。

import DOMPurify from 'dompurify';

const container = document.getElementById('content');
const userInput = '<img src=x onerror=alert("XSS")>';
const sanitizedInput = DOMPurify.sanitize(userInput);

container.innerHTML = sanitizedInput;

React里大概也写过类似的,用dangerouslySetInnerHTML渲染消毒后的内容:

import DOMPurify from 'dompurify';

function Comment({ content }) {
  const sanitizedContent = DOMPurify.sanitize(content);
  return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}

DOMPurify很强,但有短板。打包后约23.3 kB(压缩后约8.71 kB,数据来自Bundlephobia),需要持续维护。更关键的问题是:它在重复浏览器本来就擅长的事——HTML解析。

这话不是随便说说。我自己踩过坑:之前项目用DOMPurify消毒一段包含SVG的用户输入,本地测试没问题,上了Safari之后SVG里的一个事件处理器没被清掉。查半天发现是浏览器解析行为变了,DOMPurify还没跟上发新版。这种事不是DOMPurify的锅,但你作为使用者就是得扛着。

DOMPurify这类库一直是一种脆弱的方案。暴露给Web的解析API并不总能跟浏览器实际渲染HTML的方式精确对应。更糟的是,这些库得一直追赶浏览器的行为变化:曾经安全的东西,可能在一个新平台特性上线后变成定时炸弹。这让维护者陷入了跟每次浏览器发布的永久赛跑。DOMPurify到了现在的体量和影响力,这场赛跑基本就是全职工作。我猜维护者们在有朝一日能放下这个包袱那天,心里会悄悄松口气。

而浏览器呢,它完全清楚自己什么时候、怎样执行代码。消毒逻辑放在浏览器内部,天然就跟解析器同步。


新的HTML Sanitizer API

Web平台现在提供了新的API,让解析和消毒HTML更安全。规范引入了比老式innerHTML更安全的HTML插入方式。

API有六个方法,分两组:

  • 安全方法:Element.setHTML()、ShadowRoot.setHTML()、Document.parseHTML()。不管你传什么配置,它们都会剥离XSS不安全的内容。

  • 非安全方法:Element.setHTMLUnsafe()、ShadowRoot.setHTMLUnsafe()、Document.parseHTMLUnsafe()。完全按你的配置来,包括在你说允许的情况下保留危险内容。

逐一来看。

setHTML:安全的HTML插入方式

setHTML是DOM API的新增方法。用它设置HTML内容,浏览器会自动消毒,移除危险元素和属性。默认就是安全的。你仍然可以配置,但配置不会让危险内容被渲染。它始终会移除script标签和on*属性。试图配得太宽松,它会直接覆盖你的设置。

最简单的用法甚至不需要配置:

const maliciousInput = '<img src=x onerror=alert("XSS")>';
document.getElementById('content').setHTML(maliciousInput);
// onerror被剥离

就这些。onerror里的脚本消失了,因为浏览器在解析阶段就处理了消毒逻辑,用的是内置的默认安全配置。想看默认配置具体允许哪些元素和属性,MDN上有完整文档。

可配置的消毒

需要更多控制时,可以定义配置对象来指定允许或阻止哪些元素或属性。配置有时候容易搞错,比如不小心把一个元素同时放在允许和阻止列表里。API对此很严格:无效的配置对象会抛TypeError,确保你意识到配置里的矛盾。

白名单配置:

const config = {
  elements: ["em", "strong", "b", "i", "ul", "li"],
  attributes: ["id"],
  replaceWithChildrenElements: ["span", "div"],
};
const customSanitizer = new Sanitizer(config);

这个配置只允许列出的元素和属性,不在白名单上的都会被剥离。replaceWithChildrenElements让你指定哪些元素应该被替换为子元素而不是直接删除:输入中有div标签的话,div本身被去掉,里面的内容会保留。

黑名单配置:

const config = {
  removeElements: ["span", "script"],
  removeAttributes: ["lang", "id", "class", "style"],
  comments: false,
};
const customSanitizer = new Sanitizer(config);

指定要从输入中移除的元素和属性。comments设为false表示移除HTML注释。

不能在同一个配置对象中同时用elements和removeElements,互斥。attributes和removeAttributes也一样。试图同时包含两者,API会抛TypeError。可以组合elements + removeAttributes,或者removeElements + attributes,只是不能在同层级用互斥的一对。

两个例子都不需要操心on*这类危险属性。这就是“默认安全”的意思:即使你配置setHTML允许某些元素或属性,它仍然会阻止任何可能导致XSS的内容。

setHTMLUnsafe什么时候用?

setHTMLUnsafe是那个“不安全”的兄弟。两者的差异最清楚的说法是:

  • 用setHTML,你的配置是在安全默认值之上的进一步限制。不安全的东西始终被剥离,哪怕你显式允许也不行。

  • 用setHTMLUnsafe,你的配置就是全部规则。你说允许onclick,onclick就会被保留。完全不传配置,什么都不会被消毒。

主要有两个场景。一是声明式Shadow DOM:setHTML会把声明式Shadow DOM作为安全默认值的一部分剥离掉,需要它们的话,setHTMLUnsafe目前是唯一的方式。二是有意允许特定的“不安全”属性:有时候你确实需要内联事件处理器之类的东西,想精确只放开那一个,同时清理其余内容。

代码对比:

const input = '<button onclick="doThing()" onerror="stealStuff()">Click</button>';

// setHTMLUnsafe:onclick保留,onerror被剥离(白名单没列它)
const lessSafeConfig = new Sanitizer({
  attributes: ["onclick"],
});
document.getElementById('output').setHTMLUnsafe(input, { sanitizer: lessSafeConfig });

onerror被移除是因为白名单里没有它,不是因为setHTMLUnsafe自身做了安全保证。如果写attributes: ["onclick", "onerror"],两者都会保留。而setHTML不管怎么配,onerror都会被剥离。

parseHTML和parseHTMLUnsafe:不插入也能消毒

有时候不想立刻插入HTML,先解析、检查、也许转换,再决定怎么处理。Document.parseHTML()和Document.parseHTMLUnsafe()就是干这个的。

const untrustedHTML = '<div><p>Hello <script>alert("xss")</script>world</p></div>';

// 返回消毒后的Document
const doc = Document.parseHTML(untrustedHTML);
console.log(doc.body.innerHTML); // <div><p>Hello world</p></div>

parseHTML和setHTML遵循同样的规则:XSS不安全的内容始终被剥离。parseHTMLUnsafe则和setHTMLUnsafe对应,除非你传入消毒器,否则不做任何消毒。

这在需要一次性构建消毒过的DocumentFragment然后重复使用、或者先对消毒结果做检查再决定是否渲染的场景中特别有用。


实际应用场景

先说清楚:即使有了新API,后端消毒仍然不可妥协。前端消毒是为了用户体验和即时安全,任何有足够知识的人都可以绕过前端代码直接调API。这跟前端做输入校验提升体验、但仍然在后端做业务逻辑和安全校验是一个道理。

ShopTalkShow第704期里,Dave Rupert和Chris Coyier请来了Mozilla的Frederik Braun讨论HTML Sanitizer API。他们聊到把这个API用在乐观更新UI上,这在前端开发中很常见。

以评论区场景为例。用户点“发布”,通常依赖后端消毒评论、返回给浏览器、再渲染响应。这需要时间,体验不够好。直接信任原始用户输入立即渲染有风险,但有了新API,可以在后端还在处理时就安全地立即渲染评论。更流畅的用户体验,同时不牺牲安全性。

import React, { useState, useRef, useEffect } from 'react';

type Comment = { id: number; content: string };

const sanitizerConfig = { elements: ["b", "i", "em", "ul", "li"] };
const sanitizer = new Sanitizer(sanitizerConfig);

function CommentItem({ html }: { html: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    if ('setHTML' in ref.current) {
      ref.current.setHTML(html, { sanitizer });
    } else {
      // 不支持API时降级:DOMPurify或等后端消毒
      ref.current.textContent = html;
    }
  }, [html]);

  return <div ref={ref} />;
}

const CommentSection = () => {
  const [comments, setComments] = useState<Comment[]>([]);

  const handleSubmit = (userInput: string) => {
    // 1. 乐观更新:立即安全渲染
    const newComment = { id: Date.now(), content: userInput };
    setComments((prev) => [...prev, newComment]);

    // 2. 后端仍是可信来源
    postComment(userInput);
  };

  return (
    <div>
      {/* 渲染评论列表 */}
    </div>
  );
};

这个例子有几个值得说的点。Sanitizer在模块作用域中只构造一次,不是在组件内部,每次渲染都构造一遍是浪费。setHTML调用包在带feature detection的useEffect里,不支持该API的浏览器可以优雅降级。我们通过ref把div元素交给命令式DOM操作,而不是把React的dangerouslySetInnerHTML和diff机制混在一起用,那种组合在hydration时容易出问题。

这正是setHTML真正发光的地方。dangerouslySetInnerHTML处理未消毒字符串必然招来XSS,而这个API给了一条既好用又安全的路。

还有其他适用场景:

  • 富文本编辑器:用户经常从Word之类的地方粘贴内容,连带大量脏HTML和内联样式,监听paste事件用setHTML处理一遍,插入之前就清理干净了。

  • 实时Markdown预览:即使输入从未离开浏览器,显示渲染后的HTML之前还是应该做消毒。

  • 外部信息源:RSS、聚合内容、嵌入代码片段,以及任何来自你域名之外的内容,在接触DOM之前都应该消毒。


现在能不能上生产?

写这篇文章时(2026年6月),浏览器支持还在早期。Firefox 148在2026年2月率先发布了标准化API。Chrome已在Canary版中通过flag支持,Safari还没开始实现,但团队态度积极。该特性还没成为Baseline(可以简单理解为“主流浏览器普遍可用”的最低支持线),生产环境使用仍需要feature detection和降级方案,DOMPurify仍然是合适的备选。

好在Web允许我们在特性完全标准化之前就先用起来。现在就把它当渐进增强来用,持续关注兼容性表格。等它成为Baseline的那天,很多打包文件会变小一点,很多应用会更安全一点。


常见问题

Sanitizer API能替代DOMPurify吗?

短期内不能。浏览器支持还在早期,Safari还没开始实现,生产环境必须有降级方案。现阶段更合理的做法是feature detection:支持setHTML就用它,不支持就回退DOMPurify。

setHTMLUnsafe什么时候才该用?

除非你需要声明式Shadow DOM或者有意允许某个特定的内联事件处理器,否则别碰它。日常消毒用setHTML就够了。

跟DOMPurify比,性能好多少?

目前没有系统性的benchmark。但原理上,setHTML少了一层JS解析和DOM遍历,直接在浏览器C++层完成消毒,在大量重复消毒的场景下优势会明显。等浏览器支持更广泛之后值得做一轮对比。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

相关推荐

浏览器禁用了javascript,各种浏览器如何开启javascript的方法总汇

您的浏览器禁用了JS脚本运行,请启用该功能。怎么解除浏览器禁用js?这篇文章将总结整理各个浏览器如何开启、禁用javascript的方法总汇。

监听浏览器刷新及关闭

为保证‘高度安全性’,用户每次退出页面或浏览器都要清除登陆信息,每次进入系统都要重新登陆(每次登陆还要手机验证码等乱七八糟的验证信息,,,求用户的心里阴影面积),但是刷新页面不可以清除登陆信息。

Js实现阻止浏览器返回的功能

无论pc端还是移动端,浏览器都会带有后退按钮或后退键.主要方便我们能返回以前访问过的页面,但有时候我们不得不关闭这个功能.尤其是对于一些推广落地页,用户进入后不希望它返回

window.open被拦截的解决方法总汇

介绍window.open方法被浏览器拦截的处理方式。在 Chrome 的安全机制里,非用户直接触发的 window.open 方法,是会被拦截的,这是由于浏览器为了维护用户安全和体验,下面采用几种变通方法解决:表单提交的方式、onclick事件、延迟打开等

Chrome浏览器crx格式插件安装教程

谷歌浏览器在旧版本(大概是v67版本)之前安装crx插件都非常简单,直接将crx拖放到浏览器内就可以安装了。但是之后的新版本(目前已经升级到v80版本)就只允许用户通过谷歌应用商店安装插件

如何将网站设置为浏览器首页

如何将网站设置为浏览器首页

提示:按 Ctrl + D 即可添加网址到浏览器收藏夹中,方便下次访问fly63导航。下面是如何设置首页的方法。Google Chrome浏览器设为首页的方法;Firefox火狐浏览器设为首页的方法

完美解决安卓端百度浏览器屏蔽fixed悬浮元素的问题

h5活动页面底部有个悬浮图片按钮,使用fixed悬浮定位在底部,但是在安卓端的百度浏览器下打开,却发现该图片一闪而过,在百度浏览器中消失不见。

Fiddler无法正常抓取谷歌等浏览器的请求_解决方案

fiddler会自动给浏览器设置一个代理127.0.0.1端口8888,并且记忆浏览器的代理设置,所有的请求先走fiddler代理,再走浏览器代理。解决方案:关闭SwitchyOmega代理,或者使用其代理中的系统代理选项。即可解决问题。

js判断浏览器内核是否是safari浏览器

PC端只有Chrome有Safari字段吗?为什么不需要判断其他浏览器?其实360,QQ等浏览器的userAgent字段也会带有Safari字段,但是由于他们基于Chrome二次开发的,所有也会携带有Chrome字段。

常用浏览器内核及分类

浏览器是网页运行的平台,常用的浏览器有 IE、火狐(Firefox)、谷歌(Chrome)、Safari和Opera等。我们平时称为五大浏览器。 浏览器内核分成两部分:渲染引擎和 JS 引擎

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!