使用 Sanitizer API 进行安全的 DOM 操作

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

应用程序一直都在处理不受信任的字符串,但是安全地将该内容渲染为 HTML 文档的一部分可能很棘手。如果不够小心,很容易意外地为跨站脚本 (XSS) 创建机会,恶意攻击者可能会利用这些机会。

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

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

转义用户输入

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

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

如果您转义上面输入字符串中的 HTML 特殊字符,或者使用 .textContent 展开它,则不会执行 alert(0)。但是,由于用户添加的 <em> 也按原样展开为字符串,因此为了保持 HTML 中的文本装饰,不能使用此方法。

这里最好的做法不是转义,而是清理

清理用户输入

转义和清理之间的区别

转义是指用 HTML 实体替换特殊的 HTML 字符。

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

示例

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

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`
// Sanitized ⛑
$div.innerHTML = `<em>hello world</em><img src="">`

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

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

Sanitizer API

Sanitizer API 的使用方式如下

const $div = document.querySelector('div')
const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.setHTML(user_input, { sanitizer: new Sanitizer() }) // <div><em>hello world</em><img src=""></div>

但是,{ sanitizer: new Sanitizer() } 是默认参数。所以它可以像下面这样。

$div.setHTML(user_input) // <div><em>hello world</em><img src=""></div>

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

要获取清理结果作为字符串,可以使用 .innerHTMLsetHTML() 结果中获取。

const $div = document.createElement('div')
$div.setHTML(user_input)
$div.innerHTML // <em>hello world</em><img 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" ]}) })
// <div>hello <b>world</b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({blockElements: [ "b" ]}) })
// <div>hello <i>world</i></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: []}) })
// <div>hello world</div>

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

  • allowAttributes
  • dropAttributes

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

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

$div.setHTML(str)
// <div><span id="foo" class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["span"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["p"]}}) })
// <div><span>hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["*"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({dropAttributes: {"id": ["span"]}}) })
// <div><span class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {}}) })
// <div>hello</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 })
// <div><custom-elem>hello</custom-elem></div>

API 表面

与 DomPurify 的比较

DOMPurify 是一个众所周知的库,它提供了清理功能。Sanitizer API 和 DOMPurify 之间的主要区别在于,DOMPurify 将清理结果作为字符串返回,您需要通过 .innerHTML 将其写入 DOM 元素。

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sanitized
// `<em>hello world</em><img src="">`

当浏览器中未实现 Sanitizer API 时,DOMPurify 可以用作回退。

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

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

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

API 状态和浏览器支持

Sanitizer API 正在标准化过程中进行讨论,Chrome 正在实施它。

步骤 状态
1. 创建说明 完成
2. 创建规范草案 完成
3. 收集反馈并迭代设计 完成
4. Chrome Origin Trial 完成
5. 发布 M105 上的发布意向

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

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

如何启用 Sanitizer API

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

Chrome

Chrome 正在实施 Sanitizer API。在 Chrome 93 或更高版本中,您可以通过启用 about://flags/#enable-experimental-web-platform-features 标志来试用该行为。在早期版本的 Chrome Canary 和 Dev 频道中,您可以通过 --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 的实现中发现任何错误或意外行为,请提交错误报告。选择 Blink>SecurityFeature>SanitizerAPI 组件并分享详细信息,以帮助实施人员跟踪问题。

演示

要查看 Sanitizer API 的实际应用,请查看 Sanitizer API Playground,作者是 Mike West

参考


照片由 Towfiqu barbhuiyaUnsplash 上拍摄。