Seguridad web y de APIs
OWASP Top 10 explicado para desarrolladores
Qué es el OWASP Top 10, qué significa cada categoría de 2025, cómo defenderse y cómo mapear retos de Juice Shop.
Entiende el XSS reflejado, almacenado y basado en DOM, por qué la codificación depende del contexto y cómo la CSP suma defensa en profundidad.
El cross-site scripting (XSS) ocurre cuando una aplicación web toma un dato que un atacante puede influir y lo coloca en una página de forma que el navegador lo interpreta como código. El navegador ejecuta entonces ese código con los privilegios del origen de la página. Puede leer la página, enviar peticiones en nombre del usuario y cambiar lo que el usuario ve.
El problema central no es que un script venga de otro sitio. El problema es que la aplicación mezcla datos y código, y el navegador no puede distinguir qué parte vino del desarrollador y cuál vino de un usuario. MITRE lo clasifica como CWE-79, "Improper Neutralization of Input During Web Page Generation". Esa redacción importa: la falla es no neutralizar la entrada en el momento en que se construye la página.
El XSS es una de las vulnerabilidades web más comunes, y sigue siéndolo porque se esconde en funciones normales: un buscador que repite el término buscado, un campo de comentarios, el nombre de un perfil, un fragmento de URL que una single-page app lee al cargar. Un ataque exitoso se ejecuta dentro de la sesión iniciada de la víctima. Puede leer lo que la página puede leer, disparar acciones que el usuario tiene permitidas o mostrar un formulario de inicio de sesión falso. El navegador limita parte de esto con la same-origin policy, pero el XSS funciona justamente porque se ejecuta dentro del origen en el que el usuario ya confía.
Para quien desarrolla, la lección práctica es estrecha y útil. No necesitas filtrar cada cadena mala. Necesitas asegurarte de que el texto no confiable nunca sea interpretado por el navegador como marcado o código.
OWASP describe tres familias. Se diferencian por el camino que recorre el dato no confiable antes de llegar al navegador.
XSS reflejado. El servidor toma un valor de la petición actual, normalmente un parámetro de consulta o un campo de formulario, y lo devuelve en la respuesta sin una codificación segura. El atacante construye un enlace que lleva la carga maliciosa. El ataque necesita que la víctima abra ese enlace. La carga no queda guardada en ningún lugar del servidor.
XSS almacenado. La aplicación guarda la entrada controlada por el atacante, como un comentario, un nombre de usuario o un ticket de soporte, y después la muestra a otros usuarios. La víctima no necesita un enlace especial. Basta con ver la página normal, por eso el XSS almacenado suele tener el mayor impacto.
XSS basado en DOM. Es posible que el servidor nunca vea la carga. El JavaScript del cliente lee una fuente que el atacante controla, como location.hash, location.search, document.referrer o un mensaje de postMessage, y la escribe en un sink peligroso, como innerHTML, document.write o eval. El defecto está en el código del navegador. Los filtros del lado del servidor no ven el ataque, por eso este tipo sorprende a equipos que solo revisan el backend.
Los tres tipos comparten una causa raíz. Un dato no confiable llega a un contexto que lo interpreta como código.
La codificación de salida es la defensa principal, y no es una sola operación. La codificación correcta depende del lugar donde cae el valor. La OWASP XSS Prevention Cheat Sheet enumera las reglas de cada contexto. La versión corta:
| Contexto donde cae el valor | Qué hacer | Error común |
|---|---|---|
| Cuerpo HTML | Codificación de entidades HTML de &, <, >, ", ' |
Codificar dos veces, lo que muestra &lt; en la pantalla |
| Atributo HTML | Codificar y colocar siempre el valor del atributo entre comillas | Dejar el valor sin comillas, o creer que eso protege href o los manejadores de eventos |
| Cadena JavaScript | Usar un codificador de la biblioteca para ese contexto exacto | Escapar para HTML e insertar luego el valor dentro de un bloque <script> |
| Parámetro de URL | Codificar el valor con percent-encoding usando encodeURIComponent() y aceptar solo los esquemas http y https en URLs completas |
Creer que codificar basta para una URL completa que viene del usuario: un esquema javascript: sigue ejecutándose cuando llega a href |
| CSS | No meter datos no confiables en el CSS; definir propiedades fijas mediante la CSSOM | Armar una cadena para el atributo style con la entrada del usuario |
La tabla muestra por qué "sanitiza la entrada" es una regla incompleta. Una cadena segura en un párrafo puede ser peligrosa dentro de un atributo href, porque javascript: es un esquema de URL válido ahí. La misma cadena es inofensiva cuando se escribe con textContent. La codificación tiene que coincidir con el sink que recibe el valor en el momento en que se escribe.
El cheat sheet de OWASP también advierte que los escapes hexadecimales o Unicode por sí solos no sustituyen a un codificador de JavaScript sensible al contexto, y que eval(), setTimeout() y setInterval() nunca deben recibir cadenas no confiables. Codifica en la salida y mantén esas funciones lejos de los caminos no confiables.
Puedes reducir la cantidad de decisiones eligiendo sinks que traten la entrada como texto. Asignar element.textContent o element.value escribe caracteres, no marcado. Asignar element.innerHTML ejecuta un parser HTML. El cheat sheet de OWASP señala innerHTML como un sink que hay que evitar con datos no confiables y recomienda textContent o value en su lugar.
Los frameworks modernos como React, Angular, Vue y Svelte escapan los valores en sus enlaces de plantilla por defecto. Un valor escrito como {nombreUsuario} en JSX o como interpolación en una plantilla se convierte en texto. El riesgo vuelve cuando el desarrollador usa una salida de emergencia: dangerouslySetInnerHTML en React, bypassSecurityTrustHtml en Angular, v-html en Vue o una escritura directa al DOM desde una biblioteca. Esas salidas de emergencia son por donde el XSS suele entrar a una app con framework. Trata cada uso como un punto de revisión y mantén una lista de ellos.
Una Content Security Policy (CSP) es una cabecera de respuesta, o un elemento <meta http-equiv>, que indica al navegador de qué orígenes puede cargar una página scripts, estilos, imágenes y otros recursos. MDN describe su función principal como controlar qué recursos puede cargar un documento, lo que defiende frente al XSS. La directiva default-src fija la alternativa para cada tipo de recurso que no tenga su propia directiva.
Con default-src 'self', el navegador rechaza los scripts inline y los manejadores de eventos inline, como onerror="...", y rechaza las URL javascript: en los lugares donde ejecutarían código. Una CSP no corrige un defecto en tu HTML. Limita lo que puede hacer una carga inyectada. Por eso es defensa en profundidad: la codificación impide la inyección, y la CSP hace que una inyección que se escapó tenga menos efecto.
Hay dos límites importantes. Una política en <meta> no admite todas las funciones de CSP, y MDN señala que las políticas en modo report-only deben enviarse como cabecera HTTP. Revisa en la página de MDN qué directivas respetan los navegadores dentro de <meta> antes de depender de una. Una política que permite 'unsafe-inline' o una lista amplia de hosts ofrece mucha menos protección.
Este laboratorio tiene dos partes. Primero, resuelves el desafío de DOM XSS de OWASP Juice Shop, una aplicación vulnerable a propósito, que corre en tu propia máquina. Después, construyes una página pequeña que escribe el mismo texto no confiable en dos sinks distintos y observas lo que hace el navegador.
Ejecuta cada paso en una máquina que controlas. Mantén los servicios enlazados a 127.0.0.1. No apuntes estas cargas contra un sitio que no sea tuyo ni para el que no tengas permiso escrito de prueba.
Confirma que Docker responde:
docker --version
docker info --format '{{.ServerVersion}}'En la máquina usada para este artículo, el primer comando mostró Docker version 29.8.2, build 7fc2dff. Tu versión puede ser diferente.
Inicia el contenedor con un mapeo de puerto solo para loopback:
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shopEl prefijo 127.0.0.1: publica el puerto solo en tu interfaz de loopback. Si el puerto 3000 ya está en uso, elige otro puerto del host, como 127.0.0.1:3001:3000, y cambia las URL de abajo para que coincidan.
Comprueba que la aplicación responde y lee su versión:
curl -s http://127.0.0.1:3000/rest/admin/application-versionEn la máquina usada para este artículo, la respuesta fue {"version":"20.3.0"}. El primer arranque puede tardar un poco, así que repite la petición si no hay respuesta.
La guía complementaria oficial de Juice Shop tiene una sección llamada "Perform a DOM XSS attack". La pista dice que busques un campo de entrada cuyo contenido aparece en el HTML cuando se envía el formulario. La guía da esta carga:
<iframe src="javascript:alert(`xss`)">Sigue la guía en tu propio navegador. Abre la barra de búsqueda de Juice Shop, pega la carga y envíala. También funciona una URL de búsqueda con la consulta en el fragmento. En la máquina usada para este artículo, esa URL se cargó en Chrome headless, y luego el marcador informó el desafío como resuelto:
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3EConsulta el desafío a través de la API de la aplicación para comprobar su estado:
curl -s "http://127.0.0.1:3000/api/Challenges/?name=DOM%20XSS" | grep -o '"solved":[a-z]*'Antes de cargar la URL de búsqueda, la salida fue "solved":false. Después de cargarla, la salida fue "solved":true. Tu resultado debería coincidir una vez que hayas disparado el desafío en un navegador.
Fíjate en lo que hizo de esto un defecto de DOM. La carga va en el fragmento, que el navegador no envía al servidor, y el código del cliente la escribe en la página. Un registro del servidor solo mostraría peticiones del HTML y los scripts de la aplicación.
Crea una carpeta y entra en ella:
mkdir xsslab && cd xsslabCrea index.html. Este archivo lee el texto después de #, lo escribe una vez con innerHTML y otra con textContent, y agrega el elemento meta de CSP indicado en el enunciado del laboratorio:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<title>XSS sink lab</title>
</head>
<body>
<h1>Sink comparison</h1>
<p>Put text after the # in the URL, for example <code>#hello</code>.</p>
<h2>innerHTML</h2>
<div id="html-sink"></div>
<h2>textContent</h2>
<div id="text-sink"></div>
<script src="app.js"></script>
</body>
</html>Crea app.js en la misma carpeta. El script va en un archivo aparte a propósito. Un bloque <script> inline también quedaría bloqueado por la política, y eso ocultaría la comparación que quieres hacer.
// Read the fragment once, then write it to two different sinks.
const raw = decodeURIComponent(location.hash.slice(1));
document.getElementById('html-sink').innerHTML = raw;
document.getElementById('text-sink').textContent = raw;Sirve la carpeta solo en loopback. En Windows, el lanzador de Python suele ser python; en Linux y macOS, suele ser python3:
python -m http.server 8000 --bind 127.0.0.1Mantén esa terminal abierta mientras trabajas. Abre http://127.0.0.1:8000/index.html en el navegador. Detén el servidor con Ctrl+C cuando termines.
Abre las herramientas de desarrollo del navegador y la pestaña Console. Carga primero la página con un valor inofensivo:
http://127.0.0.1:8000/index.html#helloLuego ejecuta estas dos líneas en la consola:
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContentAmbas muestran hello. Ahora carga un fragmento que mezcla marcado y un manejador de eventos:
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3ECargué exactamente esa URL en Chrome headless 156.0.8078.80 y volqué el DOM resultante. La salida relevante fue:
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello <img src=x onerror=alert(1)></div>El sink innerHTML creó un elemento img. El sink textContent guardó los mismos caracteres como texto plano. Ejecuta de nuevo las dos líneas de la consola sobre esta URL y comprueba que la primera devuelve marcado con un elemento y la segunda devuelve la cadena escapada.
La página de arriba ya incluye default-src 'self'. La misma ejecución headless registró este mensaje:
Executing inline event handler violates the following Content Security Policy directive 'default-src 'self''.El elemento img se creó, pero su manejador onerror no ejecutó alert(1). La política bloqueó el manejador inline. Tu consola debería mostrar un mensaje parecido. El texto exacto puede cambiar entre versiones del navegador.
Para ver la diferencia, quita el elemento meta, carga otra vez la misma URL y mira si aparece la alerta. Luego vuelve a poner el elemento meta. Esta comparación es el único paso del laboratorio donde la carga debe ejecutarse.
Ten presente un punto. La política no volvió seguro el sink innerHTML. Solo bloqueó un tipo de ejecución. El elemento img sigue existiendo en el DOM, y un atacante que encuentre una carga que la política permita aún podría actuar. La corrección adecuada es escribir el texto no confiable con textContent.
Codificar una vez, en el contexto equivocado. La codificación HTML protege el texto de un párrafo. No protege una URL, una cadena JavaScript ni un valor CSS. Ajusta el codificador al lugar donde cae el valor.
Filtrar por cadenas malas conocidas. Las listas de bloqueo que quitan <script> dejan pasar <img onerror>, <svg onload> y URL javascript:. La codificación y los sinks seguros funcionan porque no dependen de adivinar la siguiente cadena del atacante.
Confiar a ciegas en el framework. Los bindings por defecto escapan los valores, pero dangerouslySetInnerHTML, v-html y bypassSecurityTrustHtml saltan esa protección. Busca esos usos en tu código.
Leer solo el código del servidor. El DOM XSS nunca llega al servidor. Revisa el código del cliente que lee location, document.referrer y datos de postMessage.
Usar la CSP como único control. Una política estricta ayuda, pero no reemplaza la codificación, y una política débil con 'unsafe-inline' ofrece poca protección.
Suponer que la política en <meta> tiene todas las funciones. Una política en <meta> no puede usar el modo report-only ni todas las directivas. Si necesitas informes, envía la política como cabecera de respuesta.
Lee el OWASP Cross Site Scripting Prevention Cheat Sheet de principio a fin. Para ser una referencia de seguridad, es corto, y cubre todos los contextos de la tabla de arriba. Después lee la guía de CSP de MDN y prueba una política más estricta, con nonces o hashes, en tu página de laboratorio.
Resuelve los otros desafíos de Juice Shop que involucran XSS y compara en qué se diferencian las variantes almacenada y reflejada del caso de DOM que acabas de resolver. Mantén el laboratorio en loopback y detén el contenedor cuando termines:
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>El próximo artículo de la serie trata sobre cross-site request forgery y cómo las cookies SameSite reducen uno de sus caminos. XSS y CSRF suelen confundirse, y conviene entender la diferencia antes de diseñar un modelo de sesión.
Seguridad web y de APIs
Qué es el OWASP Top 10, qué significa cada categoría de 2025, cómo defenderse y cómo mapear retos de Juice Shop.
Seguridad web y de APIs
Cómo la SQL Injection cambia el sentido de una consulta, por qué las consultas parametrizadas la corrigen y un laboratorio con Juice Shop y sqlite3.
Seguridad web y de APIs
CSRF usa las cookies de tu propio navegador en tu contra. Revisa las defensas de la más fuerte a la más débil y prueba SameSite con un laboratorio Flask.