使用 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

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

Chrome

Chrome 正在实现 Sanitizer API。在 Chrome 93 或更高版本中,您可以通过启用 about://flags/#enable-experimental-web-platform-features 标志来试用此行为。在较低版本的 Chrome Canary 和开发者渠道中,您可以通过 --enable-blink-features=SanitizerAPI 启用此功能,并立即试用。请参阅有关如何使用 flag 运行 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 WestSanitizer API Playground

参考


照片由 Unsplash 用户 Towfiqu barbhuiya 拍摄。