使用 Sanitizer API 安全地操控 DOM

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

Jack J
Jack J

应用始终需要处理不受信任的字符串,但安全地将相应内容作为 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 中。

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

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 Surface

与 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 也正在实施此 API。

步骤 状态
1. 创建铺垫消息 完成
2. 创建规范草稿 完成
3. 收集反馈并对设计进行改进 完成
4. Chrome 源试用 完成
5. 发布 打算在 M105 上发货

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

WebKit:请在 WebKit 邮寄名单中查看响应。

如何启用 Sanitizer API

浏览器支持

  • Chrome:不支持。 <ph type="x-smartling-placeholder">
  • Edge:不支持。 <ph type="x-smartling-placeholder">
  • Firefox:背后有旗帜。
  • Safari:不支持。 <ph type="x-smartling-placeholder">

来源

通过 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 进行报告。选择 Blink>SecurityFeature>SanitizerAPI 组件并分享详细信息,以帮助实现人员跟踪问题。

演示

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

参考


照片由 Towfiqu barbhuiya 拍摄于 Unsplash 网站。