⏱️ Lectura: 16 min
Un atacante inyecta un <script> en un campo de comentarios y el navegador lo ejecuta exactamente igual que si lo hubiera escrito el propio sitio: así funciona un ataque XSS clásico. Content Security Policy es la cabecera HTTP que le dice al navegador, con una lista explícita, de qué orígenes puede cargar scripts, estilos e imágenes, y bloquea todo lo demás antes de que se ejecute.
📑 En este artículo
- TL;DR
- Qué es Content Security Policy y por qué importa
- Cómo funciona Content Security Policy por dentro
- Ejemplos prácticos: de la política mínima a una real
- Cómo empezar: pasos concretos para activar CSP
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa: CSP frente a otras defensas contra XSS
- Profundizando: nonces, hashes y strict-dynamic
- Preguntas frecuentes
- ¿Qué diferencia hay entre Content-Security-Policy y Content-Security-Policy-Report-Only?
- ¿CSP reemplaza la sanitización de HTML?
- ¿Funciona Content Security Policy en todos los navegadores?
- ¿Puedo usar CSP solo con una etiqueta meta, sin tocar el servidor?
- ¿Qué es ‘strict-dynamic’ y cuándo conviene usarlo?
- ¿CSP protege contra ataques CSRF?
- Referencias
No reemplaza sanitizar el HTML del lado del servidor, pero funciona como una segunda barrera: si algo se filtra igual, CSP puede evitar que ese código llegue a correr en el navegador de la víctima.
TL;DR
- Vas a entender cómo la cabecera Content-Security-Policy bloquea scripts no autorizados antes de que se ejecuten.
- Vas a poder escribir una política CSP funcional con default-src, script-src y style-src en minutos.
- Vas a distinguir entre el modo que bloquea (Content-Security-Policy) y el que solo reporta (Content-Security-Policy-Report-Only).
- Vas a usar nonces y hashes para permitir scripts inline sin abrir la puerta al XSS.
- Vas a saber diagnosticar una política rota leyendo los errores en la consola del navegador.
- Vas a conocer qué directivas reemplazan a cabeceras obsoletas como X-XSS-Protection.
- Vas a identificar los errores más comunes que rompen un sitio entero al activar CSP por primera vez.
Qué es Content Security Policy y por qué importa
Content Security Policy (CSP) es un estándar del W3C que se implementa como una cabecera HTTP de respuesta. El servidor la envía en cada página y el navegador la usa como una lista blanca: solo se cargan scripts, hojas de estilo, imágenes, fuentes o conexiones que vengan de los orígenes permitidos explícitamente.
La motivación central es el cross-site scripting (XSS): un ataque donde código malicioso se inyecta en una página confiable, ya sea a través de un campo de formulario mal sanitizado, un parámetro de URL reflejado o una dependencia de npm comprometida. Sin CSP, cualquier script que termine en el HTML se ejecuta con los mismos privilegios que el código legítimo del sitio: puede leer cookies, capturar contraseñas de formularios o redirigir al usuario.
Con una política activa, ese mismo script inyectado se bloquea si su origen no está en la lista permitida. El navegador no lo descarga, no lo ejecuta y, si configuraste un endpoint de reportes, te avisa que ocurrió.
Es importante entender qué no hace Content Security Policy: no sanitiza el HTML, no valida inputs y no reemplaza el escapado de datos en el servidor o el framework. Es una capa adicional que limita el daño cuando la primera línea de defensa falla.
Cómo funciona Content Security Policy por dentro
Una política CSP se compone de directivas separadas por punto y coma. Cada directiva controla un tipo de recurso: script-src para JavaScript, style-src para CSS, img-src para imágenes, connect-src para fetch y WebSocket, font-src para tipografías. Si una directiva específica no está definida, el navegador cae a default-src como respaldo.
Cada valor dentro de una directiva es una fuente permitida: puede ser una palabra clave como 'self' (el mismo origen), un dominio explícito como https://cdn.ejemplo.com, o un esquema como data:. También existen palabras clave más finas, como 'nonce-xxxx' o 'sha256-xxxx', que permiten un bloque de script concreto sin abrir todo el origen.
El navegador evalúa esta lista en cada intento de carga de recurso, no una sola vez al inicio de la página. Si un script se inserta dinámicamente después de la carga inicial, por ejemplo vía innerHTML, también pasa por el mismo chequeo.
Vale aclarar la diferencia entre report-uri y report-to: el primero es el mecanismo original, hoy marcado como obsoleto pero todavía ampliamente soportado; el segundo forma parte de la Reporting API moderna y permite agrupar varios tipos de reportes (CSP, cabeceras de permisos, errores de red) en un mismo endpoint. Muchos equipos configuran ambos en paralelo durante la transición.
Otro detalle que sorprende la primera vez: script-src también controla si el código puede usar eval() o el constructor Function(). Por defecto, sin la palabra clave 'unsafe-eval', cualquier llamada a eval lanza una excepción en la consola, aunque el script en sí venga de un origen permitido.
flowchart TD
A["Navegador solicita la pagina"] --> B["Servidor responde con header CSP"]
B --> C["Navegador parsea la politica"]
C --> D{"Recurso permitido por script-src?"}
D -->|"Si"| E["El script se ejecuta"]
D -->|"No"| F["El script se bloquea y se reporta"]
Cuando un recurso se bloquea, el navegador dispara el evento SecurityPolicyViolationEvent en JavaScript y, si configuraste un endpoint de reportes, envía un JSON con el detalle a tu servidor. Ese reporte incluye el documento donde ocurrió, la directiva violada y la fuente bloqueada.
sequenceDiagram
participant A as Atacante
participant N as Navegador
participant S as Servidor
A->>N: inyecta un script en un campo de comentarios
N->>S: solicita la pagina que incluye el comentario
S-->>N: responde HTML mas header Content-Security-Policy
N->>N: compara el origen del script contra script-src
Note over N: el origen inyectado no esta en la lista permitida
N-->>A: el script no se ejecuta, se registra la violacion
Ejemplos prácticos: de la política mínima a una real
El ejemplo más simple posible es restringir todo al mismo origen. Este es el punto de partida típico antes de ir afinando directiva por directiva:
Content-Security-Policy: default-src 'self'
Esta única línea le dice al navegador que scripts, estilos, imágenes y todo lo demás solo pueden venir del propio dominio. Cualquier script de un CDN externo, cualquier iframe de un tercero y cualquier <script> inline se bloquean de inmediato.
En la práctica casi ningún sitio real puede quedarse con una sola directiva: hay analytics de terceros, fuentes de Google Fonts, imágenes en base64 y llamadas a una API en otro subdominio. Una política más realista, generada dinámicamente en un middleware de Express, se ve así:
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
`script-src 'self' 'nonce-${nonce}'; ` +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.ejemplo.com; " +
"font-src 'self' https://fonts.gstatic.com"
);
next();
});
Este middleware genera un nonce aleatorio distinto en cada request y lo inyecta tanto en la cabecera como en cada etiqueta <script nonce="..."> del HTML. Un atacante que logre inyectar un script no puede adivinar ese valor, así que su código queda fuera de la lista permitida aunque el resto de la política sea permisiva en otros aspectos.
Cada directiva de este bloque cumple un rol distinto: script-src solo permite JavaScript del propio origen más el bloque marcado con el nonce del request actual; style-src deja pasar CSS inline porque muchos frameworks de UI inyectan estilos dinámicamente y migrar eso a nonces exige más trabajo; img-src permite imágenes propias, en base64 y de cualquier origen HTTPS, útil cuando el contenido lo suben usuarios; connect-src limita a qué dominios puede llamar fetch o XMLHttpRequest, clave para evitar exfiltración de datos si un script logra ejecutarse igual.
Cómo empezar: pasos concretos para activar CSP
Activar una política en modo bloqueo directamente en producción, sin haberla probado antes, suele romper el sitio. El camino seguro tiene cuatro pasos.
Paso 1: auditar qué carga tu sitio hoy. Abrí las herramientas de desarrollador, pestaña Network, y filtrá por JS, CSS, Img y Fetch/XHR. Anotá todos los dominios externos que aparecen: CDNs, analytics, fuentes, widgets de terceros.
Paso 2: desplegar en modo Report-Only. Este modo no bloquea nada, solo registra qué se hubiera bloqueado. Es la forma de medir el impacto real antes de romper algo:
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint
Para que los reportes lleguen a algún lado, hace falta declarar el endpoint con la Reporting API:
Reporting-Endpoints: csp-endpoint="https://tu-sitio.com/csp-reports"
Paso 3: revisar los reportes durante unos días. Cada violación llega como JSON a tu endpoint con el campo blocked-uri (qué se bloqueó) y violated-directive (qué regla lo bloqueó). Con eso vas ajustando la política, agregando los orígenes legítimos que faltan.
Paso 4: pasar a modo bloqueo. Cuando los reportes se reduzcan a cero durante varios días seguidos, cambiá la cabecera de Content-Security-Policy-Report-Only a Content-Security-Policy. A partir de ahí, cualquier cosa fuera de la lista se bloquea de verdad.
💡 Tip: empezá siempre con Content-Security-Policy-Report-Only para medir el impacto antes de bloquear nada en producción.
Para confirmar que la cabecera está activa en cualquier momento, alcanza con un curl a la página:
curl -I https://tu-sitio.com | grep -i content-security-policy
Si preferís revisarlo desde el navegador, la consola de DevTools filtra automáticamente los mensajes de violación con el texto “Content Security Policy” cuando algo se bloquea, y la pestaña Application muestra la cabecera cruda en cada respuesta.
Casos de uso reales
En checkouts de e-commerce, CSP limita qué scripts pueden tocar el formulario de tarjeta de crédito, lo que reduce el riesgo de ataques de skimming donde un script de terceros comprometido captura los datos de pago antes de que se envíen.
En paneles de administración internos, una política estricta con default-src 'none' como base y permisos mínimos agregados directiva por directiva reduce la superficie de ataque si un empleado abre un enlace malicioso mientras tiene sesión activa.
En sitios con contenido generado por usuarios (foros, comentarios, wikis), CSP es la defensa que queda cuando un filtro de sanitización de HTML tiene un bug y deja pasar una etiqueta que no debería.
En dashboards con widgets de terceros (mapas, reproductores de video, chats de soporte), CSP obliga a declarar explícitamente cada dominio embebido, lo que convierte una decisión que antes era invisible en una lista auditable dentro del propio código del servidor.
También sirve como red de seguridad frente a extensiones de navegador maliciosas o SDKs de publicidad comprometidos: si un script de un tercero intenta cargar código adicional desde un dominio no autorizado, la política lo bloquea sin importar de dónde vino la instrucción original.
Errores comunes y buenas prácticas
- ‘unsafe-inline’ en script-src: se agrega para que funcione rápido y anula gran parte de la protección contra XSS, porque vuelve a permitir cualquier script inline, inyectado o no.
- Olvidar connect-src: rompe silenciosamente cualquier llamada fetch o XHR hacia una API, incluso si es del mismo dominio pero en otro puerto o subdominio.
- Olvidar img-src data:: rompe imágenes embebidas en base64, comunes en editores de texto enriquecido y generadores de PDF.
- Reutilizar el mismo nonce en toda la sesión: anula su propósito. El nonce tiene que regenerarse en cada respuesta HTTP, no una sola vez por sesión de usuario.
- Desplegar directo en modo bloqueo: sin pasar antes por Report-Only, es la forma más rápida de romper un checkout de producción un viernes a la tarde.
- Bloquear extensiones del navegador: algunas extensiones inyectan sus propios scripts en la página, eso también puede aparecer en los reportes como falso positivo, sin que sea un problema de tu código.
⚠️ Ojo: ‘unsafe-inline’ en script-src anula buena parte de la protección contra XSS. Usalo solo como paso temporal mientras migrás a nonces o hashes.
Comparativa: CSP frente a otras defensas contra XSS
| Mecanismo | Qué hace | Cuándo usarlo | Limitación |
|---|---|---|---|
| Content Security Policy | Restringe desde qué orígenes puede cargar scripts, estilos e imágenes el navegador | Como capa adicional en cualquier sitio con contenido generado por usuarios | No sanitiza HTML; si el navegador no la soporta, no protege nada |
| Sanitización de salida | Escapa caracteres especiales antes de insertar datos del usuario en el HTML | Siempre, es la primera línea de defensa contra XSS | Un solo punto sin escapar en todo el código rompe la protección |
| Trusted Types | Obliga a pasar por una función validadora antes de asignar HTML dinámico al DOM | Apps con mucho DOM dinámico en JavaScript vanilla | Soporte limitado a navegadores basados en Chromium |
| X-XSS-Protection | Activaba el filtro XSS heurístico del navegador | Nunca: la cabecera está obsoleta | Deprecada; Chrome y Edge la eliminaron |
| Auto-escaping de frameworks | Escapa automáticamente cualquier valor interpolado en el template (React, Vue) | Por defecto en cualquier app moderna basada en componentes | Se rompe si el código usa dangerouslySetInnerHTML o v-html sin sanitizar |
Profundizando: nonces, hashes y strict-dynamic
Existen tres formas de permitir un script inline sin usar 'unsafe-inline'. La primera es el nonce: un valor aleatorio generado en cada respuesta del servidor que se agrega tanto a la cabecera como al atributo del script. La segunda es el hash: un sha256 del contenido exacto del script, útil cuando el script no cambia entre requests. La tercera es 'strict-dynamic', que le dice al navegador que confíe en cualquier script cargado por un script ya confiado (por nonce o hash), lo cual simplifica mucho las políticas de aplicaciones que cargan bundles dinámicamente.
La especificación CSP Level 3 del W3C agregó justamente 'strict-dynamic' y la directiva trusted-types, que va un paso más allá de restringir orígenes: obliga a que cualquier asignación a innerHTML, document.write o similares pase primero por una función validadora registrada en JavaScript. Sin una policy de Trusted Types, esas asignaciones lanzan una excepción en tiempo de ejecución.
Content Security Policy no debe confundirse con Permissions-Policy (antes Feature-Policy), que es una cabecera distinta y controla el acceso a APIs del navegador como la cámara, el micrófono o la geolocalización, no el origen de los recursos cargados. Son complementarias: una app moderna suele enviar ambas cabeceras a la vez.
Para equipos con muchos sitios, agregar los reportes de CSP de distintos dominios en un solo panel ayuda a detectar patrones: un mismo dominio bloqueado en decenas de sitios distintos suele ser una extensión de navegador ruidosa, no un ataque real, y eso ayuda a priorizar qué violaciones investigar primero.
💭 Clave: Trusted Types va un paso más allá de CSP: no solo restringe de dónde vienen los scripts, obliga a validar cualquier HTML antes de insertarlo en el DOM.
flowchart TD
A["default-src 'self'"] --> B{"script-src definido?"}
B -->|"No"| C["hereda default-src"]
B -->|"Si"| D["usa su propia lista"]
A --> E{"style-src definido?"}
E -->|"No"| F["hereda default-src"]
E -->|"Si"| G["usa su propia lista"]
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: agregá Content-Security-Policy-Report-Only a un endpoint de prueba de tu proyecto y revisá los reportes durante 24 horas antes de bloquear nada en producción.
Preguntas frecuentes
¿Qué diferencia hay entre Content-Security-Policy y Content-Security-Policy-Report-Only?
La primera bloquea activamente cualquier recurso que no cumpla la política. La segunda solo registra qué se hubiera bloqueado, sin afectar el funcionamiento del sitio; se usa para probar una política antes de activarla en modo bloqueo.
¿CSP reemplaza la sanitización de HTML?
No. CSP es una capa adicional de defensa en profundidad. La sanitización de entradas y el escapado de salida siguen siendo la primera línea de defensa contra XSS; CSP limita el daño si esa primera línea falla.
¿Funciona Content Security Policy en todos los navegadores?
Los navegadores modernos (Chrome, Firefox, Safari, Edge) soportan la mayoría de las directivas de CSP Level 2 y buena parte de Level 3. Directivas más nuevas como trusted-types tienen soporte más limitado, principalmente en navegadores basados en Chromium.
¿Puedo usar CSP solo con una etiqueta meta, sin tocar el servidor?
Sí, con <meta http-equiv="Content-Security-Policy" content="default-src 'self'"> en el <head>. La limitación es que algunas directivas, como frame-ancestors o report-to, solo funcionan con la cabecera HTTP real, no con la etiqueta meta.
¿Qué es ‘strict-dynamic’ y cuándo conviene usarlo?
Le dice al navegador que confíe en cualquier script cargado dinámicamente por un script ya autorizado por nonce o hash. Conviene en aplicaciones con bundlers modernos que cargan chunks de JavaScript en tiempo de ejecución, donde listar cada dominio a mano sería inmanejable.
¿CSP protege contra ataques CSRF?
No directamente. CSRF se previene con tokens anti-falsificación y la cabecera SameSite en las cookies. CSP sí puede ayudar de forma indirecta con directivas como form-action, que restringe a qué URLs puede enviarse un formulario.
Referencias
- MDN Web Docs, Content Security Policy: referencia completa de directivas, valores y compatibilidad de navegadores.
- W3C, Content Security Policy Level 3: especificación oficial del estándar, incluye strict-dynamic y trusted-types.
- OWASP, Content Security Policy Cheat Sheet: guía de buenas prácticas y errores comunes al implementar CSP.
- web.dev, Content Security Policy: tutorial de Google con ejemplos de despliegue progresivo.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Imagen destacada: Foto de Sasun Bughdaryan en Unsplash
0 Comentarios