我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 97.1% 的成绩。 了解更多

深度解析

深度解析

GHSA-cg2g-xh64-v4q7:DOMPurify 在 Shadow Root 中的 SAFE_FOR_TEMPLATES 绕过

DOMPurify 3.4.4–3.4.16 中,拆分的 ${...} 表达式可在已附加的 Shadow Root 内绕过 SAFE_FOR_TEMPLATES(严重,CVSS v4.0 9.2)。请升级至 3.4.17。

要点速览

  • DOMPurify 是每周 npm 下载量约 4500 万次的 HTML 净化器。当模板表达式在已附加的 Shadow Root 内分散于多个文本节点,且仅在净化后重新组合时,它无法移除这些表达式。此缺口需要同时启用 SAFE_FOR_TEMPLATES: true 和 DOM 节点模式(RETURN_DOM、RETURN_DOM_FRAGMENT 或 IN_PLACE)。
  • 能提供带有 Shadow Root 的 DOM 节点的攻击者,将 ${...} 之类的表达式拆分到由不允许元素隔开的文本节点中。DOMPurify 移除分隔元素后,片段变成相邻节点,重组的表达式保留到输出,被下游模板引擎求值,导致跨站脚本或服务端模板注入。
  • 3.4.4 至 3.4.16 受影响。公告评级为严重,CVSS v4.0 9.2。已有公开概念验证;截至 2026 年 10 月 23 日,没有已知在野利用,问题未列入 CISA KEV 目录。
  • 升级至 DOMPurify 3.4.17。如果无法升级,将不可信标记作为字符串传入,而不是预解析的 DOM 节点:对于字符串输入,DOMPurify 自身解析器会忽略声明式 Shadow Root。

我是否受到影响?

以下条件全部成立时,您就受到影响:

  • 您的依赖解析为 DOMPurify 3.4.4 至 3.4.16。
  • 配置启用了 SAFE_FOR_TEMPLATES(该选项移除 {{...}}、${...} 和 <%...%> 模板语法,使输出可以传给模板引擎)。
  • 调用 sanitize 时使用处理 DOM 节点而非返回字符串的模式:RETURN_DOM、RETURN_DOM_FRAGMENT 或 IN_PLACE(原地净化输入节点)。本文将这些称为 DOM 节点模式。
  • 输入可以是已经附加了 Shadow Root 的 DOM 节点。

将不可信输入作为字符串净化的应用不会进入此路径:正如 3.4.17 README 所述,DOMPurify 自身解析器会忽略声明式 Shadow Root。

分支 受影响版本 修复版本
DOMPurify 3.4.x 3.4.4 – 3.4.16 3.4.17
DOMPurify ≤ 3.4.3 不受影响:该漏洞依赖的 Shadow Root 遍历和遍历后清理均在 3.4.4 引入 -

检查已安装版本及风险选项出现的位置:

# Installed version
npm ls dompurify
node -e "console.log(require('dompurify/package.json').version)"

# Lock file check (matches lockfileVersion 1 "dompurify" and v2/v3 "node_modules/dompurify" keys)
grep -nE '"(node_modules/)?dompurify"' package-lock.json

# Container image check
docker run --rm <your-image> node -e "console.log(require('dompurify/package.json').version)"

# Find the risky configuration combination in your code
# (-A 8 lets the second grep see sibling options on adjacent lines)
grep -rn -A 8 "SAFE_FOR_TEMPLATES" --include="*.js" --include="*.ts" . | grep -iE "RETURN_DOM|IN_PLACE"

一个细节扩大了输入面:Shadow Root 不必通过 attachShadow 以编程方式创建。在支持声明式 Shadow DOM 的浏览器中,通过 setHTMLUnsafe() 或 Document.parseHTMLUnsafe() 解析包含 <template shadowrootmode="open"> 的标记,会产生同样已附加的 Shadow Root。因此,应用若在净化前采用这种解析方式,普通 HTML 字符串也能触及漏洞。

如何修复?

升级 DOMPurify:

npm install dompurify@3.4.17
# or: pnpm add dompurify@3.4.17   |   yarn add dompurify@3.4.17

如果暂时无法升级,可改变调用方式:

  • 将不可信标记作为字符串传给 sanitize。DOMPurify 解析器会忽略字符串输入中的声明式 Shadow Root,因此不会出现遍历遗漏的已附加 Shadow Root。这是 3.4.17 README 记录的行为。
  • 如果必须接受 DOM 节点,在升级前不要将 SAFE_FOR_TEMPLATES 与 IN_PLACE、RETURN_DOM 或 RETURN_DOM_FRAGMENT 组合使用。片段也可携带已附加 Shadow Root,因此与另外两种模式处于同一路径。

临时措施的局限是依赖整个代码库中每个调用位置遵守规则;修复则为所有调用方消除净化器内部的缺口。

根本原因是什么?

SAFE_FOR_TEMPLATES 通过两种机制消除模板表达式,漏洞在于第二种看不到 Shadow Root。

第一种机制是在主遍历期间逐个文本节点清除表达式:_sanitizeElements 访问每个节点时(受影响版本的 src/purify.ts:2246),_stripTemplateExpressions 会移除匹配表达式正则的内容。拆分表达式可以绕过它,因为单个片段不符合定界符模式:MUSTACHE_EXPR 需要 {{ 或 }},TMPLIT_EXPR 需要 ${,ERB_EXPR 需要 <% 或 %>。单独的 $ 只是文本。

第二种机制正是针对这种情况的安全网。遍历完成后,_scrubTemplateExpressions(src/purify.ts:1717)在树上调用 normalize(),合并相邻文本节点,再次移除合并后才形成的表达式。只有这一轮处理能够捕获拆分表达式。公告解释了它为何无法触及 Shadow 内容:

_scrubTemplateExpressions(src/purify.ts:1717-1751):
  - 第 1718 行的 node.normalize() 不会合并 Shadow Root 内的文本节点
    (DOM 规范)。
  - 第 1723-1731 行的 NodeIterator 不会进入 Shadow 树
    (DOM 规范)。
  - 第 1743-1750 行的显式递归仅通过 querySelectorAll('template')
    进入 <template>.content;没有对应的递归逻辑
    进入已附加的 Shadow Root。

Shadow Root 确实会被净化,但仅通过较早的 _sanitizeShadowDOM 阶段(src/purify.ts:2710)。该阶段逐节点移除表达式,并删除不允许的元素,却不执行遍历后的规范化与重新清理。因此,Shadow Root 文本只逐节点净化,不会作为合并后的整体重新检查。

不变量:移除元素后,相邻文本节点需要合并,并再次清除表达式。主树和 <template> 内容满足这一要求,但已附加的 Shadow Root 内没有任何机制强制执行,因此净化后片段才相邻的拆分表达式会在那里保留下来。

此缺口位于独立边界,并非较早修复的回归:该系列前一份公告 CVE-2026-65900(GHSA-gvmj-g25r-r7wr)将遍历后的处理扩展为递归进入 <template>.content,其回归测试仅涵盖模板内容,Shadow Root 没有被纳入。

如何利用?

攻击者构造宿主元素,其 Shadow Root 中包含由不允许元素隔开的多个文本节点,将表达式拆分其中。应用随后启用 SAFE_FOR_TEMPLATES 进行原地净化:

// Proof of concept from GHSA-cg2g-xh64-v4q7 (Cure53 advisory), trimmed
const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });

// Split "${alert(document.domain)}" so no single fragment matches a regex.
// <object> is not in the default ALLOWED_TAGS and is removed during
// sanitization, leaving the surrounding text-node siblings adjacent.
root.appendChild(document.createTextNode('$'));
root.appendChild(document.createElement('object'));
root.appendChild(document.createTextNode('{alert(document.domain)}'));

DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });

host.shadowRoot.normalize();
console.log(host.shadowRoot.textContent);
// "${alert(document.domain)}"

攻击路径逐步如下:

  1. 应用调用 DOMPurify.sanitize(node, cfg),启用 SAFE_FOR_TEMPLATES: true 和 DOM 节点模式(IN_PLACE、RETURN_DOM 或 RETURN_DOM_FRAGMENT),传入已附加 Shadow Root 的 DOM 节点。
  2. 攻击者控制 Shadow Root 中的文本节点及中间不允许的分隔元素。每个片段 $ 和 {alert(document.domain)} 单独看都是无害文本。
  3. _sanitizeAttachedShadowRoots 将 Shadow Root 传入 _sanitizeShadowDOM,后者逐文本节点清除表达式,并移除不允许的分隔元素,片段于是变得相邻。
  4. 遍历后的安全网是唯一能捕获合并表达式的阶段,但它不会进入 Shadow Root。
  5. 相邻片段通过 shadowRoot.textContent 或 shadowRoot.innerHTML 序列化为一个字符串,重建完整表达式。
  6. 下游模板求值器读取 Shadow Root 内容,计算攻击者表达式:在引擎上下文中执行脚本;如果接收端是服务端引擎,则发生模板注入。

利用需要一个会对 DOMPurify 返回内容求值的接收端。公告展示了注入原语,并列出典型求值器:new Function('return `' + content + '`')(),或任何 ${...} 和 {{...}} 引擎。机密性或完整性的后果取决于下游求值上下文。

拆分表达式如何到达模板引擎?

拆分表达式能够保留,是因为合并与重新清理只针对主树和模板内容,没有处理已附加 Shadow Root 的分支。从攻击者输入到求值的流程如下:

flowchart LR A["攻击者提供已附加
Shadow Root 的 DOM 节点"] --> B["应用启用 DOM 节点模式
及 SAFE_FOR_TEMPLATES 进行净化"] B --> C["逐节点清除遗漏了
拆分的表达式片段"] C --> D["不允许的分隔元素被移除,
片段变得相邻"] D --> E["遍历后的清理仅作用于主树
和模板内容"] E --> F["shadowRoot.textContent 序列化
完整表达式"] F --> G["下游模板引擎
对攻击者表达式求值"]

缺失的检查是合并与重新清理阶段:它从不进入 Shadow Root,因此没有机制再次清除合并后的片段。

攻击者能做什么?

已经演示的能力是向净化后的 Shadow Root 内容注入完整的攻击者自选模板表达式。在客户端上下文中,结果是在应用源中执行跨站脚本。当净化内容传入服务端模板时,结果是模板注入;对于支持丰富表达式的引擎,这经常可以升级为服务器上的远程代码执行。

公告列出的攻击条件如下:

  • 应用同时启用 SAFE_FOR_TEMPLATES 和 DOM 节点模式。
  • 应用传入已经附加 Shadow Root 的 DOM 节点;在受支持引擎中,声明式 Shadow DOM 配合 setHTMLUnsafe() 或 Document.parseHTMLUnsafe() 可以从普通 HTML 字符串生成这种节点。
  • 应用将净化后的 Shadow Root 内容传给模板求值器。
  • 净化器本身不要求身份验证、权限或用户交互。CVSS v4.0 向量为 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N;攻击复杂度高且存在攻击前提,正是因为上述三个应用侧条件必须同时满足。

条件成立时,攻击者表达式以求值引擎的权限运行,因此影响取决于接收端:可能是在浏览器窃取会话数据,也可能通过宽松的服务端模板执行命令。

为什么修复有效?

DOMPurify 3.4.17(拉取请求 #1672,提交 a77a3e7)将遍历后的不变量扩展到 Shadow Root。_sanitizeShadowDOM 完成 Shadow Root 遍历后,现在会执行主树同样的规范化与重新清理:

// cure53/DOMPurify, dist/purify.cjs.js (release 3.4.17)
   const _sanitizeShadowDOM = function (fragment) {
     ...
+    if (SAFE_FOR_TEMPLATES) _scrubOwnCharacterData(fragment);
     _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
   };

旧的递归函数 _scrubTemplateExpressions 被拆为两部分。_scrubOwnCharacterData 是非递归核心:调用 normalize(),遍历节点自身的文本、注释、CDATA 和处理指令节点,清除表达式。包装器继续递归进入 <template>.content。由于 _sanitizeShadowDOM 已经是嵌套 Shadow Root 和模板内容的递归入口,对每个片段运行核心清理可确保全部内容只清理一次,而不是按每个祖先节点重复扫描嵌套片段。

对每个 Shadow Root 执行合并与重新清理后,拆分表达式技巧不再有效:不允许的分隔元素被移除,normalize() 合并相邻片段后,重新清除阶段会看到完整的 ${...} 序列并移除它,与主树中的处理完全相同。

修复完整吗?对于受影响调用路径上的开放 Shadow Root,是的;同一版本还加固了周边机制,包括对净化过程中离开树的节点进行更严格的原地中和。

仍有一个边界没有修复,而是记录进 README:封闭 Shadow Root 无法由脚本读取,因此 DOMPurify 既看不到也无法净化其内容。这是平台限制,不是净化器缺口,文档建议仍然有效:将不可信标记作为字符串传入。

如何检查是否已遭入侵?

该公告没有已发布的入侵指标。在缺乏公开指导时,可审查以下一般信号:

  • 净化后的 DOM 节点输出进入模板引擎、类似 eval 的调用或 new Function 的位置。如果这些调用位置的净化输出包含表达式形态的内容,就意味着绕过已发生。
  • 存储内容中包含源自用户标记的模板语法(${、{{、<%),无论位于 Shadow Root 还是其他地方,因为绕过保留的正是这类内容。
  • 客户端错误日志或 CSP 报告提及应用通常不联系的域名。这可能指向净化后触发的出站请求或表达式求值。

如果发现求值证据,应根据求值引擎能够访问的范围,将相关会话或服务器上下文视为已暴露。

时间线

  • 2026 年 10 月 12 日:Cure53 发布公告 GHSA-cg2g-xh64-v4q7,并通过拉取请求 #1672 发布 DOMPurify 3.4.17。报告者 zx(GitHub:@manus-pi)获得致谢;GitHub 公告还向 @manus-use 致谢。
  • 2026 年 10 月 22 日:GitHub Advisory Database 发布并评审该公告。
  • 2026 年 10 月 23 日:本文发布。没有已知在野利用;问题未列入 CISA KEV 目录。

常见问题

默认 DOMPurify 配置受影响吗? 不受影响。此缺口要求同时启用 SAFE_FOR_TEMPLATES 和 DOM 节点模式,且输入 DOM 节点已附加 Shadow Root。默认字符串净化不会进入受影响路径,因为 DOMPurify 解析器会忽略字符串输入中的声明式 Shadow Root。

SAFE_FOR_TEMPLATES 有什么用途? 它用于将净化后的标记传入模板引擎的应用。该选项移除 {{...}}、${...} 和 <%...%> 模板表达式语法,防止攻击者将表达式偷渡过净化器,再由引擎执行。该公告表明 Shadow Root 内的清除并不完整。

模板内容已经修复,为什么遗漏了 Shadow Root? 该系列较早的修复 CVE-2026-65900 添加了对 <template>.content 的递归处理,测试也仅覆盖该容器。已附加 Shadow Root 是独立 DOM 边界,遍历后阶段的 NodeIterator 无法进入,因此需要单独处理,3.4.17 补充的正是这一部分。

此问题有 CVE 吗? 截至 2026 年 10 月 23 日,公告 GHSA-cg2g-xh64-v4q7 没有 CVE 标识符,通过其 GHSA ID 跟踪。

受影响包的部署有多广? dompurify 包在 2026 年 10 月 16-22 日这一周的 npm 下载量为 45,293,289。捆绑或随项目保存的副本不会显示在 npm ls 中,因此也要检查下游打包产物。

升级后可以继续对不可信节点使用 IN_PLACE 吗? 3.4.17 在该路径上清理 Shadow Root,并加固节点在遍历中移动或移除时的原地行为。文档中仍保留的平台级限制是:封闭 Shadow Root 对任何脚本侧净化器均不可见,因此应尽可能继续将不可信标记作为字符串传入。

审查应用暴露情况

通过 Ostorlab 开始 Web 应用安全评估。使用上述受影响版本和调用位置检查,确定您的 DOMPurify 部署是否会触及此漏洞。

参考资料

  • Cure53 公告 GHSA-cg2g-xh64-v4q7:https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7 (2026 年 10 月 23 日核查)
  • GitHub Advisory Database 条目:https://github.com/advisories/GHSA-cg2g-xh64-v4q7 (2026 年 10 月 23 日核查)
  • 修复提交 a77a3e7(3.4.17 版本):https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 (2026 年 10 月 23 日核查)
  • 拉取请求 #1672:https://github.com/cure53/DOMPurify/pull/1672 (2026 年 10 月 23 日核查)
  • DOMPurify 3.4.17 版本:https://github.com/cure53/DOMPurify/releases/tag/3.4.17 (2026 年 10 月 23 日核查)
  • 该系列先前公告 CVE-2026-65900(GHSA-gvmj-g25r-r7wr):https://github.com/advisories/GHSA-gvmj-g25r-r7wr (2026 年 10 月 23 日核查)
  • dompurify 的 npm 下载量(2026 年 10 月 16-22 日这一周共 45,293,289 次):https://api.npmjs.org/downloads/point/last-week/dompurify (2026 年 10 月 24 日核查)