Manipulación segura de DOM con la API de Sanitizer

La nueva API de Sanitizer tiene como objetivo 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 trabajan con cadenas que no son de confianza todo el tiempo, pero renderizar de forma segura ese contenido como parte de un documento HTML puede ser complicado. Si no se cuenta con la suficiente atención, es fácil crear oportunidades para secuencias de comandos entre sitios (XSS) que los atacantes maliciosos podrían explotar.

Para mitigar ese riesgo, la nueva propuesta de la API de Sanitizer busca 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())

Escapa de la entrada del usuario

Al insertar entradas del usuario, cadenas de consulta, contenido de cookies, etc., en el DOM, las cadenas deben tener el escape correcto. Se debe prestar especial atención a la manipulación del DOM a través de .innerHTML, donde las strings 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 agregó el usuario también se expande como una cadena tal como está, este método no se puede usar para mantener la decoración del texto en HTML.

Lo mejor que se puede hacer aquí no es evitar, sino depurar.

Limpia las entradas del usuario

Cuál es la diferencia entre escapar y limpiar

El escape se refiere al reemplazo de caracteres HTML especiales por entidades HTML.

La limpieza hace referencia a quitar las partes que son perjudiciales desde el punto de vista semántico (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 quita el controlador onerror, se puede expandir 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 limpiar correctamente, es necesario analizar la cadena de entrada como HTML, omitir las etiquetas y los atributos que se consideren dañinos, y conservar los que sean inofensivos.

La especificación de la API de Sanitizer propuesta tiene como objetivo proporcionar ese procesamiento como una API estándar para 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. 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 se trata de un método de Element, el contexto para analizar no requiere explicación (en este caso, es <div>), el análisis se realiza una vez de forma interna y el resultado se expande directamente al 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="">

Personalizar mediante configuración

La API de Sanitizer está configurada de forma predeterminada para quitar cadenas que activarían la ejecución de la secuencia 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 limpiador debe conservar.

blockElements: Son los nombres de los elementos que el limpiador debe quitar, pero conserva sus elementos secundarios.

dropElements: Son los nombres de los elementos que debe quitar el desinfectante, 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 usar las siguientes opciones para controlar si el limpiador permitirá o rechazará atributos específicos:

  • allowAttributes
  • dropAttributes

Las propiedades allowAttributes y dropAttributes esperan listas de coincidencias de atributos, es decir, objetos cuyas claves son nombres de atributos, y sus 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 están permitidas, se aplican otras configuraciones de 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 funciones de limpieza. La principal diferencia entre la API de Sanitizer y DOMPurify es que DOMPurify muestra el resultado de la limpieza en forma de cadena, que debes escribir en un elemento del DOM mediante .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 funcionar 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, la cadena de entrada se analiza dos veces mediante DOMPurify y .innerHTML. Este análisis doble desperdicia tiempo de procesamiento, pero también puede dar lugar a vulnerabilidades interesantes causadas por casos en los que el resultado del segundo análisis es diferente del primero.

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

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

Estado de la API y compatibilidad con navegadores

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

Paso Estado
1. Crear explicación Completo
2. Crear borrador de especificación Completo
3. Recopilar comentarios e iterar el diseño Completo
4. Prueba de origen de Chrome Completo
5. Lanzamiento Intención de enviar en M105

Mozilla: Considera que vale la pena crear un prototipo de esta propuesta y la implementa activamente.

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

Cómo habilitar la API de Sanitizer

Navegadores compatibles

  • Chrome no es compatible.
  • Edge: no es compatible.
  • Firefox: detrás de una marca.
  • Safari: no es compatible.

Origen

Habilita mediante about://flags o la opción de CLI

Chrome

Chrome está en proceso de implementar la API de Sanitizer. En Chrome 93 o versiones posteriores, puedes habilitar la marca about://flags/#enable-experimental-web-platform-features para probar el comportamiento. En las versiones anteriores de Chrome Canary y el canal para desarrolladores, puedes habilitarla mediante --enable-blink-features=SanitizerAPI y probarla ahora mismo. Consulta las instrucciones para ejecutar Chrome con marcas.

Firefox

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

Detección de funciones

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

Comentarios

Si pruebas esta API y tienes comentarios, nos encantaría recibirlos. Comparte tus ideas sobre los problemas de la API de Sanitizer en GitHub y háblalo con los autores de las especificaciones y las personas interesadas en esta API.

Si encuentras algún error o comportamiento inesperado en la implementación de Chrome, informa un error para informarlo. Selecciona los componentes de Blink>SecurityFeature>SanitizerAPI y comparte los detalles para ayudar a los implementadores a realizar un seguimiento del problema.

Demostración

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

Referencias


Foto de Towfiqu barbhuiya en Unsplash.