Manipulación segura de DOM con la API de Sanitizer

El objetivo de la nueva API de Sanitizer es compilar un procesador sólido para que las cadenas arbitrarias se inserten de forma segura en una página.

Jack J
Jack J

Las aplicaciones siempre trabajan con cadenas no confiables, pero renderizar ese contenido de forma segura como parte de un documento HTML puede ser complicado. Si no se tiene la suficiente precaución, es fácil crear accidentalmente oportunidades para secuencias de comandos entre sitios (XSS) que los atacantes maliciosos pueden aprovechar.

Para mitigar ese riesgo, el objetivo de la nueva propuesta de la API de Sanitizer es compilar un procesador sólido para que las cadenas arbitrarias se inserten de forma segura en una página. En este artículo, se presenta la API y se explica su uso.

// Expanded Safely !!
$div.setHTML(`<em>hello world</em><img src="" onerror=alert(0)>`, new Sanitizer())

Cómo escapar de la entrada del usuario

Cuando se insertan entradas del usuario, cadenas de consulta, contenido de cookies, etcétera, en el DOM, las cadenas deben escaparse correctamente. Se debe prestar especial atención a la manipulación del DOM a través de .innerHTML, donde las cadenas sin escape son una fuente típica de XSS.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.innerHTML = user_input

Si escapas los caracteres especiales HTML en la cadena de entrada anterior o la expandes con .textContent, no se ejecutará alert(0). Sin embargo, como el <em> que agrega el usuario también se expande como una cadena tal como está, no se puede usar este método para mantener la decoración de texto en HTML.

Lo mejor que puedes hacer aquí no es escapar, sino limpiar.

Cómo limpiar la entrada del usuario

Diferencia entre escape y limpieza

El escape consiste en reemplazar caracteres HTML especiales por entidades HTML.

La limpieza consiste en quitar partes semánticamente dañinas (como la ejecución de secuencias de comandos) de las cadenas HTML.

Ejemplo

En el ejemplo anterior, <img onerror> hace que se ejecute el controlador de errores, pero si se quitara el controlador onerror, sería posible expandirlo de forma segura en el DOM y dejar <em> intacto.

// XSS 🧨
$div.innerHTML = `<em>hello world</em><img src="" onerror=alert(0)>`
// Sanitized ⛑
$div.innerHTML = `<em>hello world</em><img src="">`

Para realizar la limpieza correctamente, es necesario analizar la cadena de entrada como HTML, omitir las etiquetas y los atributos que se consideren dañinos y conservar los inofensivos.

El objetivo de la especificación propuesta de la API de Sanitizer es proporcionar ese procesamiento como una API estándar para los navegadores.

API de Sanitizer

La API de Sanitizer se usa de la siguiente manera:

const $div = document.querySelector('div')
const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
$div.setHTML(user_input, { sanitizer: new Sanitizer() }) // <div><em>hello world</em><img src=""></div>

Sin embargo, { sanitizer: new Sanitizer() } es el argumento predeterminado. Por lo tanto, puede ser como se muestra a continuación.

$div.setHTML(user_input) // <div><em>hello world</em><img src=""></div>

Vale la pena señalar que setHTML() se define en Element. Como es un método de Element, el contexto para analizar se explica por sí mismo (<div> en este caso), el análisis se realiza una vez de forma interna y el resultado se expande directamente en el DOM.

Para obtener el resultado de la limpieza como una cadena, puedes usar .innerHTML de los resultados de setHTML().

const $div = document.createElement('div')
$div.setHTML(user_input)
$div.innerHTML // <em>hello world</em><img src="">

Personalización mediante configuración

La API de Sanitizer se configura de forma predeterminada para quitar las cadenas que activarían la ejecución de secuencias de comandos. Sin embargo, también puedes agregar tus propias personalizaciones al proceso de limpieza a través de un objeto de configuración.

const config = {
  allowElements: [],
  blockElements: [],
  dropElements: [],
  allowAttributes: {},
  dropAttributes: {},
  allowCustomElements: true,
  allowComments: true
};
// sanitized result is customized by configuration
new Sanitizer(config)

Las siguientes opciones especifican cómo el resultado de la limpieza debe tratar el elemento especificado.

allowElements: Son los nombres de los elementos que el validador debe retener.

blockElements: Son los nombres de los elementos que el validador debe quitar, sin dejar de conservar sus elementos secundarios.

dropElements: Son los nombres de los elementos que el validador debe quitar, junto con sus elementos secundarios.

const str = `hello <b><i>world</i></b>`

$div.setHTML(str)
// <div>hello <b><i>world</i></b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: [ "b" ]}) })
// <div>hello <b>world</b></div>

$div.setHTML(str, { sanitizer: new Sanitizer({blockElements: [ "b" ]}) })
// <div>hello <i>world</i></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowElements: []}) })
// <div>hello world</div>

También puedes controlar si el validador permitirá o rechazará atributos específicos con las siguientes opciones:

  • allowAttributes
  • dropAttributes

Las propiedades allowAttributes y dropAttributes esperan listas de coincidencia de atributos, que son objetos cuyas claves son nombres de atributos y cuyos valores son listas de elementos de destino o el comodín *.

const str = `<span id=foo class=bar style="color: red">hello</span>`

$div.setHTML(str)
// <div><span id="foo" class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["span"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["p"]}}) })
// <div><span>hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {"style": ["*"]}}) })
// <div><span style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({dropAttributes: {"id": ["span"]}}) })
// <div><span class="bar" style="color: red">hello</span></div>

$div.setHTML(str, { sanitizer: new Sanitizer({allowAttributes: {}}) })
// <div>hello</div>

allowCustomElements es la opción para permitir o rechazar elementos personalizados. Si se permiten, se seguirán aplicando otras configuraciones para los elementos y atributos.

const str = `<custom-elem>hello</custom-elem>`

$div.setHTML(str)
// <div></div>

const sanitizer = new Sanitizer({
  allowCustomElements: true,
  allowElements: ["div", "custom-elem"]
})
$div.setHTML(str, { sanitizer })
// <div><custom-elem>hello</custom-elem></div>

Superficie de la API

Comparación con DomPurify

DOMPurify es una biblioteca conocida que ofrece funcionalidad de limpieza. La diferencia principal entre la API de Sanitizer y DOMPurify es que DOMPurify muestra el resultado de la limpieza como una cadena, que debes escribir en un elemento DOM a través de .innerHTML.

const user_input = `<em>hello world</em><img src="" onerror=alert(0)>`
const sanitized = DOMPurify.sanitize(user_input)
$div.innerHTML = sanitized
// `<em>hello world</em><img src="">`

DOMPurify puede servir como resguardo cuando la API de Sanitizer no está implementada en el navegador.

La implementación de DOMPurify tiene algunas desventajas. Si se muestra una cadena, DOMPurify y .innerHTML la analizan dos veces. Este análisis doble desperdicia tiempo de procesamiento, pero también puede generar vulnerabilidades interesantes causadas por casos en los que el resultado del segundo análisis es diferente del primero.

El HTML también necesita un contexto para que se analice. Por ejemplo, <td> tiene sentido en <table>, pero no en <div>. Dado que DOMPurify.sanitize() solo toma una cadena como argumento, se tuvo que adivinar el contexto de análisis.

La API de Sanitizer mejora el enfoque de DOMPurify y está diseñada para eliminar la necesidad de un análisis doble y aclarar el contexto de análisis.

Estado de la API y compatibilidad con navegadores

La API de Sanitizer se está analizando en el proceso de estandarización, y Chrome está en proceso de implementarla.

Paso Estado
1. Crea un video explicativo Completar
2. Crea un borrador de las especificaciones Completar
3. Recopila comentarios y itera en el diseño Completar
4. Prueba de origen de Chrome Completar
5. Lanzamiento Intención de enviar en M105

Mozilla: Considera que esta propuesta vale la pena prototipar y la está implementando de forma activa.

WebKit: Consulta la respuesta en la lista de distribución de WebKit.

Cómo habilitar la API de Sanitizer

Habilitación a través de la opción about://flags o CLI

Chrome

Chrome está en proceso de implementar la API de Sanitizer. En Chrome 93 o versiones posteriores, puedes probar el comportamiento habilitando la marca about://flags/#enable-experimental-web-platform-features. En versiones anteriores de Chrome Canary y el canal para desarrolladores, puedes habilitarlo a través de --enable-blink-features=SanitizerAPI y probarlo de inmediato. Consulta las instrucciones para ejecutar Chrome con marcas.

Firefox

Firefox también implementa la API de Sanitizer como una función experimental. Para habilitarlo, establece la marca dom.security.sanitizer.enabled en true en about:config.

Detección de atributos

if (window.Sanitizer) {
  // Sanitizer API is enabled
}

Comentarios

Si pruebas esta API y tienes comentarios, nos encantaría escucharlos. Comparte tus comentarios sobre los problemas de GitHub de la API de Sanitizer y conversa con los autores de la especificación y las personas interesadas en esta API.

Si encuentras errores o comportamientos inesperados en la implementación de Chrome, informa el error. Selecciona los componentes Blink>SecurityFeature>SanitizerAPI y comparte los detalles para ayudar a los implementadores a hacer un seguimiento del problema.

Demostración

Para ver la API de Sanitizer en acción, consulta el Sanitizer API Playground de Mike West:

Referencias


Foto de Towfiqu barbhuiya en Unsplash.