Durante bastante tiempo asocié XSS con una imagen muy concreta: alguien escribe código malicioso en un formulario y ese código aparece después en una página.
Ese caso existe, pero se queda corto para entender por qué debería importarle a alguien que hace frontend.
En cuanto una interfaz lee una búsqueda, un parámetro de URL, una respuesta de API, un comentario, una preferencia guardada en el navegador o contenido de un servicio externo, está moviendo datos de un sitio a otro. El problema aparece cuando uno de esos datos acaba tratado como instrucciones para el navegador en lugar de como texto.
Estoy estudiando ciberseguridad y este es uno de los temas que más me ha hecho revisar cómo pienso el JavaScript. No porque haya descubierto una técnica rara, sino porque me obliga a mirar algo muy básico. Qué estoy renderizando y con qué API.
Qué es XSS sin la explicación de manual
XSS significa Cross-Site Scripting. Ocurre cuando una página acaba ejecutando contenido que no debería ejecutar porque ha tratado un dato no confiable como HTML o JavaScript.
El resultado puede ir bastante más allá de una alerta en pantalla. Si una persona tiene sesión iniciada, ese código puede modificar lo que ve, enviar peticiones en su nombre, capturar datos introducidos en formularios o cargar recursos de otro sitio.
Como frontend me interesa una cosa. El navegador no sabe si ese contenido lo escribió tu equipo, lo devolvió una API o salió de un campo de búsqueda. Solo interpreta lo que recibe en el contexto en el que se lo das.
Por eso XSS no empieza necesariamente en un formulario. Empieza cuando un dato que no controlas llega a un lugar donde el navegador puede interpretarlo como algo más que texto.
Los datos no confiables están en más sitios de los que parece
Un comentario de usuario es el ejemplo típico, pero no es el único. En una aplicación web conviene tratar con cuidado datos que pueden venir de varios sitios
- Un formulario.
- Una API propia o de terceros.
- Parámetros de URL y fragmentos como
location.hash. localStorage,sessionStorageo cookies accesibles desde JavaScript.- Contenido escrito en un CMS.
- Un archivo JSON.
- Un servicio de analítica, chat o cualquier script externo.
Que un dato esté en tu propia base de datos tampoco lo vuelve seguro automáticamente. Puede haber llegado antes desde un formulario, una integración o una migración de contenido.
Esto me resulta especialmente útil al pensar en proyectos de contenido. En Apuntes ICO, por ejemplo, hay búsqueda, módulos y muchas piezas de información que se muestran en pantalla. Aunque el contenido lo prepare yo, la pregunta sigue siendo buena. Si mañana parte de esa información llega de otra fuente, ¿el código la mostraría como texto o intentaría interpretarla como HTML?
Sirve para localizar los puntos donde se puede romper la separación entre dato y código.
El riesgo suele estar en cómo lo pintas
En JavaScript, innerHTML es cómodo porque permite construir una parte de la interfaz con una cadena.
results.innerHTML = `<p>Resultados para: ${query}</p>`;Si query viene de una fuente que no controlas, el navegador intentará interpretar el contenido resultante como HTML. Ahí está el problema.
Si solo quieres mostrar texto, no necesitas abrir esa puerta. Puedes crear el nodo y asignar su contenido como texto.
const message = document.createElement('p');
message.textContent = `Resultados para: ${query}`;
results.replaceChildren(message);Cuando el valor es texto, textContent expresa exactamente esa intención. Muestra una cadena y no la procesa como marcado.
También es fácil caer en esto con plantillas de tarjetas, resultados de búsqueda, mensajes de error, nombres de usuario o un preview generado en cliente. El sitio del que viene el dato importa, pero el lugar donde lo insertas determina cómo debe tratarse.
El contexto importa tanto como el dato
Un mismo valor no se maneja igual si aparece dentro de un párrafo, en un atributo, en una URL o en código JavaScript.
Por ejemplo, construir enlaces a partir de datos externos necesita más que interpolar una cadena.
link.href = profileUrl;Que el navegador acepte el valor no lo convierte en un destino adecuado. Conviene comprobar que es una URL y limitar qué protocolos quieres permitir.
const url = new URL(profileUrl, window.location.origin);
if (url.protocol === 'https:' || url.protocol === 'http:') {
link.href = url.href;
}En una aplicación real quizá quieras permitir solo dominios concretos o ni siquiera aceptar URLs externas. La decisión depende de la función.
OWASP insiste en esta idea porque no existe un escape genérico que sirva igual para HTML, atributos, URLs, CSS y JavaScript. El navegador interpreta cada contexto de una forma distinta. Una solución que funciona en un texto puede ser incorrecta dentro de un href o de un script.
Validar la entrada no arregla todo
Al empezar a aprender seguridad, es tentador buscar una lista de caracteres prohibidos. Bloquear <script> parece una salida rápida, pero deja fuera muchas otras formas de crear contenido que el navegador puede interpretar. Además, puede romper texto legítimo.
La validación tiene su sitio. Si un campo debe ser un código postal, una fecha o una categoría concreta, hay que comprobarlo. Pero para evitar XSS también tienes que decidir cómo se representa ese dato al salir.
Si estás mostrando texto, trátalo como texto. Si estás construyendo una URL, valida la URL y usa APIs que asignen el atributo. Si necesitas permitir HTML escrito por usuarios, el problema ya no se resuelve escapándolo todo, porque dejaría de renderizarse. Necesitas sanear ese HTML con una herramienta mantenida para ello y definir qué etiquetas y atributos aceptas.
Ese cambio de enfoque me parece más útil que memorizar ataques. Primero entiendo qué necesita hacer la interfaz y después elijo la forma más segura de representarlo.
Los frameworks ayudan, pero tienen salidas de emergencia
Los frameworks modernos suelen escapar texto por defecto. Eso reduce muchos errores al renderizar variables en una plantilla.
Pero esa protección se puede saltar cuando usamos APIs pensadas precisamente para insertar HTML. En React aparece dangerouslySetInnerHTML; en Vue, v-html; en Angular, funciones que marcan contenido como seguro; y en JavaScript sin framework, innerHTML, outerHTML, insertAdjacentHTML o document.write.
Estas APIs existen porque a veces una aplicación necesita mostrar HTML enriquecido. Su uso debería ser una decisión concreta, con contenido saneado y una razón para no usar texto normal.
En mis proyectos con Astro y MDX, el contenido de los artículos se escribe dentro del repositorio y pasa por el build. Eso reduce el tipo de superficie que tendría un blog abierto a comentarios o un editor de texto en producción. Aun así, cualquier parte interactiva que lea datos en cliente merece la misma pregunta. ¿Este valor acaba en un sitio que interpreta HTML?
CSP limita daños, pero no sustituye al código
En el artículo sobre cabeceras de seguridad que debería conocer un desarrollador web hablé de Content-Security-Policy.
Una CSP bien configurada puede dificultar que un script inyectado cargue recursos o se ejecute. Es una capa valiosa, sobre todo cuando la política se ajusta a lo que realmente usa la web.
Pero no arregla la causa de un XSS. Una cabecera no convierte en segura una inserción de datos sin cuidado en el DOM.
Lo primero sigue siendo evitar que datos no confiables acaben en APIs que los interpretan como HTML o código. Después tiene sentido añadir CSP, cookies con HttpOnly cuando corresponda y el resto de medidas que limitan el daño si algo falla.
Lo que intento revisar cuando toco contenido dinámico
Cambiar una línea de código no resuelve XSS en toda una aplicación. La revisión tiene que seguir el recorrido del dato y el contexto en el que se utiliza.
Cuando trabajo con una interfaz que pinta datos dinámicos, estas preguntas me ayudan a no ir por inercia.
- ¿De dónde sale este valor realmente?
- ¿Quiero mostrar texto o permitir HTML?
- ¿Hay una API del DOM que conserve el valor como texto?
- Si es una URL, ¿qué protocolos o dominios tienen sentido?
- ¿Estoy usando una salida de emergencia del framework? ¿Por qué?
- Si permito HTML, ¿dónde se sanea y quién mantiene esa configuración?
Son preguntas sencillas, pero cambian la revisión. En vez de pensar “esto solo es una tarjeta” o “esto solo es un mensaje”, miro el recorrido completo del dato hasta que llega al navegador.
Por qué quiero seguir aprendiendo esto
Una parte de construir productos útiles es no limitarse a que funcionen en el caso ideal.
Cuando una aplicación empieza con una búsqueda, una preferencia guardada o un pequeño formulario, parece que el frontend solo tiene que pintar información. Luego llegan usuarios, contenido externo, sesiones, integraciones y decisiones que ya no son tan inocentes.
XSS me interesa porque conecta seguridad con una habilidad muy cercana al frontend, representar información en pantalla. Sigo aprendiendo a entender mejor las APIs que uso, revisar decisiones que antes habría dado por hechas y no convertir un dato en código por comodidad.
Ese es el tipo de aprendizaje que quiero documentar mientras estudio ciberseguridad y sigo construyendo proyectos.