使用 setHTML() 方法消毒HTML

** 实验性功能:** ** 此为实验性技术**
在生产环境中使用前,请仔细查阅浏览器兼容性表

Element接口的 setHTML() 方法提供了一种解析和清理HTML字符串的安全方法,可生成DocumentFragment 接口的 setHTML() 方法提供了一种跨站脚本安全的解析方式,可将 HTML 字符串转换为 DocumentFragment,并将其作为子树插入元素的 DOM 中。

语法

setHTML(input)
setHTML(input, options)

参数

input

定义待清理并注入元素的HTML字符串。

元素周期表

options 可选

包含以下可选参数的选项对象:

sanitizer

一个 SanitizerSanitizerConfig 对象,用于定义允许保留或移除的输入元素,或使用字符串 “default” 采用默认配置。请注意,若需重复使用配置,通常“Sanitizer”SanitizerConfig更高效。若未指定,则使用默认净化器配置。

返回值

None(undefined)。

异常

TypeError

options.sanitizer 接收以下情况时抛出此异常:

描述

setHTML() 方法提供了一种跨站脚本安全的处理方式:将 HTML 字符串解析并净化为 DocumentFragment,随后将其作为子树插入元素的 DOM 中。

setHTML() 会移除 HTML 输入字符串中当前元素上下文中无效的元素,例如位于 <col> 外部的元素。接着移除任何不符合清理程序配置的 HTML 实体,并进一步清除所有存在 XSS 风险的元素或属性——无论它们是否被清理程序配置允许。

若未在 options.sanitizer 参数中指定净化器配置,则 setHTML() 将采用默认的 Sanitizer 配置。该配置允许所有被视为跨站脚本安全的元素和属性,同时禁止被视为不安全的实体。可通过指定自定义净化器或净化器配置来选择允许或移除的元素、属性及注释。请注意:即使净化器配置允许不安全选项,使用本方法时仍会将其移除(该方法会隐式调用Sanitizer.removeUnsafe())。

向元素插入不可信的 HTML 字符串时,应使用 setHTML() 替代 Element.innerHTML。除非存在允许不安全元素和属性的特殊需求,否则也应优先使用该方法替代Element.setHTMLUnsafe()

请注意,由于该方法会对所有输入字符串进行XSS安全实体的净化处理,因此无法通过可信类型API实现安全验证。

示例

基本用法

本示例展示了使用 setHTML() 对 HTML 字符串进行安全处理和注入的几种方式。

// Define unsanitized string of HTML
const unsanitizedString = "abc <script>alert(1)<" + "/script> def";
// Get the target Element with id "target"
const target = document.getElementById("target");

// setHTML() with default sanitizer
target.setHTML(unsanitizedString);

// Define custom Sanitizer and use in setHTML()
// This allows only elements: div, p, button (script is unsafe and will be removed)
const sanitizer1 = new Sanitizer({
  elements: ["div", "p", "button", "script"],
});
target.setHTML(unsanitizedString, { sanitizer: sanitizer1 });

// Define custom SanitizerConfig within setHTML()
// This removes elements div, p, button, script, and any other unsafe elements/attributes
target.setHTML(unsanitizedString, {
  sanitizer: { removeElements: ["div", "p", "button", "script"] },
});

setHTML() 实时演示

本示例通过不同净化器调用该方法进行实时演示。代码定义了两个可点击按钮,分别使用默认净化器和自定义净化器对HTML字符串进行净化与注入。原始字符串和净化后的HTML均被记录,便于您检查每种情况下的处理结果。

HTML

HTML定义了两个 <button> 元素用于应用不同过滤器,另有按钮可重置示例,以及用于注入字符串的<div> 元素。

<button id="buttonDefault" type="button">Default</button>
<button id="buttonAllowScript" type="button">allowScript</button>

<button id="reload" type="button">Reload</button>
<div id="target">Original content of target element</div>

JAVASCRIPT

首先定义待清理的字符串,该字符串在所有场景中保持一致。该字符串包含<script>元素和onclick事件处理器,二者均被视为XSS高危元素。同时定义了刷新按钮的事件处理器。

// Define unsafe string of HTML
const unsanitizedString = `
  <div>
    <p>This is a paragraph. <button onclick="alert('You clicked the button!')">Click me</button></p>
    <script src="path/to/a/module.js" type="module"><script>
  </div>
`;

const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());

接着定义按钮的点击处理程序,该按钮通过默认净化器设置HTML。这应在插入HTML字符串前移除所有不安全实体。请注意,您可在Sanitizer()构造函数示例中精确查看被移除的元素。

const defaultSanitizerButton = document.querySelector("#buttonDefault");
defaultSanitizerButton.addEventListener("click", () => {
  // Set the content of the element using the default sanitizer
  target.setHTML(unsanitizedString);

  // Log HTML before sanitization and after being injected
  logElement.textContent =
    "Default sanitizer: remove script element and onclick attributenn";
  log(`nunsanitized: ${unsanitizedString}`);
  log(`nsanitized: ${target.innerHTML}`);
});

下一个点击处理器使用自定义净化器设置目标HTML,该净化器仅允许<div><p><script> 元素。请注意,由于使用了setHTML方法,<script>元素也将被移除!

const allowScriptButton = document.querySelector("#buttonAllowScript");
allowScriptButton.addEventListener("click", () => {
  // Set the content of the element using a custom sanitizer
  const sanitizer1 = new Sanitizer({
    elements: ["div", "p", "script"],
  });
  target.setHTML(unsanitizedString, { sanitizer: sanitizer1 });

  // Log HTML before sanitization and after being injected
  logElement.textContent =
    "Sanitizer: {elements: ['div', 'p', 'script']}n Script removed even though allowedn";
  log(`nunsanitized: ${unsanitizedString}`);
  log(`nsanitized: ${target.innerHTML}`);
});

结果

点击“默认”和“允许脚本”按钮,分别查看默认和自定义净化器的效果。请注意,在两种情况下,即使净化器明确允许,<script>元素和onclick处理程序仍会被移除。

共有 54 条评论

  1. 所以 .setHTML(“<script>...</script>”) 不会设置 HTML?

  2. 我们本周已在 Firefox Nightly(仅限此版本)中默认启用此功能。

    • 等这个功能进入基线版本时,我迫不及待要在 Lit 中使用它。

      虽然lit-html模板因模板字符串不可伪造而具备XSS防护能力,但我们确实存在`unsafeHTML()`这类工具,允许将不可信字符串作为HTML处理——这显然存在安全隐患。

      借助`Element.setHTML()`,我们可以创建`safeHTML()`指令,同时允许开发者指定数据净化选项。

      • 为何不直接采用DOMPurify?它经过实战检验,且支持与本提案完全一致的配置机制。

        • 其一,lit-html本身没有任何依赖项。

          其二,即便存在依赖,DOMPurify的体积约为lit-html核心的2.7倍(压缩后3.1KB),而unsafeHTML()指令压缩后不足400字节。要整合这么庞大的净化器实在不现实,而且选择哪种方案本就是需要讨论的意见问题。况且lit-html具备可扩展性,开发者完全可以自行编写基于DOMPurify的安全HTML指令。

          对我们而言,采用安全模板+不安全指令的方案更为简洁,无需在两者之间进行精细化解析。

          不过内置API对我们来说情况不同。它是标准、稳定的组件,最终应被所有Web开发者熟知。我们无法在不增加额外依赖或代码的情况下集成它,只能采用标准平台选项。

        • 框架为何要这么做?

          应用开发者目前仍可自由使用,但若框架强制要求使用,将不必要地增加不需要该功能用户的包体积。

  3. 看到这个功能实在欣喜,毕竟过去25年间(https://www.bugcrowd.com/glossary/cross-site-scripting-xss/)我们都是在没有它的情况下生存下来的。我始终认为这是DOM API中显而易见的缺失环节,至今仍不明白为何耗时如此之久。

    但最重要的是它终于来了,我衷心感谢所有为此付出的努力。

  4. 作为多年来处理过大量内容注入漏洞的人,终于看到这个功能实在令人欣喜。更荒谬的是,其他更繁琐的解决方案(如CSP)已存在多年,而这个功能却迟至今日才出现。

  5. 或许现在该考虑超越JavaScript文档开头的“use strict”声明,提供更灵活的配置选项。

    我认为通过配置对象定义脚本选项(如内容净化及其他脚本配置)会很有帮助。

    毕竟几乎总要确保向后兼容性,这种方案或许可行。我并非规范制定者,仅是提出构想。React采用“use client/server”模式,因此这种方案会更核心化且明确。

  6. 这本质上是innerHTML的安全版本吗?

    • 我不明白既然HTML是由你生成并注入的,为何还需要“安全”版本?

    • 是的,更准确的说法是它相当于内置的DOMPurify(dompurify是常用于注入前净化HTML的npm包)。

  7. > 它会移除净化器配置中禁止的所有HTML实体,并进一步清除任何XSS高危元素或属性——无论净化器配置是否允许这些元素

    强调部分为本人添加。我不理解这种设计选择。如果我明确允许script标签,为何还要将其移除?

    若方法命名为setXSSSafeSubsetOfHTML倒还说得通,但 setHTML 方法带有无法覆盖的过滤器实在令人费解。

    • 这主要是为提升操作便利性而设计的,因此在过程中不让危险操作更便捷也合乎逻辑。你仍可通过赋值innerHTML等方式执行危险操作。

      • 我同意,不过也认同楼主说的,方法名确实有点令人困惑。“safeSetHTML”或“setUntrustedHTML”之类的命名会更清晰。

        • 我喜欢React的dangerouslySetInnerHTML。这个名称明确传达出“你可以这么做,但真的真的真的不该这么做”的警示。

        • 我不这么认为,有充分理由认为最直观的方法应该代表安全操作。初学者很可能会优先选择这类方法。若需要不安全操作,开发者通常能快速识别并找到相应方法。

      • 理想情况下应命名为dangerouslySetInnerHTML,但事后诸葛亮嘛…

    • 若需使用存在XSS风险的清理器,必须调用setHTMLUnsafe。

    • 我猜他们追求的是安全默认值…核心思路是:那些不仔细阅读文档或不严格监控动态生成HTML来源的人,很可能会直接使用“setHTML()”。

      与此同时,还有“setHTMLUnsafe()”方法,当然还有经典的.innerHTML。

    • 这难道不会打开安全闸门吗?允许代码自身再次调用setHTML,然后进一步修改参数来提升权限?

  8. API设计可以优化。文档片段本就用于复用,应支持可选的片段键参数接收文档片段。若非片段则抛异常,若有子节点则先清空内容。

    • 文档片段究竟如何实现复用?

      它们在追加时会将内容清空至新父节点,因此若不重建就无法有意义地二次追加。

      <template>本就支持复用——你需要克隆它才能使用,之后还能再次克隆。

  9. 在此发现了一个polyfill实现 https://github.com/mozilla/sanitizer-polyfill

  10. > 此功能尚未达到基准状态,因为它在部分主流浏览器中无法正常工作。

    这很有意思,但似乎尚处于早期阶段——目前 所有 主流浏览器均未支持该功能。

  11. 很棒。我认为一旦HTMX(或类似库)采用该功能,服务器端就无需再进行数据净化处理了吧?

    • 你当真认为服务器有朝一日能完全免除客户端数据的净化处理?真的?我不这么认为。任何宣称“无需净化客户端数据”的建议,都会让我立刻怀疑提议者要么能力不足、要么经验浅薄、要么意图欺诈。

      没有理由 对客户端数据进行安全处理,却存在所有理由 必须 进行安全处理。

      • 若在服务器端进行安全处理,就意味着你对客户端的安全/不安全特性做出了预设。虽然可能正确预设,但这要求所有客户端保持同步,而这很难做到。

        从HTML角度净化过的数据,未必适用于原生桌面/移动应用、客户端UI框架等场景。例如Cloudflare的CloudBleed安全事件中,源服务器发送的畸形img标签(本身在浏览器中并无安全隐患)导致其边缘服务器将堆内存中的垃圾数据(含各类敏感信息)附加到某些请求中,这些请求随后被搜索引擎索引。

        内容消费方始终是数据安全处理的唯一责任主体,必须确保所有传入数据的安全呈现。有时“消费方”与服务器共存(如服务器渲染HTML且无原生/API用户),但多数情况下并非如此。

        • > 若在服务器端进行数据净化,你是在替客户端预设安全边界。

          不。我只为自身服务器的安全性做决策。作为后端开发者,我根本不在乎你的前端代码。我永远不会信任来自你前端代码的请求。如果前端代码无法正确处理编码,后端代码会采取必要措施阻止愚蠢的字符串注入攻击。我无法追溯请求的来源路径。即便你认为请求来自浏览器端的代码,也无法保证它在到达后端前没有被篡改过。

          • 用户输入在服务器端怎么可能不安全?难道你在进行某种评估?

            用户生成内容本就不该被信任(包括客户端传入请求、用户填写的数据字段等)

            • 这是认真的问题吗?

              INSERT INTO table (user_name) VALUES …

              你该不会是今天那万名坚持在服务器端净化用户数据的人之一吧?

              • 通过拼接包含用户输入的字符串来调用SQL驱动程序,然后直接执行?搞什么?

                我非常想知道你使用的技术栈中存在这种问题。

                • 这种操作在任何允许执行命令字符串的技术栈中都屡见不鲜。早期很多数据库甚至不支持参数化插入这类功能。

              • 你是今天那万分之一使用参数化查询和预编译语句的人吗?

                除非你做些拼接字符串到SQL查询这种蠢事,否则根本无需对数据库输入进行“净化”。SQL注入问题早已解决。

                数据从数据库传入并发送给客户端,这当然没问题。但除非你做些蠢事,比如把字符串拼接成SQL语句,否则很久以前就没必要对输入数据库的数据进行“净化”了。

                编辑:重读这条评论时才发现重复了部分内容,但我保留下来,因为值得强调。

                • 当然,使用能解决SQL注入问题的依赖库就能解决这个问题。

                  除了SQL注入,还存在命令注入或日志注入风险。文件名需要进行安全处理,任何用户上传的内容(包括图片)都需防范XSS攻击。所有传入的JSON数据都应进行安全处理,移除多余字段等。

                  日志注入是一种相当恶劣的攻击手段,根据日志处理方式的不同,可能导致XSS或命令注入漏洞。

      • 这可能是个复杂且易出错的过程,尤其当涉及多种需要不同净化器的媒介时。显然你应该这样做。但在这种场景下,最佳实践是尽可能在使用位置附近进行净化。我见过糟糕的代码库,它们在将用户输入存储到数据库前尝试应用多层净化,然后在输出前逆转多余层级——显然这行不通。

        关键在于:若能将安全处理移至 更接近 使用位置,且该处理由目标平台的标准库提供,这将带来巨大优势。

        • 你错误地假设客户端代码是提交字符串在传输路径中最后被修改的位置。中间人攻击者可能另有打算,因此必须始终在服务器端进行最后一道安全检查。

        • 所谓“安全检查”通常指的是“转义”操作。用户输入的显示名称为

          • 同意。我想到的代码库在存储前会进行HTML编码,但当需要发送短信时,又得记得解码。太糟糕了。

      • 尽可能在使用前进行数据净化通常是最佳方案,这样就不必长期追踪哪些数据经过净化、哪些未净化。

        (若数据净化操作不具备幂等性,这一点尤为关键!)

      • 从某种角度看,直接在显示时进行净化反而更简单,否则会引发双重转义等问题。

        • 简单不等于更好——多年来因输入净化缺失而被利用的众多漏洞,恰恰印证了这一点。

          • 此处简单反而更优。在字符串使用点进行净化处理。局部化操作便于验证净化是否符合上下文要求。替代方案则需维护字符串的完整性链条并确保其安全性。

            • 若在客户端使用当然可行,但为何要让服务器参与?若需向服务器传输数据,就必须假设所有数据都来自怀有恶意攻击者的黑客。我不在乎数据来源,我的服务器会为自身安全进行净化处理。毕竟,数据从 你的 浏览器发出时看似“干净”,并不代表上游传输过程中没有被篡改——TLS协议也无济于事。如果我们对数据进行了双重编码,那也没关系,至少不会炸掉服务器。归根结底,这才是最重要的。如果客户端解码失败,那也只能耸耸肩了。

  12. 功能强大,命名糟糕。

    • 我时常好奇:若能在假想世界重构一切,DOM API会呈现何种形态?

    • 为何?它难道不设置HTML吗?

      • 光看方法名根本看不出“里面藏着大量隐蔽的清理逻辑”…

        “setSafeHTML()”这类命名会更理想。(毕竟是Mozilla,肯定要开好几轮委员会会议才能定名)…

        • 那改成safeSetHTML比setSafeHTML更合理?

          后者暗示HTML本身已安全,前者则是安全设置HTML的方式。

          若直接用setHTML,则可能暗示对安全性不作要求。

  13. Cursor构建了一个伪sethtml:https://github.com/skorotkiewicz/pseudo-sethtml

    • 该代码仅实施最基础且粗糙的正则表达式过滤,连初级XSS课程的输入都能绕过。以Node示例代码和输入字符串为例:

        <p>Hello <scr<script>ipt>alert(1)</scr<script>ipt> World</p>
      

      程序输出:

        $ node .
        <p>Hello <script>alert(1)</script> World</p>
        {
          sanitizedHTML: ‘<p>Hello <script>alert(1)</script> World</p>’,
          wasModified: true,
          removedElements: [],
          removedAttributes: []
        }
      

      要求聊天机器人编写安全函数,却在未经任何审查的情况下将其发布供他人使用,这种行为不仅缺乏尊重,更是危险且严重疏忽的。请立即删除此内容。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号