XSS: un script ajeno en tu página
Problema
Una búsqueda que roba
La tienda tiene un buscador. La página de resultados muestra lo que escribió el comprador: «Resultados para: zapatillas para correr». El servidor pega el texto de la búsqueda en HTML:
function searchPage(query) { return "<p>Resultados para: <b>" + query + "</b></p>";}
console.log(searchPage("zapatillas"));console.log(searchPage("<img src=x onerror=\"steal(shopSession)\">"));<p>Resultados para: <b>zapatillas</b></p><p>Resultados para: <b><img src=x onerror="steal(shopSession)"></b></p>La primera línea es una página normal. En la segunda, la búsqueda es <img src=x onerror="steal(shopSession)">, y ha llegado al HTML entera. El navegador ve una etiqueta de imagen, la imagen x no carga, y ejecuta el código de onerror. Aquí steal es una función inventada; en un ataque real el código envía las cookies o la sesión al servidor del atacante.
El atacante no escribe en su propio buscador. Les envía a los compradores un enlace a una búsqueda en la tienda con este texto en la dirección. Quien lo abre ejecuta el código ajeno en la tienda, con su propia sesión. Eso es XSS (cross-site scripting): un script ajeno en tu página.
La idea
HTML es un lenguaje más
Esta es la inyección de la lección anterior, en un lenguaje más. En HTML, < empieza una etiqueta y " cierra el valor de un atributo. El código pegó el texto del comprador en el lenguaje del navegador, y los caracteres del texto pasaron a ser parte de la página.
Dos tipos son los más frecuentes:
- Reflejado (reflected): el texto viene de la dirección y vuelve en la respuesta, como la búsqueda de arriba. El atacante tiene que conseguir que alguien abra el enlace.
- Almacenado (stored): el texto se guarda en la base de datos, por ejemplo una reseña, y se muestra a todo el que abre el producto. El atacante escribe una vez, y el script se ejecuta en todos.
El servidor no ejecutó nada. El código se ejecuta en el navegador del comprador, con su sesión, como si fuera parte de la tienda.
Míralo
Pruébalo tú mismo
Escribe un texto en el buscador de la tienda, elige una defensa y mira qué pasa. La página está en un iframe aislado, sin red, y no ve nada de este sitio.
Sandbox: entrada hostil en una página de la tienda
La página (en un iframe aislado, sin red)
Qué pasó
Lo que llega al navegador
La sandbox está aislada: no tiene red y no ve nada del sitio. Aquí steal() es el servidor falso del atacante; un ataque real envía los datos con fetch.
Prueba las tres entradas listas sin defensa, luego con escape, luego con CSP. Mira en «Lo que llega al navegador» cómo es el HTML en los tres casos.
La idea
Escapar la salida
El arreglo es el escape: antes de poner un texto en HTML, cambias los cinco caracteres que HTML interpreta por sus entidades. El navegador muestra la entidad como un carácter, pero no la lee como parte de la página:
| Carácter | Se convierte en | Por qué |
|---|---|---|
& |
& |
empieza una entidad; se cambia el primero |
< |
< |
empieza una etiqueta |
> |
> |
cierra una etiqueta |
" |
" |
cierra el valor de un atributo |
' |
' |
cierra un valor entre comillas simples |
function escapeHtml(text) { return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'");}
function searchPage(query) { return "<p>Resultados para: <b>" + escapeHtml(query) + "</b></p>";}
console.log(searchPage("zapatillas"));console.log(searchPage("<img src=x onerror=\"steal(shopSession)\">"));<p>Resultados para: <b>zapatillas</b></p><p>Resultados para: <b><img src=x onerror="steal(shopSession)"></b></p>Ahora la búsqueda llegó al navegador como texto. El comprador ve exactamente lo que escribió, pero no hay ninguna etiqueta.
La regla es escapar la salida: al mostrar, en el lugar donde el texto entra en HTML. No al recibir: entonces no sabes adónde irá el texto (a HTML, a JSON, a un correo), y en la base de datos queda < en lugar de lo que escribió el comprador.
Más precisamente, para curiosos
-
En un proyecto real el HTML se genera con plantillas o con React, y ellos escapan por sí solos:
{text}en JSX es seguro.dangerouslySetInnerHTMLdesactiva el escape, y su nombre dice por qué. En el navegador,element.textContent = textes seguro, mientras queelement.innerHTML = textlee el texto como HTML. - El escape depende del sitio. Los cinco caracteres bastan para el texto entre etiquetas y para el valor de un atributo entre comillas. Un atributo sin comillas, el texto dentro de
<script>, los estilos y una dirección enhreftienen otras reglas. Lo más sencillo: pon texto de fuera solo entre etiquetas y en atributos con comillas. - La dirección en
href.javascript:steal(shopSession)no tiene ninguno de los cinco caracteres y aun así ejecuta código al hacer clic. Para las direcciones de los usuarios hace falta una lista de permitidos, como en la lección «Entrada no confiable»: la dirección empieza porhttps://ohttp://, y si no, no se convierte en enlace.
Pruébalo
Encuentra el campo
La página de reseñas muestra un título, un autor y un texto. ¿Qué campo llega en bruto al navegador?
Elige la línea con el error.
El último: el texto de la reseña. El título y el autor pasan por escapeHtml, mientras que text se pega tal cual. Esto es XSS almacenado: una reseña, y el script se ejecuta en todo el que abre el producto.
Pruébalo
Qué imprimirá
¿Qué imprime el escape para Tom & Jerry <3?
console.log(escapeHtml("Tom & Jerry <3"));& se convierte en &, y < se convierte en <. La segunda respuesta olvidó &. La tercera es un escape que cambia & el último: entonces cambia también el & de <, que acaba de poner, y el comprador ve en la página <3 en lugar de <3.
La idea
CSP: la segunda línea
En un sitio grande hay cientos de lugares donde el texto entra en HTML. Basta con pasar uno por alto. Por eso hay una segunda defensa: Content-Security-Policy (CSP). Es una cabecera de la respuesta que le dice al navegador qué scripts pueden ejecutarse:
Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'script-src 'self' significa: solo scripts de archivos del mismo sitio. Un script escrito dentro de la página (<script>…</script> o onerror="…") no se ejecuta, y el navegador anota la infracción. Por eso también el JavaScript propio del sitio tiene que estar en archivos. Si aun así necesitas un script en la página, CSP acepta uno con un nonce: un valor aleatorio, nuevo en cada respuesta, que el atacante no conoce. Así funciona la defensa «CSP» de la sandbox.
CSP no sustituye al escape. Detiene el script, pero no el HTML ajeno: un formulario falso «Inicia sesión de nuevo» o un precio cambiado no son un script. El escape es la primera línea, CSP es la red de debajo.
Más precisamente, para curiosos
- Pruébalo sin romper nada.
Content-Security-Policy-Report-Onlysolo informa de lo que la política bloquearía. Así se introduce CSP en un sitio antiguo: primero se miran las infracciones, luego se activa. - La cookie de la sesión. Aunque el script se ejecute, una cookie con la marca
HttpOnlyno es visible para JavaScript. Eso viene en la lección sobre las sesiones.
Ejercicio
La reseña
Resuelve la tarea en tu editor.
Una reseña con autor. Escribe renderReview(author, text). Devuelve <article><h3>autor</h3><p>texto</p></article>, con el autor y el texto escapados. Prueba con una reseña normal y con un autor que se llama <script>steal(shopSession)</script>:
<article><h3>Ana</h3><p>Muy buenas <3</p></article><article><h3><script>steal(shopSession)</script></h3><p>Genial</p></article>Más tareas
- Un enlace en la reseña. Añade a la reseña un campo «sitio web del autor». Muéstralo como
<a href="…">solo si la dirección empieza porhttps://ohttp://; si no, muéstralo como texto normal. Prueba conjavascript:steal(shopSession). - Doble escape. ¿Qué verá el comprador si la reseña se escapa una vez al guardarla en la base de datos y otra vez al mostrarla? Prueba con
<3.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Parámetros y return · Cadenas · Entrada no confiable · Inyecciones: cuando los datos se vuelven una orden
- Ejercicios sobre el tema: Seguridad para programadores