Cómo limpiar HTML no confiable

Funciones web: API de Sanitizer

Mostrar de forma segura el HTML generado por el usuario es un desafío de seguridad común. La API de Sanitizer nativa proporciona una forma integrada y optimizada para el navegador de quitar el contenido peligroso (como las etiquetas <script> o los atributos on*) antes de que llegue al DOM.

La limpieza del cliente protege la página que renderiza el contenido. Si el mismo contenido generado por usuarios también se renderiza en otro lugar (otros clientes, páginas renderizadas por el servidor o correos electrónicos), realiza una limpieza en el servidor y una defensa en profundidad, y asegúrate de que las reglas de limpieza del servidor y del cliente no entren en conflicto.

1. Inserta HTML de forma segura con setHTML()

La forma principal de usar la API de Sanitizer es a través del método setHTML() en cualquier Element. Este método controla el análisis, la limpieza y la inserción en un solo paso, lo que garantiza que el contenido se limpie específicamente para el contexto en el que se insertará.

const untrustedHTML = `
  <p>Hello!</p>
  <script>alert('XSS')</script>
  <img src="x" onerror="alert('XSS')">
  <div onclick="doBadThing()">Click me</div>
`;

const container = document.getElementById('output');

// Sanitize and insert in one call
// The default sanitizer removes scripts and event handlers, plus other
// elements and attributes outside its built-in allowlist (such as style)
container.setHTML(untrustedHTML);

2. Usa configuraciones de limpiador personalizadas

Puedes personalizar las reglas de limpieza creando una instancia de Sanitizer. Esto te permite definir exactamente qué elementos y atributos se permiten para tu caso de uso específico.

Cuando se usan Element.setHTML() y Document.parseHTML(), la configuración predeterminada de Sanitizer quita todos los elementos y atributos no seguros para XSS. No puedes crear un Sanitizer más permisivo que permita esos elementos, pero puedes restringir aún más lo que se permite.

// Define a restrictive configuration that only allows the specified elements and attributes.
const config = {
  elements: ["p", "b", "i", "strong", "em"],
  attributes: ["class"],
  replaceWithChildrenElements: ['div']
};

const mySanitizer = new Sanitizer(config);

// Apply custom sanitization
container.setHTML(untrustedHTML, { sanitizer: mySanitizer });

Los elementos que no están en elements se quitan junto con su contenido. Enumera los elementos de wrapper cuyo texto quieras conservar (como div más arriba) en replaceWithChildrenElements, que quita el elemento, pero conserva sus elementos secundarios.

Si necesitas un Sanitizer más permisivo que permita algunos elementos o atributos no seguros para XSS, puedes usar un Sanitizer personalizado con Element.setHTMLUnsafe() y Document.parseHTMLUnsafe().

3. Analiza la entrada del usuario con Document.parseHTML()

Si necesitas analizar HTML no confiable sin insertarlo de inmediato en el DOM activo, usa Document.parseHTML(). Este método devuelve un Document que se limpió según las reglas predeterminadas o personalizadas, de modo que puedas inspeccionar o transformar los nodos limpios (por ejemplo, para compilar una vista previa o extraer texto) antes de insertarlos.

Mueve los nodos resultantes a la página en lugar de serializarlos en una cadena y volver a analizarlos con innerHTML, lo que puede volver a introducir XSS de mutación (mXSS).

const rawHTML = '<p>Hello <script>console.log("bad")</script></p>';

const doc = Document.parseHTML(rawHTML);
// doc.body now contains the sanitized nodes: <p>Hello </p>
preview.replaceChildren(...doc.body.childNodes);

Resguardos y compatibilidad de navegadores con la API de Sanitizer

Si la API de Sanitizer nativa no está disponible en tus navegadores de destino, carga una biblioteca como DOMPurify solo cuando falte la compatibilidad nativa para evitar enviarla a navegadores que no la necesitan. Usa verificaciones de in para la detección de funciones, de modo que la verificación en sí funcione en navegadores más antiguos.

let domPurifyPromise;

function loadDOMPurify() {
  // Code-split with your bundler so DOMPurify is only downloaded when needed
  domPurifyPromise ??= import('dompurify').then((mod) => mod.default ?? mod);
  return domPurifyPromise;
}

export async function safeSetHTML(el, html) {
  const elements = ['p', 'b', 'i', 'strong', 'em'];
  const attributes = ['class'];

  if ('setHTML' in Element.prototype) {
    // setHTML() accepts a plain configuration object as the sanitizer
    el.setHTML(html, { sanitizer: { elements, attributes } });
  } else {
    const DOMPurify = await loadDOMPurify();
    el.innerHTML = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: [...elements, '#text'],
      ALLOWED_ATTR: attributes,
      // Match the native Sanitizer: strip aria-*/data-* and disallowed element content
      ALLOW_ARIA_ATTR: false,
      ALLOW_DATA_ATTR: false,
      KEEP_CONTENT: false
    });
  }
}

De forma predeterminada, DOMPurify conserva los atributos aria-* y data-* (incluso cuando se especifica ALLOWED_ATTR), así como el contenido de texto de los elementos que quita, mientras que el Sanitizer nativo quita los atributos aria-* y data-*, y quita los elementos no permitidos junto con su contenido. Configurar ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false, KEEP_CONTENT: false y permitir #text hace que DOMPurify coincida con el comportamiento nativo. DOMPurify no tiene una opción integrada equivalente a replaceWithChildrenElements, por lo que debes evitar esa opción en las configuraciones que necesitan un resguardo coincidente.

Si la página aplica Trusted Types (require-trusted-types-for 'script'), la asignación de una cadena a innerHTML arroja un error. setHTML() no necesita una política de Trusted Types, pero el resguardo sí: pasa RETURN_TRUSTED_TYPE: true a DOMPurify.sanitize() y permite su nombre de política dompurify en tu directiva de CSP trusted-types.