Skip to content
This page has been auto-translated and may contain errors.View in English

Cross-site scripting

Te registras, y la página te saluda. El formulario de registro toma un nombre, lo envía al servidor, y el servidor lo devuelve para que la página te salude con él.

Ese viaje de ida y vuelta es todo el problema.

Un texto que viene de un visitante se vuelve peligroso en el momento en que una página lo trata como marcado (markup).

El error

Acá está la función que muestra el saludo:

Vulnerable
ts
type RegisterResponse = { user: { name: string } }

function displaySuccess(response: RegisterResponse) {
  successBox.innerHTML = `Welcome, ${response.user.name}`
}

innerHTML significa "interpreta este string como HTML". El navegador lo lee, construye los elementos que describe, y activa cualquier comportamiento que esos elementos pidan.

Para el nombre Priya eso es inofensivo. El navegador construye un nodo de texto y sigue adelante.

Para un nombre que contiene una etiqueta, el navegador construye esa etiqueta en su lugar. No tiene forma de saber que ese marcado en particular vino del teclado de un desconocido, porque para cuando llega al parser ya es parte de tu página.

Eso es cross-site scripting, generalmente escrito XSS: script inyectado en un sitio en el que la víctima confía, ejecutándose en el navegador de la víctima. Existe desde fines de los años noventa y todavía aparece constantemente, porque los ingredientes son comunes. Tomas un input y lo pones en una página.

JunoEl error El navegador no está siendo descuidado acá. Está haciendo exactamente lo que se le pidió: innerHTML es una solicitud para tratar un string como estructura de página, así que eso hace.

El problema es que el string vino de alguien que nunca conociste, y nada en el medio dijo "esta parte es solo texto".

JunoEl error La propiedad a la que se asigna el valor es todo el hallazgo. Cuando revises código de front end, busca innerHTML, outerHTML, insertAdjacentHTML y document.write, y luego pregúntate de dónde vino cada valor.

A estos se les llama sinks (sumideros): los lugares donde un valor deja de ser dato y empieza a ser interpretado. Un valor es tan seguro como el sink en el que cae, así que el mismo nombre puede ser inofensivo en una línea y peligroso en la siguiente.

JunoEl error Vale la pena nombrar las tres variantes, porque necesitan arreglos distintos. Esta es XSS reflejado: el valor va al servidor y vuelve directo en la respuesta. El XSS almacenado es el mismo error con paciencia, guardado una vez y servido a todos los que cargan la página después.

El XSS basado en el DOM no involucra al servidor para nada. El DOM es el modelo de objetos vivo que el navegador tiene de la página, y esta variante es script que lo reescribe usando un valor que la página leyó por su cuenta.

Ese payload puede vivir en el fragmento de la URL después del signo de numeral, que los navegadores no envían al servidor.

Ese último punto importa para cómo buscas este tipo de errores. Los logs del servidor no pueden mostrarte un payload de XSS basado en el DOM, porque el servidor nunca lo recibió.

Si revisar logs es tu forma de cazar esta clase de error, hay un tercio de ella ante el que estás estructuralmente ciego.

El ataque

Un nombre es un campo de texto, así que nadie lo piensa como un lugar para poner código. Escribe esto en él:

html
<img src="x" onerror="alert('XSS successful')">

Envía el formulario. El servidor guarda el valor y lo devuelve tal cual. displaySuccess se lo pasa a innerHTML, el navegador construye un elemento <img> real, intenta cargar una imagen llamada x, falla, y ejecuta el manejador onerror.

No hay ninguna etiqueta <script> en ningún lado. Esa es la parte que sorprende a la gente: bloquear la palabra script no evita casi nada, porque HTML tiene docenas de atributos que ejecutan código cuando pasa algo cotidiano.

Ejecuta esto solo contra tu propio proyecto

Los payloads acá existen para que puedas reconocer este error en código del que eres responsable. Uno almacenado no se queda contigo: se dispara en el navegador de quien sea que cargue la página después.

Eso descarta cualquier lugar con visitantes reales. Tu propio proyecto, o un sistema para el que tengas permiso explícito de probar.

El script se ejecuta con el origen de la página, así que puede hacer lo mismo que podría hacer tu propio JavaScript:

A qué puede acceder el scriptQué significa eso para el visitante
Cookies y almacenamiento del navegadorSu sesión puede copiarse y usarse en otro lado
La página mismaPuede reescribirse para decir o pedir cualquier cosa
Tu API, como si fuera élLas solicitudes salen ya autenticadas
NavegaciónPuede ser redirigido a una copia convincente de tu sitio
JunoEl ataque La palabra "script" hace que suene como si necesitara una etiqueta <script>. No es así.

Una imagen que falla al cargar, un elemento de página sobre el que pasa el mouse, un SVG que termina de cargar: cada uno de ellos puede llevar una instrucción. Bloquear una sola palabra clave deja el resto intacto.

JunoEl ataque "Mismo origen" es la parte con la que hay que quedarse pensando. El script se ejecuta como si fuera tu app, así que toda protección basada en confiar en tu propio front end desaparece en ese momento.

Una consecuencia práctica: HttpOnly en una cookie de sesión evita que el script la lea. No hace nada para evitar que el script envíe solicitudes a las que el navegador la adjunta de todos modos. Es un buen control, pero más limitado de lo que parece.

JunoEl ataque La razón por la que las listas de bloqueo fallan es que la superficie de ataque es la especificación de HTML, no una lista de palabras. Solo los atributos de manejadores de eventos suman docenas, y siguen apareciendo más.

Por eso Content Security Policy, un header de respuesta que le dice al navegador qué orígenes de script puede ejecutar, vale la pena tenerlo aun cuando tu escape ya sea correcto. Es una segunda capa para el día en que a alguien se le escape una ruta de salida.

La versión que sirve es la basada en nonce o en hash. Una política que contiene unsafe-inline permite exactamente el manejador inline de este ataque, que es la forma más común en que una política termina siendo decorativa.

No recurras a esto en lugar de arreglar el sink. Recurre a esto porque eventualmente se te va a escapar uno.

La solución

Una propiedad:

Fixed
ts
function displaySuccess(response: RegisterResponse) {
  successBox.textContent = `Welcome, ${response.user.name}`
}

textContent asigna texto. No marcado que podría ser texto, sino texto. El mismo payload ahora aparece en la página como los caracteres literales <img src="x" onerror="alert('XSS successful')">, visible e inerte.

JunoLa solución Fíjate en lo que la solución no hace. No inspecciona el nombre, no le quita nada, ni decide si parece sospechoso.

Cambia lo que se le pidió al navegador que hiciera con él. El valor no cambia, y es seguro porque nunca iba a ejecutarse.

JunoLa solución Recurre a textContent por defecto, y trata cada innerHTML como algo que necesita una justificación. La mayoría están ahí porque alguien quería un salto de línea o una palabra en negrita, algo que un pequeño elemento construido con createElement resuelve sin abrir la puerta.

Cuando el marcado realmente tiene que venir del input del usuario, texto enriquecido en un comentario por ejemplo, eso es trabajo de un sanitizador. Uno mantenido activamente como DOMPurify, nunca una expresión regular que hayas escrito tú, porque lo que estás analizando es HTML, y HTML es mucho más raro de lo que parece.

JunoLa solucióntextContent es el escape correcto para un solo contexto: texto HTML. Los contextos no comparten una misma respuesta. El mismo valor colocado en un atributo necesita codificación de atributo, en una URL necesita codificación de URL, en un bloque <script> necesita codificación de string de JavaScript, y en CSS necesita la suya propia otra vez.

El fallo clásico es un valor escapado una sola vez, al entrar, y luego reutilizado en algún lugar con reglas distintas. Por eso el escape pertenece a la salida, donde se conoce el destino, y la validación pertenece a la entrada, donde decides si aceptas el valor o no.

Los frameworks modernos escapan la interpolación de texto por ti, lo cual elimina la mayor parte de este problema. Pero cada uno también conserva una vía de escape, dangerouslySetInnerHTML en React y v-html en Vue, y esos nombres son toda la advertencia que necesitas. Búscalos primero en cualquier revisión de código.

Por qué funciona la solución

El navegador necesita una de dos instrucciones para cualquier valor: tratarlo como estructura, o tratarlo como texto. innerHTML da la primera, textContent da la segunda. Mismo string, instrucción distinta, resultado distinto.

La solución acá está en el front end, lo cual arregla esta página y solo esta página. El valor sigue almacenado sin procesar en el servidor, y ahí, en el almacenamiento, es donde espera:

  • Un panel de administración que lista los registros recientes.
  • Una plantilla de correo que saluda al usuario por su nombre.
  • Un reporte exportado para que alguien más lo abra.
  • El cliente de otro equipo llamando a la misma API.

Cada uno de esos es un destino distinto con reglas distintas, y ninguno sabe lo que decidió esta página en particular. Así que el servidor tampoco puede confiar en el valor, y ahí es donde entra más adelante la validación de esquemas: rechazar un valor absurdo desde la entrada reduce lo que cualquier destino tiene que soportar.

JunoPor qué funciona la solución Que una página esté arreglada no hace que el valor sea seguro. Lo hace seguro ahí, en esa página.

El mismo nombre sigue estando en el almacenamiento, esperando la siguiente pantalla que lo muestre, y esa pantalla tiene que tomar su propia decisión.

JunoPor qué funciona la solución Cuando encuentres uno de estos casos, resiste la tentación de arreglar solo la línea del reporte de errores. Busca cada lugar donde ese campo se renderiza, porque un payload almacenado se dispara dondequiera que aparezca, y las herramientas de administración suelen ser la superficie menos revisada del código.

Las páginas de administración también son el peor lugar donde puede dispararse, porque la sesión que toma prestada tiene la mayor autoridad.

JunoPor qué funciona la solución Por eso "sanitizar en la entrada" sigue fallando como estrategia. Incrusta las suposiciones de un destino en los datos almacenados, corrompe valores que eran legítimos, y termina dándote una tabla llena de strings a medio codificar que nadie puede revertir de forma segura una vez que aparece un segundo consumidor.

La división duradera es esta: valida en el límite, porque ahí estás decidiendo si aceptas el valor; escapa en la salida, porque solo el renderizador sabe hacia dónde va. La hoja de referencia de prevención de OWASP vale la pena tenerla siempre a mano, y está organizada por contexto de salida justamente por esa razón.

Ponlo a prueba

Tres valores, cada uno escrito en el campo de nombre de una página que todavía usa innerHTML. Descubre cuáles se ejecutan, y qué hace que cada uno se dispare:

html
<div onmouseover="alert('one')">hover me</div>
<svg onload="alert('two')"></svg>
<iframe src="javascript:alert('three')"></iframe>
Compara tus respuestas

Los tres se ejecutan, y no todos necesitan lo mismo del visitante.

  • El div espera. Se renderiza como texto normal que dice "hover me", y onmouseover se dispara cuando el puntero pasa por encima. No pasa nada hasta que alguien mueve el mouse, por eso un payload puede parecer inofensivo en una captura de pantalla.
  • El svg no espera. onload se dispara apenas el elemento termina de analizarse, así que se ejecuta en el mismo momento en que se renderiza el saludo.
  • El iframe depende del navegador. El esquema de URL javascript: está bloqueado en los navegadores actuales para navegación dentro de un frame, así que este es el más probable de no hacer nada, y es la razón por la que un payload que falla demuestra muy poco. Un navegador distinto, uno más antiguo, o un sink ligeramente diferente pueden cambiar el resultado.

Cambia el sink a textContent y los tres se convierten en lo que siempre fueron: texto raro dentro de un saludo.

Hacia dónde va esto

Cada uno de esos payloads es un string corto. Hacen daño al ser interpretados, no por ser grandes.

La denegación de servicio le da la vuelta a eso, con input que nunca se interpreta como nada y que causa problemas solo por la cantidad que hay de él.