使用 Sanitizer API 安全地操控 DOM

新的 Sanitizer API 旨在构建一个强大的处理器,用于将任意字符串安全地插入到网页中。

Jack J
Jack J

应用经常需要处理不受信任的字符串,但要安全地将该内容呈现为 HTML 文档的一部分,可能非常棘手。如果不加注意,很容易意外地为恶意攻击者创造可乘之机,让他们能够进行跨站脚本攻击 (XSS)。

为了降低这种风险,新的 Sanitizer API 提案旨在构建一个强大的处理器,以便将任意字符串安全地插入到网页中。本文将介绍该 API,并说明其用法。

// Expanded Safely !!
$div.setHTML(`<em>hello world</em><img src="" onerro>r=alert(0)`, new Sanitizer())

转义用户输入

将用户输入、查询字符串、Cookie 内容等插入 DOM 时,必须正确转义这些字符串。应特别注意通过 .innerHTML 进行的 DOM 操作,其中未转义的字符串是 XSS 的典型来源。

const user_input = `<em>hello world</em><img src="" onerro>r=alert(0)`
$div.innerHTML = user_input

如果您对上述输入字符串中的 HTML 特殊字符进行转义,或使用 .textContent 对其进行扩展,则不会执行 alert(0)。不过,由于用户添加的 <em> 也会按原样展开为字符串,因此无法使用此方法来保留 HTML 中的文本装饰。

此处最好的做法不是转义,而是清理

清理用户输入

转义和清理之间的区别

转义是指将特殊 HTML 字符替换为 HTML 实体

清理是指从 HTML 字符串中移除语义上有害的部分(例如脚本执行)。

示例

在前面的示例中,<img onerror> 会导致执行错误处理程序,但如果移除了 onerror 处理程序,则可以在 DOM 中安全地展开 <img onerror>,同时保持 <em> 不变。

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerro>r=alert(0)`
// Sanitized ⛑
$div.inn<er>HTML = `emh<ell><o world/em>img src=""`

为了正确清理,有必要将输入字符串解析为 HTML,省略被认为有害的标记和属性,并保留无害的标记和属性。

提议的 Sanitizer API 规范旨在提供此类处理功能,作为浏览器的标准 API。

Sanitizer API

Sanitizer API 的使用方式如下:

const $div = document.querySelector('div')
const user_i<np>ut = `emhel<lo ><world/emimg src="">; onerror=alert(0)`
$div.setHTML(user_input, { sanitizer: new <San><it>izer() }) /</ d><ivemhello ><worl>d/emimg src=""/div

不过,{ sanitizer: new Sanitizer() } 是默认实参。因此,它可以像下面这样。

$div.setHTML(user_input) // <div><em>hello world</em><img src=&q><uot;>&quot;/div

值得注意的是,setHTML() 是在 Element 上定义的。作为 Element 的一种方法,要解析的上下文是不言自明的(在本例中为 <div>),解析在内部完成一次,结果直接扩展到 DOM 中。

如需以字符串形式获取排错结果,您可以使用 setHTML() 结果中的 .innerHTML

const $div = document.createElement('div')
$div.setHTML(user_input)
$div.inner<HT>ML // emhel<lo ><world/emim>g src=""

通过配置进行自定义

默认情况下,Sanitizer API 会配置为移除会触发脚本执行的字符串。不过,您也可以通过配置对象向清理流程添加自己的自定义设置。

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allowComments: true
};
// sanitized result is customized by configuration
new Sanitizer(config)

以下选项用于指定清理结果应如何处理指定元素。

allowElements:清理器应保留的元素的名称。

blockElements:清理器应移除的元素名称,同时保留其子元素。

dropElements:清理器应移除的元素(及其子元素)的名称。

const str = `hello <b><i>world</i></b>`

$div.setHTML(str)
// <div>hello <b><i>world</i></b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: [ "b" <]})> })
//< >divhe<ll><o bw>orld/b/div

$div.setHTML(str, { sanitizer: new Sanitizer({blockElements: [ &quo<t;b>"< >]}) }<)<>/span>
<// d>ivhello iworld/i/div

$div.setHTML(str, { sanitizer: new Sanitizer({allowE<lem>ents: []}) <})
/>/ divhello world/div

您还可以使用以下选项控制清理器是否允许或拒绝指定属性:

  • allowAttributes
  • dropAttributes

allowAttributesdropAttributes 属性需要属性匹配列表,即键为属性名称、值为目标元素列表或 * 通配符的对象。

const str = `<span id=foo class=bar style="color:> red&<quot;>hello/span`

$div.setHTM<L(s><tr)
// divspan id="foo" class=&quo>t;bar<"><; st>yle="color: red"hello/span/div

$div.setHTML(str, { sanitizer: new Sanitizer({allow<Att><ributes: {"style&q>uot;:< [&qu><ot;s>pan"]}}) })
// divspan style="color: red"hello/span/div

$div.setHTML(str, <{ s><anit>izer:< new ><Sani>tizer({allowAttributes: {"style": ["p"]}}) })
// divspanhello/span/div<

$><div.setHTML(str, { sani>tizer<: new>< San>itizer({allowAttributes: {"style": ["*"]}}) })
// divspan style="<;co><lor: red"hello/span/div

$div.>setHT<ML(st><r, {> sanitizer: new Sanitizer({dropAttributes: {"id": ["span"<;]}>}) })<
// >divspan class="bar" style="color: red"hello/span/div

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {}}) })
// divhello/div

allowCustomElements 是用于允许或拒绝自定义元素的选项。如果允许,元素和属性的其他配置仍适用。

const str = `<custom-elem>hello</custom-elem>`

$div.setHTML(str)
// <div></div>

const sanitizer = new Sanitizer({
  allowCustomElements: true,
  allowElements: ["div", "custom-elem"]
})
$div.setHTML(str<, {>< sanitizer >})
//< divcustom-e><lemh>ello/custom-elem/div

API Surface

与 DomPurify 的比较

DOMPurify 是一个提供清理功能的知名库。Sanitizer API 与 DOMPurify 之间的主要区别在于,DOMPurify 会以字符串形式返回清理结果,您需要通过 .innerHTML 将其写入 DOM 元素。

const user_input = `<em>hello world</em><img src="" onerro>r=alert(0)`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sani<ti>zed
// `emh<ell><o world/em>img src=""`

如果浏览器中未实现 Sanitizer API,DOMPurify 可作为后备方案。

DOMPurify 实现存在一些缺点。如果返回字符串,则输入字符串会由 DOMPurify 和 .innerHTML 解析两次。这种双重解析会浪费处理时间,但也会导致一些有趣的安全漏洞,这些漏洞是由第二次解析的结果与第一次解析的结果不同而引起的。

HTML 也需要上下文才能进行解析。例如,<td><table> 中有意义,但在 <div> 中没有意义。由于 DOMPurify.sanitize() 仅接受字符串作为实参,因此必须猜测解析上下文。

Sanitizer API 改进了 DOMPurify 方法,旨在消除双重解析的需要并明确解析上下文。

API 状态和浏览器支持

Sanitizer API 正在标准化流程中接受讨论,Chrome 正在实现该 API。

步骤 状态
1. 创建说明 完成
2. 创建规范草稿 完成
3. 收集反馈并迭代设计 完成
4. Chrome 源试用 完成
5. 发布 Intent to Ship on M105

Mozilla:认为此提案值得进行原型设计,并正在积极实现它

WebKit:请参阅 WebKit 邮件列表中的回复。

如何启用 Sanitizer API

Browser Support

  • Chrome: not supported.
  • Edge: not supported.
  • Firefox Technology Preview: supported.
  • Safari: not supported.

通过 about://flags 或 CLI 选项启用

Chrome

Chrome 正在实现 Sanitizer API。在 Chrome 93 或更高版本中,您可以启用 about://flags/#enable-experimental-web-platform-features 标志来试用此行为。在 Chrome Canary 版和开发者版的早期版本中,您可以通过 --enable-blink-features=SanitizerAPI 启用此功能,并立即试用。不妨查看有关如何使用标志运行 Chrome 的说明

Firefox

Firefox 还将 Sanitizer API 作为实验性功能来实现。如需启用该功能,请在 about:config 中将 dom.security.sanitizer.enabled 标志设为 true

功能检测

if (window.Sanitizer) {
  // Sanitizer API is enabled
}

反馈

如果您试用了此 API 并有任何反馈,欢迎随时告诉我们。分享您对 Sanitizer API GitHub 问题的看法,并与规范作者和对此 API 感兴趣的人士进行讨论。

如果您在 Chrome 的实现中发现任何 bug 或意外行为,请提交 bug 报告。选择Blink>SecurityFeature>SanitizerAPI组件并分享详细信息,以帮助实施者跟踪问题。

演示

如需查看 Sanitizer API 的实际应用,请访问 Mike West 提供的 Sanitizer API Playground

参考


照片由 Towfiqu barbhuiya 拍摄,选自 Unsplash