Neutron, nuestro motor de IA, obtuvo un 97.1% en el benchmark CyberGym de UC Berkeley. Más información

Análisis a fondo

Análisis a fondo

GHSA-cg2g-xh64-v4q7: omisión de SAFE_FOR_TEMPLATES de DOMPurify en raíces shadow

DOMPurify 3.4.4–3.4.16 permite que expresiones ${...} divididas sobrevivan a SAFE_FOR_TEMPLATES dentro de raíces shadow adjuntas (crítico, CVSS v4.0 9.2). Actualice a 3.4.17.

Resumen

  • DOMPurify, un saneador HTML con unos 45 millones de descargas semanales en npm, no elimina expresiones de plantilla dentro de raíces shadow adjuntas cuando están divididas entre nodos de texto y solo se recomponen después del saneamiento. La brecha requiere SAFE_FOR_TEMPLATES: true junto con un modo de nodos DOM (RETURN_DOM, RETURN_DOM_FRAGMENT o IN_PLACE).
  • Un atacante que pueda proporcionar un nodo DOM con una raíz shadow divide una expresión como ${...} entre nodos de texto separados por un elemento no permitido. DOMPurify elimina el separador, los fragmentos quedan adyacentes y la expresión reconstruida sobrevive en la salida, donde un motor de plantillas posterior la evalúa: cross-site scripting o inyección de plantillas en el servidor.
  • Están afectadas las versiones 3.4.4 a 3.4.16. El aviso lo califica como crítico, CVSS v4.0 9.2. Hay una prueba de concepto pública; no se conoce explotación en ataques reales a fecha del 23 de octubre de 2026 y el problema no figura en el catálogo CISA KEV.
  • Actualice a DOMPurify 3.4.17. Si no puede hacerlo, pase el marcado no confiable como una cadena en lugar de nodos DOM ya analizados: con entrada de tipo cadena, el analizador propio de DOMPurify ignora las raíces shadow declarativas.

¿Estoy afectado?

Está afectado si se cumplen todas estas condiciones:

  • Su dependencia se resuelve a DOMPurify 3.4.4 a 3.4.16.
  • Su configuración activa SAFE_FOR_TEMPLATES (la opción que elimina la sintaxis de plantillas {{...}}, ${...} y <%...%> para poder entregar la salida a un motor de plantillas).
  • Llama a sanitize en un modo que trabaja con nodos DOM en lugar de devolver una cadena: RETURN_DOM, RETURN_DOM_FRAGMENT o IN_PLACE (que sanea el nodo de entrada en su ubicación). Este artículo los denomina modos de nodos DOM.
  • La entrada puede ser un nodo DOM que ya contenga una raíz shadow adjunta.

Las aplicaciones que sanean entradas no confiables como cadenas no siguen esta ruta: el analizador propio de DOMPurify ignora las raíces shadow declarativas, como documenta el README de 3.4.17.

Rama Versiones afectadas Versión corregida
DOMPurify 3.4.x 3.4.4 – 3.4.16 3.4.17
DOMPurify ≤ 3.4.3 No afectado: tanto el recorrido de raíces shadow como la limpieza posterior de los que depende este error llegaron en 3.4.4 -

Compruebe lo que está instalado y dónde aparecen las opciones de riesgo:

# 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"

Un detalle amplía la superficie de entrada: la raíz shadow no tiene que construirse programáticamente con attachShadow. En navegadores que admiten shadow DOM declarativo, el marcado con <template shadowrootmode="open"> analizado mediante setHTMLUnsafe() o Document.parseHTMLUnsafe() produce la misma raíz shadow adjunta. Por tanto, una cadena HTML normal llega a la brecha si la aplicación la analiza así antes de sanearla.

¿Cómo lo corrijo?

Actualice DOMPurify:

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

Si no puede actualizar inmediatamente, cambie el patrón de llamada en lugar de la versión:

  • Pase el marcado no confiable a sanitize como una cadena. El analizador de DOMPurify ignora las raíces shadow declarativas en entradas de tipo cadena, por lo que no existe ninguna raíz shadow adjunta que el recorrido pueda omitir. Es el comportamiento documentado en el README de 3.4.17.
  • Si debe aceptar nodos DOM, no combine SAFE_FOR_TEMPLATES con IN_PLACE, RETURN_DOM o RETURN_DOM_FRAGMENT hasta actualizar. Un fragmento también puede contener raíces shadow adjuntas, por lo que sigue la misma ruta que los otros dos modos.

El límite de esta medida es que depende de la disciplina en todos los puntos de llamada de la base de código, mientras que la corrección elimina la brecha dentro del saneador para todos sus usuarios.

¿Cuál es la causa raíz?

SAFE_FOR_TEMPLATES neutraliza las expresiones de plantilla mediante dos mecanismos, y el error consiste en que el segundo no puede ver las raíces shadow.

El primer mecanismo elimina expresiones por nodo de texto durante el recorrido principal: a medida que _sanitizeElements visita cada nodo (src/purify.ts:2246 en las versiones afectadas), _stripTemplateExpressions elimina todo lo que coincide con las expresiones regulares. Una expresión dividida lo elude, porque cada fragmento por separado no cumple los patrones de delimitadores: MUSTACHE_EXPR necesita {{ o }}, TMPLIT_EXPR necesita ${ y ERB_EXPR necesita <% o %>. Un $ aislado es solo texto.

El segundo mecanismo es la red de seguridad precisamente para ese caso. Tras terminar el recorrido, _scrubTemplateExpressions (src/purify.ts:1717) llama a normalize() sobre el árbol, que fusiona los nodos de texto adyacentes, y luego vuelve a eliminar las expresiones formadas tras la fusión. Esta es la única pasada capaz de detectar una expresión dividida. El aviso documenta por qué no alcanza el contenido 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.

Las raíces shadow sí se sanean, pero solo mediante la pasada anterior _sanitizeShadowDOM (src/purify.ts:2710), que aplica la eliminación por nodo y retira los elementos no permitidos, sin realizar la normalización y nueva limpieza posteriores al recorrido. Así, el texto de las raíces shadow se sanea por nodo y no vuelve a examinarse como una unidad fusionada.

La invariante: los nodos de texto adyacentes se fusionan y vuelven a limpiarse después de eliminar elementos. Se cumple para el árbol principal y el contenido de <template>. Nada la impone dentro de las raíces shadow adjuntas, de modo que sobrevive allí una expresión dividida cuyos fragmentos solo quedan adyacentes después del saneamiento.

La brecha corresponde a un límite distinto, no a una regresión de la corrección anterior: el aviso previo de esta serie, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr), amplió la pasada posterior al recorrido para entrar recursivamente en <template>.content, y sus pruebas de regresión solo cubren contenido de plantillas. Las raíces shadow quedaron fuera de esa ampliación.

¿Cómo se explota?

El atacante construye un elemento anfitrión cuya raíz shadow contiene una expresión dividida entre nodos de texto separados por un elemento no permitido. Después, la aplicación lo sanea en su ubicación con 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)}"

La ruta de ataque, paso a paso:

  1. La aplicación llama a DOMPurify.sanitize(node, cfg) con SAFE_FOR_TEMPLATES: true y un modo de nodos DOM (IN_PLACE, RETURN_DOM o RETURN_DOM_FRAGMENT), pasando un nodo DOM que ya contiene una raíz shadow adjunta.
  2. El atacante controla los nodos de texto de la raíz shadow y el elemento separador no permitido entre ellos. Cada fragmento, $ y {alert(document.domain)}, es por sí solo texto inocuo.
  3. _sanitizeAttachedShadowRoots entrega la raíz shadow a _sanitizeShadowDOM, que elimina expresiones por nodo de texto y retira el separador no permitido. Los fragmentos quedan adyacentes.
  4. La red de seguridad posterior al recorrido, la única pasada que detectaría la expresión fusionada, no entra en las raíces shadow.
  5. Los fragmentos adyacentes se serializan como una sola cadena en shadowRoot.textContent o shadowRoot.innerHTML, reconstruyendo la expresión completa.
  6. Un evaluador de plantillas posterior lee el contenido de la raíz shadow y evalúa la expresión del atacante: ejecución de script en el contexto del motor o inyección de plantillas si el destino es un motor del lado del servidor.

El exploit necesita un destino que evalúe lo que devolvió DOMPurify. El aviso demuestra la primitiva de inyección y cita evaluadores habituales: new Function('return `' + content + '`')() o cualquier motor ${...} y {{...}}. Las consecuencias para la confidencialidad o la integridad dependen de ese contexto de evaluación posterior.

¿Cómo llega la expresión dividida al motor de plantillas?

La expresión dividida sobrevive porque la pasada de fusión y nueva limpieza solo se ejecuta sobre el árbol principal y el contenido de plantillas, sin contemplar las raíces shadow adjuntas. El flujo desde la entrada del atacante hasta la evaluación:

flowchart LR A["El atacante proporciona un nodo DOM
con raíz shadow adjunta"] --> B["La aplicación sanea con un modo DOM
más SAFE_FOR_TEMPLATES"] B --> C["La limpieza por nodo omite
los fragmentos de la expresión"] C --> D["Se elimina el separador no permitido,
los fragmentos quedan adyacentes"] D --> E["La limpieza posterior solo recorre
árbol principal y plantillas"] E --> F["shadowRoot.textContent serializa
la expresión completa"] F --> G["El motor de plantillas posterior
evalúa la expresión del atacante"]

La comprobación ausente es la pasada de fusión y nueva limpieza: nunca entra en las raíces shadow, de modo que nada vuelve a eliminar los fragmentos fusionados.

¿Qué puede hacer un atacante?

La capacidad demostrada es inyectar una expresión de plantilla completa elegida por el atacante en el contenido de una raíz shadow después del saneamiento. En un contexto del cliente, el resultado es cross-site scripting en el origen de la aplicación. Si el contenido saneado alimenta una plantilla del servidor, el resultado es inyección de plantillas, que en motores con expresiones potentes suele escalar a ejecución remota de código en el servidor.

Las condiciones que necesita el atacante, según el aviso:

  • La aplicación activa a la vez SAFE_FOR_TEMPLATES y un modo de nodos DOM.
  • La aplicación pasa un nodo DOM que ya tiene una raíz shadow adjunta, que el shadow DOM declarativo junto con setHTMLUnsafe() o Document.parseHTMLUnsafe() puede producir a partir de una cadena HTML normal en los motores compatibles.
  • La aplicación entrega el contenido saneado de la raíz shadow a un evaluador de plantillas.
  • No se requieren autenticación, privilegios ni interacción del usuario en el propio saneador. El vector CVSS v4.0 es 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, con complejidad de ataque alta y requisitos de ataque presentes precisamente porque deben coincidir las tres condiciones de la aplicación.

Si se cumplen esas condiciones, la expresión del atacante se ejecuta con los permisos del motor evaluador, por lo que el impacto depende del destino: desde el robo de datos de sesión en el navegador hasta ejecución de comandos detrás de una plantilla de servidor permisiva.

¿Por qué funciona la corrección?

DOMPurify 3.4.17 (pull request #1672, commit a77a3e7) extiende la invariante posterior al recorrido a las raíces shadow. Después de que _sanitizeShadowDOM termina de recorrer una raíz shadow, ahora ejecuta la misma normalización y nueva limpieza que recibe el árbol 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);
   };

La antigua _scrubTemplateExpressions recursiva se divide en dos. _scrubOwnCharacterData es el núcleo no recursivo: llama a normalize() y recorre los nodos propios de texto, comentario, CDATA e instrucciones de procesamiento para eliminar expresiones. Un envoltorio mantiene la recursión en <template>.content. Como _sanitizeShadowDOM ya es el punto de entrada recursivo para raíces shadow anidadas y contenido de plantillas, ejecutar el núcleo por fragmento limpia todo una vez en lugar de volver a recorrer los fragmentos anidados una vez por antecesor.

Con la fusión y nueva limpieza ejecutándose en cada raíz shadow, el truco de la expresión dividida deja de funcionar: después de retirar el separador no permitido y de que normalize() fusione los fragmentos adyacentes, la nueva pasada ve la secuencia ${...} completa y la elimina, exactamente como en el árbol principal.

¿Es completa la corrección? Para raíces shadow abiertas en las rutas de llamada afectadas, sí. La misma versión endurece los mecanismos circundantes, incluida una neutralización más estricta en la ubicación original de los nodos que abandonan el árbol durante el saneamiento.

Permanece un límite, ahora documentado en el README en lugar de corregido: una raíz shadow cerrada no puede leerse desde un script, por lo que DOMPurify no puede ver ni sanear su contenido. Es una limitación de la plataforma, no una brecha del saneador, y la recomendación documentada se mantiene: pase el marcado no confiable como una cadena.

¿Cómo compruebo si he sufrido una intrusión?

No se han publicado indicadores de compromiso para este aviso. A falta de orientación pública, revise estas señales generales:

  • Lugares donde la salida saneada de nodos DOM alimenta un motor de plantillas, una llamada del tipo eval o new Function. Cualquier contenido con forma de expresión en la salida saneada en esos puntos indica que se produjo la omisión.
  • Contenido almacenado con sintaxis de plantillas (${, {{, <%) procedente de marcado proporcionado por usuarios, en raíces shadow o en cualquier otro lugar, ya que ese es el contenido que conserva la omisión.
  • Registros de errores del cliente o informes CSP que mencionen un dominio al que la aplicación no se conecta normalmente. Eso apunta a una solicitud saliente o evaluación de expresión desencadenada tras el saneamiento.

Si encuentra evidencia de evaluación, considere expuestos la sesión o el contexto de servidor circundantes en función de lo que pudiera alcanzar el motor evaluador.

Cronología

  • 12 de octubre de 2026: Cure53 publica el aviso GHSA-cg2g-xh64-v4q7 y DOMPurify 3.4.17 con la pull request #1672. Se reconoce el informe de zx (GitHub: @manus-pi); el aviso de GitHub también reconoce a @manus-use.
  • 22 de octubre de 2026: el aviso se publica y revisa en GitHub Advisory Database.
  • 23 de octubre de 2026: este artículo. No se conoce explotación en ataques reales y el problema no figura en el catálogo CISA KEV.

Preguntas frecuentes

¿Está afectada la configuración predeterminada de DOMPurify? No. La brecha necesita SAFE_FOR_TEMPLATES junto con un modo de nodos DOM y una entrada de nodo DOM que contenga una raíz shadow adjunta. El saneamiento predeterminado de cadenas no alcanza la ruta afectada porque el analizador de DOMPurify ignora las raíces shadow declarativas en entradas de tipo cadena.

¿Para qué sirve SAFE_FOR_TEMPLATES? Es la opción para aplicaciones que entregan marcado saneado a un motor de plantillas. Elimina la sintaxis de expresiones de plantilla, {{...}}, ${...} y <%...%>, para que un atacante no pueda introducir una expresión a través del saneador que el motor ejecute después. Este aviso demuestra que esa eliminación era incompleta dentro de las raíces shadow.

¿Por qué se omitieron las raíces shadow si el contenido de plantillas ya estaba corregido? La corrección anterior de esta serie, CVE-2026-65900, añadió recursión en <template>.content y sus pruebas solo cubrían ese contenedor. Las raíces shadow adjuntas son un límite DOM independiente en el que el NodeIterator de la pasada posterior no puede entrar, por lo que necesitaban un caso propio, añadido en 3.4.17.

¿Existe un CVE para este problema? A fecha del 23 de octubre de 2026, el aviso GHSA-cg2g-xh64-v4q7 no tiene identificador CVE; se registra mediante su identificador GHSA.

¿Cuál es el alcance de uso del paquete afectado? El paquete dompurify registró 45.293.289 descargas en npm durante la semana del 16-22 de octubre de 2026. Una copia incorporada en un paquete o vendorizada no aparece en npm ls, por lo que también debe comprobar los paquetes que la incluyen.

¿Puedo seguir utilizando IN_PLACE con nodos no confiables tras actualizar? 3.4.17 limpia las raíces shadow en esa ruta y endurece el comportamiento en la ubicación original cuando los nodos se mueven o eliminan durante el recorrido. La salvedad documentada que permanece corresponde a la plataforma: las raíces shadow cerradas son invisibles para cualquier saneador del lado del script. Por tanto, siga pasando el marcado no confiable como cadenas cuando pueda.

Revise la exposición de su aplicación

Inicie una evaluación de seguridad de aplicaciones web con Ostorlab. Utilice las comprobaciones anteriores de versión afectada y puntos de llamada para determinar si su despliegue de DOMPurify alcanza esta vulnerabilidad.

Referencias

  • Aviso de Cure53 GHSA-cg2g-xh64-v4q7: https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7 (consultado el 23 de octubre de 2026)
  • Entrada de GitHub Advisory Database: https://github.com/advisories/GHSA-cg2g-xh64-v4q7 (consultado el 23 de octubre de 2026)
  • Commit de corrección a77a3e7 (versión 3.4.17): https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 (consultado el 23 de octubre de 2026)
  • Pull request #1672: https://github.com/cure53/DOMPurify/pull/1672 (consultado el 23 de octubre de 2026)
  • Versión DOMPurify 3.4.17: https://github.com/cure53/DOMPurify/releases/tag/3.4.17 (consultado el 23 de octubre de 2026)
  • Aviso anterior de la serie, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr): https://github.com/advisories/GHSA-gvmj-g25r-r7wr (consultado el 23 de octubre de 2026)
  • Recuento de descargas npm de dompurify (45.293.289 descargas, semana del 16-22 de octubre de 2026): https://api.npmjs.org/downloads/point/last-week/dompurify (consultado el 24 de octubre de 2026)