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:
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.
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".
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:
<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 script | Qué significa eso para el visitante |
|---|---|
| Cookies y almacenamiento del navegador | Su sesión puede copiarse y usarse en otro lado |
| La página misma | Puede reescribirse para decir o pedir cualquier cosa |
| Tu API, como si fuera él | Las solicitudes salen ya autenticadas |
| Navegación | Puede ser redirigido a una copia convincente de tu sitio |
<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.
La solución
Una propiedad:
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.
Cambia lo que se le pidió al navegador que hiciera con él. El valor no cambia, y es seguro porque nunca iba a ejecutarse.
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.
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.
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:
<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
onmouseoverse 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.
onloadse 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.

