GHSA-cg2g-xh64-v4q7 : contournement de SAFE_FOR_TEMPLATES dans les racines shadow de DOMPurify
DOMPurify 3.4.4–3.4.16 laisse des expressions ${...} fragmentées survivre à SAFE_FOR_TEMPLATES dans les racines shadow attachées (critique, CVSS v4.0 9.2). Mettez à jour vers 3.4.17.
En bref
- DOMPurify, un outil d’assainissement HTML téléchargé environ 45 millions de fois par semaine sur npm, ne supprime pas les expressions de template dans les racines shadow attachées lorsqu’elles sont réparties entre plusieurs nœuds texte et ne se reconstituent qu’après l’assainissement. La faille nécessite
SAFE_FOR_TEMPLATES: truecombiné à un mode de nœuds DOM (RETURN_DOM,RETURN_DOM_FRAGMENTouIN_PLACE). - Un attaquant pouvant fournir un nœud DOM portant une racine shadow répartit une expression telle que
${...}entre des nœuds texte séparés par un élément interdit. DOMPurify supprime le séparateur, les fragments deviennent adjacents et l’expression reconstituée subsiste dans la sortie, où un moteur de templates en aval l’évalue : cross-site scripting, ou injection de template côté serveur. - Les versions 3.4.4 à 3.4.16 sont affectées. Le bulletin classe la faille comme critique, CVSS v4.0 9.2. Une preuve de concept est publique ; aucune exploitation réelle n’est connue au 23 octobre 2026, et la faille ne figure pas dans le catalogue CISA KEV.
- Mettez à jour vers DOMPurify 3.4.17. Si cela n’est pas possible, transmettez le balisage non fiable sous forme de chaîne plutôt que de nœuds DOM préanalysés : pour une entrée de type chaîne, l’analyseur de DOMPurify ignore les racines shadow déclaratives.
Suis-je concerné ?
Vous êtes concerné si toutes ces conditions sont réunies :
- Votre dépendance se résout vers une version de DOMPurify comprise entre 3.4.4 et 3.4.16.
- Votre configuration active
SAFE_FOR_TEMPLATES(l’option qui supprime la syntaxe de template{{...}},${...}et<%...%>afin que la sortie puisse être transmise à un moteur de templates). - Vous appelez
sanitizedans un mode qui travaille sur des nœuds DOM plutôt que de renvoyer une chaîne :RETURN_DOM,RETURN_DOM_FRAGMENTouIN_PLACE(qui assainit le nœud d’entrée sur place). Cet article les appelle les modes de nœuds DOM. - L’entrée peut être un nœud DOM portant déjà une racine shadow attachée.
Les applications qui assainissent les entrées non fiables sous forme de chaînes ne suivent pas ce chemin : l’analyseur de DOMPurify ignore les racines shadow déclaratives, comme l’indique le README de la version 3.4.17.
| Branche | Versions affectées | Version corrigée |
|---|---|---|
| DOMPurify 3.4.x | 3.4.4 – 3.4.16 | 3.4.17 |
| DOMPurify ≤ 3.4.3 | Non affecté : le parcours des racines shadow et le nettoyage après parcours dont dépend ce bug ont tous deux été introduits dans 3.4.4 | - |
Vérifiez la version installée et les endroits où apparaissent les options à risque :
# 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"
Une subtilité élargit la surface d’entrée : la racine shadow n’a pas besoin d’être construite par programmation avec attachShadow. Dans les navigateurs prenant en charge le DOM shadow déclaratif, un balisage contenant <template shadowrootmode="open">, analysé via setHTMLUnsafe() ou Document.parseHTMLUnsafe(), produit la même racine shadow attachée. Une simple chaîne HTML atteint donc la faille si l’application l’analyse ainsi avant l’assainissement.
Comment corriger le problème ?
Mettez DOMPurify à jour :
npm install dompurify@3.4.17
# or: pnpm add dompurify@3.4.17 | yarn add dompurify@3.4.17
Si vous ne pouvez pas effectuer immédiatement la mise à jour, modifiez la façon d’appeler l’outil plutôt que sa version :
- Transmettez le balisage non fiable à
sanitizesous forme de chaîne. Pour une entrée de type chaîne, l’analyseur de DOMPurify ignore les racines shadow déclaratives : aucune racine shadow attachée ne peut donc échapper au parcours. Ce comportement est documenté dans le README de la version 3.4.17. - Si vous devez accepter des nœuds DOM, ne combinez pas
SAFE_FOR_TEMPLATESavecIN_PLACE,RETURN_DOMouRETURN_DOM_FRAGMENTavant d’avoir effectué la mise à jour. Un fragment peut lui aussi porter des racines shadow attachées ; il suit donc le même chemin que les deux autres modes.
La limite de cette solution de contournement est qu’elle dépend de la rigueur de tous les sites d’appel du code, tandis que le correctif supprime la faille dans l’outil d’assainissement pour tous les appelants.
Quelle est la cause racine ?
SAFE_FOR_TEMPLATES neutralise les expressions de template par deux mécanismes. Le bug vient du fait que le second ne voit pas les racines shadow.
Le premier mécanisme supprime les expressions nœud texte par nœud texte pendant le parcours principal : lorsque _sanitizeElements visite chaque nœud (src/purify.ts:2246 dans les versions affectées), _stripTemplateExpressions supprime tout ce qui correspond aux expressions régulières prévues. Une expression fragmentée déjoue cette vérification, car chaque fragment pris isolément ne correspond pas aux motifs de délimiteurs : MUSTACHE_EXPR attend {{ ou }}, TMPLIT_EXPR attend ${, et ERB_EXPR attend <% ou %>. Un simple $ n’est que du texte.
Le second mécanisme constitue précisément le filet de sécurité pour ce cas. Après la fin du parcours, _scrubTemplateExpressions (src/purify.ts:1717) appelle normalize() sur l’arbre, ce qui fusionne les nœuds texte adjacents, puis supprime à nouveau les expressions qui ne sont apparues qu’après cette fusion. C’est la seule passe capable de détecter une expression fragmentée. Le bulletin explique pourquoi elle n’atteint pas le contenu shadow :
_scrubTemplateExpressions (src/purify.ts:1717-1751):
- node.normalize() at line 1718 does not merge text nodes inside shadow
roots (DOM spec).
- The NodeIterator at lines 1723-1731 does not descend into shadow trees
(DOM spec).
- The explicit recursion at lines 1743-1750 enters <template>.content
only, via querySelectorAll('template'); there is no equivalent recursion
into attached shadow roots.
Les racines shadow sont bien assainies, mais uniquement par la passe antérieure _sanitizeShadowDOM (src/purify.ts:2710), qui applique la suppression par nœud et retire les éléments interdits, sans effectuer la normalisation et le nouveau nettoyage après parcours. Le texte des racines shadow est donc assaini nœud par nœud, sans être réexaminé comme une unité fusionnée.
L’invariant : les nœuds texte adjacents sont fusionnés et de nouveau nettoyés après le retrait des éléments. Il est respecté dans l’arbre principal et dans le contenu de <template>. Rien ne l’impose à l’intérieur des racines shadow attachées : une expression fragmentée dont les morceaux ne deviennent adjacents qu’après l’assainissement y survit donc.
Cette faille concerne une frontière distincte et non une régression du correctif précédent : le bulletin antérieur de cette série, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr), étendait la passe après parcours pour descendre récursivement dans <template>.content, et ses tests de non-régression ne couvrent que le contenu des templates. Les racines shadow avaient été exclues de cette extension.
Comment la faille est-elle exploitée ?
L’attaquant construit un élément hôte dont la racine shadow contient une expression répartie entre des nœuds texte séparés par un élément interdit. L’application l’assainit ensuite sur place avec 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)}"
Le chemin d’attaque, étape par étape :
- L’application appelle
DOMPurify.sanitize(node, cfg)avecSAFE_FOR_TEMPLATES: trueet un mode de nœuds DOM (IN_PLACE,RETURN_DOMouRETURN_DOM_FRAGMENT), en transmettant un nœud DOM qui porte déjà une racine shadow attachée. - L’attaquant contrôle les nœuds texte de la racine shadow et l’élément séparateur interdit placé entre eux. Chaque fragment,
$et{alert(document.domain)}, est individuellement un texte inoffensif. _sanitizeAttachedShadowRootstransmet la racine shadow à_sanitizeShadowDOM, qui supprime les expressions nœud texte par nœud texte et retire le séparateur interdit. Les fragments deviennent adjacents.- Le filet de sécurité après parcours, seule passe qui détecterait l’expression fusionnée, n’entre pas dans les racines shadow.
- Les fragments adjacents sont sérialisés en une seule chaîne par
shadowRoot.textContentoushadowRoot.innerHTML, ce qui reconstitue l’expression complète. - Un évaluateur de templates en aval lit le contenu de la racine shadow et évalue l’expression de l’attaquant : exécution de script dans le contexte du moteur, ou injection de template si le point d’évaluation est un moteur côté serveur.
L’exploit nécessite un point d’évaluation qui interprète ce que DOMPurify a renvoyé. Le bulletin démontre la primitive d’injection et cite des évaluateurs courants : new Function('return `' + content + '`')() ou tout moteur utilisant ${...} et {{...}}. Les conséquences sur la confidentialité ou l’intégrité dépendent de ce contexte d’évaluation en aval.
Comment l’expression fragmentée atteint-elle le moteur de templates ?
L’expression fragmentée survit parce que la passe de fusion et de nouveau nettoyage ne s’applique qu’à l’arbre principal et au contenu des templates, sans cas prévu pour les racines shadow attachées. Voici le chemin de l’entrée de l’attaquant à l’évaluation :
avec une racine shadow attachée"] --> B["L’application assainit en mode de nœuds DOM
avec SAFE_FOR_TEMPLATES"] B --> C["Le nettoyage par nœud manque
les fragments de l’expression"] C --> D["Le séparateur interdit est retiré,
les fragments deviennent adjacents"] D --> E["Le nettoyage après parcours ne couvre
que l’arbre principal et les templates"] E --> F["shadowRoot.textContent sérialise
l’expression complète"] F --> G["Le moteur de templates en aval
évalue l’expression de l’attaquant"]
La vérification manquante est la passe de fusion et de nouveau nettoyage : elle n’entre jamais dans les racines shadow, donc rien ne supprime les fragments fusionnés.
Que peut faire un attaquant ?
La capacité démontrée est l’injection d’une expression de template complète, choisie par l’attaquant, dans le contenu d’une racine shadow après assainissement. Côté client, le résultat est un cross-site scripting dans l’origine de l’application. Lorsque le contenu assaini alimente un template côté serveur, le résultat est une injection de template qui, dans les moteurs dotés d’expressions riches, conduit fréquemment à une exécution de code à distance sur le serveur.
Selon le bulletin, l’attaquant a besoin des conditions suivantes :
- L’application active simultanément
SAFE_FOR_TEMPLATESet un mode de nœuds DOM. - L’application transmet un nœud DOM portant déjà une racine shadow attachée. Le DOM shadow déclaratif combiné à
setHTMLUnsafe()ouDocument.parseHTMLUnsafe()peut le produire à partir d’une simple chaîne HTML dans les moteurs compatibles. - L’application transmet le contenu assaini de la racine shadow à un évaluateur de templates.
- Aucune authentification, aucun privilège et aucune interaction utilisateur ne sont requis au niveau de l’outil d’assainissement lui-même. Le vecteur CVSS v4.0 est
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, avec une complexité d’attaque élevée et des conditions d’attaque présentes, précisément parce que les trois conditions applicatives doivent se cumuler.
Si ces conditions sont réunies, l’expression de l’attaquant s’exécute avec les permissions du moteur qui l’évalue. L’impact dépend donc du point d’évaluation, du vol de données de session dans le navigateur à l’exécution de commandes derrière un template côté serveur permissif.
Pourquoi le correctif fonctionne-t-il ?
DOMPurify 3.4.17 (pull request #1672, commit a77a3e7) étend l’invariant après parcours aux racines shadow. Lorsque _sanitizeShadowDOM termine le parcours d’une racine shadow, il exécute désormais la même normalisation et le même nouveau nettoyage que pour l’arbre principal :
// 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);
};
L’ancienne fonction récursive _scrubTemplateExpressions est divisée en deux. _scrubOwnCharacterData constitue le cœur non récursif : elle appelle normalize() et parcourt les nœuds texte, commentaires, CDATA et instructions de traitement propres au nœud pour supprimer les expressions. Une fonction d’encapsulation poursuit la récursion dans <template>.content. Comme _sanitizeShadowDOM est déjà le point d’entrée récursif pour les racines shadow imbriquées et le contenu des templates, exécuter le cœur pour chaque fragment nettoie l’ensemble une seule fois, au lieu de réexaminer les fragments imbriqués pour chacun de leurs ancêtres.
La fusion et le nouveau nettoyage s’exécutant désormais sur chaque racine shadow, l’astuce de l’expression fragmentée ne fonctionne plus : après le retrait du séparateur interdit et la fusion des fragments adjacents par normalize(), la nouvelle passe voit la séquence ${...} complète et la supprime, exactement comme dans l’arbre principal.
Le correctif est-il complet ? Pour les racines shadow ouvertes sur les chemins d’appel affectés, oui. La même version durcit aussi les mécanismes environnants, notamment la neutralisation sur place plus stricte des nœuds qui quittent l’arbre pendant l’assainissement.
Une limite subsiste et est désormais documentée dans le README plutôt que corrigée : une racine shadow fermée n’est pas lisible depuis un script, si bien que DOMPurify ne peut ni voir ni assainir son contenu. Il s’agit d’une limite de la plateforme, pas d’une faille de l’outil d’assainissement. La recommandation documentée reste donc valable : transmettre le balisage non fiable sous forme de chaîne.
Comment vérifier si j’ai été compromis ?
Aucun indicateur de compromission n’a été publié pour ce bulletin. En l’absence de recommandations publiques, examinez ces signaux généraux :
- Les endroits où la sortie de nœuds DOM assainis alimente un moteur de templates, un appel de type
evalounew Function. Tout contenu ressemblant à une expression dans la sortie assainie à ces sites d’appel indique que le contournement s’est produit. - Le contenu stocké contenant une syntaxe de template (
${,{{,<%) issue d’un balisage fourni par l’utilisateur, dans des racines shadow ou ailleurs, puisque c’est ce contenu que le contournement préserve. - Les journaux d’erreurs côté client ou les rapports CSP citant un domaine que l’application ne contacte pas habituellement. Cela indique une requête sortante ou une évaluation d’expression déclenchée après l’assainissement.
Si vous trouvez des preuves d’évaluation, considérez la session ou le contexte serveur environnant comme exposé, selon les ressources que le moteur d’évaluation pouvait atteindre.
Chronologie
- 12 octobre 2026 : Cure53 publie le bulletin GHSA-cg2g-xh64-v4q7 et DOMPurify 3.4.17 avec la pull request #1672. Le chercheur zx (GitHub : @manus-pi) est crédité pour le signalement ; @manus-use est également crédité dans le bulletin GitHub.
- 22 octobre 2026 : le bulletin est publié et examiné dans la GitHub Advisory Database.
- 23 octobre 2026 : publication de cet article. Aucune exploitation réelle n’est connue ; la faille ne figure pas dans le catalogue CISA KEV.
FAQ
La configuration par défaut de DOMPurify est-elle affectée ?
Non. La faille nécessite SAFE_FOR_TEMPLATES avec un mode de nœuds DOM et une entrée de type nœud DOM portant une racine shadow attachée. L’assainissement de chaînes par défaut n’emprunte pas le chemin affecté, car l’analyseur de DOMPurify ignore les racines shadow déclaratives pour les entrées de type chaîne.
À quoi sert SAFE_FOR_TEMPLATES ?
Cette option est destinée aux applications qui transmettent du balisage assaini à un moteur de templates. Elle supprime les syntaxes d’expression de template {{...}}, ${...} et <%...%>, afin qu’un attaquant ne puisse pas faire passer une expression à travers l’assainissement pour que le moteur l’exécute ensuite. Ce bulletin montre que cette suppression était incomplète à l’intérieur des racines shadow.
Pourquoi les racines shadow ont-elles été oubliées alors que le contenu des templates avait déjà été corrigé ?
Le correctif précédent de cette série, CVE-2026-65900, ajoutait une récursion dans <template>.content, et ses tests ne couvraient que ce conteneur. Les racines shadow attachées constituent une frontière DOM distincte que le NodeIterator de la passe après parcours ne peut pas franchir. Elles nécessitaient donc un traitement spécifique, ajouté dans 3.4.17.
Existe-t-il une CVE pour ce problème ? Au 23 octobre 2026, le bulletin GHSA-cg2g-xh64-v4q7 n’a pas d’identifiant CVE ; il est suivi sous son identifiant GHSA.
Quelle est l’ampleur du déploiement du package affecté ?
Le package dompurify a enregistré 45 293 289 téléchargements sur npm pendant la semaine du 16-22 octobre 2026. Une copie intégrée à un bundle ou embarquée directement n’apparaît pas dans npm ls : vérifiez donc aussi les bundles en aval.
Puis-je continuer à utiliser IN_PLACE avec des nœuds non fiables après la mise à jour ? La version 3.4.17 nettoie les racines shadow sur ce chemin et durcit le comportement sur place lorsque des nœuds sont déplacés ou retirés pendant le parcours. La réserve documentée qui subsiste relève de la plateforme : les racines shadow fermées sont invisibles pour tout outil d’assainissement exécuté côté script. Continuez donc à transmettre le balisage non fiable sous forme de chaînes lorsque c’est possible.
Examinez l’exposition de votre application
Lancez une évaluation de sécurité de votre application web avec Ostorlab. Utilisez les vérifications de versions affectées et de sites d’appel ci-dessus pour déterminer si votre déploiement DOMPurify atteint cette vulnérabilité.
Références
- Bulletin Cure53 GHSA-cg2g-xh64-v4q7 : https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7 (consulté le 23 octobre 2026)
- Entrée de la GitHub Advisory Database : https://github.com/advisories/GHSA-cg2g-xh64-v4q7 (consultée le 23 octobre 2026)
- Commit du correctif a77a3e7 (version 3.4.17) : https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 (consulté le 23 octobre 2026)
- Pull request #1672 : https://github.com/cure53/DOMPurify/pull/1672 (consultée le 23 octobre 2026)
- Version DOMPurify 3.4.17 : https://github.com/cure53/DOMPurify/releases/tag/3.4.17 (consultée le 23 octobre 2026)
- Bulletin précédent de la série, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) : https://github.com/advisories/GHSA-gvmj-g25r-r7wr (consulté le 23 octobre 2026)
- Nombre de téléchargements npm de
dompurify(45 293 289 téléchargements, semaine du 16-22 octobre 2026) : https://api.npmjs.org/downloads/point/last-week/dompurify (consulté le 24 octobre 2026)