Content Security Policy (CSP): guía práctica, ejemplos y mejores prácticas

Autor: Jeffry Chaves, Ing. en Sistemas – Diccionario Informático

Qué es CSP y por qué importa en 2025

Content Security Policy (CSP) es una política que tu navegador obedece para decidir qué recursos puede cargar y ejecutar tu sitio (scripts, estilos, imágenes, iframes, conexiones, etc.). Su objetivo es reducir superficies de ataque, especialmente XSS y inyecciones que provienen de contenido no confiable. Se aplica principalmente con una cabecera HTTP (Content-Security-Policy) o, en casos puntuales, con una etiqueta <meta http-equiv="Content-Security-Policy"> en el HTML.

¿Por qué te debe importar hoy?

  • Los stacks modernos combinan bibliotecas de terceros, etiquetas de marketing y iframes: todo eso es potencialmente peligroso si no está bajo control.

  • CSP añade una última línea de defensa: aunque haya un bug que permita inyectar HTML, el navegador no ejecutará el script si la política lo prohíbe.

  • Permite modo “report-only” para observar violaciones antes de hacer cumplir las reglas.

En el ejemplo de LunaShop, al activar CSP en modo “report-only” aparecieron decenas de violaciones por onclick inline y por scripts que el chat de atención al cliente inyectaba dinámicamente. Eso nos mostró dónde endurecer sin romper la tienda.


Cómo funciona una política CSP: directivas clave explicadas

Una política CSP se compone de directivas. Cada directiva controla un tipo de recurso y recibe una lista de fuentes permitidas (origins, esquemas, palabras clave especiales).

Directivas imprescindibles

  • default-src: valor por defecto para todo lo que no tenga una directiva específica.

  • script-src: fuentes de scripts. Aquí vivirán nonces, hashes, y palabras clave como 'unsafe-inline' (evítala) o 'strict-dynamic'.

  • style-src: fuentes de CSS (cuidado con 'unsafe-inline'; mejor hashes para estilos críticos).

  • img-src: imágenes (incluye data URIs si las usas: data:).

  • connect-src: XHR/fetch/WebSocket/EventSource. Clave para APIs y analytics.

  • frame-ancestors: quién puede embeber tu sitio en un iframe (anti-clickjacking).

  • base-uri: restringe la etiqueta <base>.

  • object-src: debe ser 'none' en 2025 (plugins legacy).

  • Otras comunes: font-src, media-src, frame-src (o child-src en políticas antiguas), manifest-src, worker-src.

Palabras clave y esquemas útiles

  • 'self', 'none', https:, data:, blob:.

  • Nonces: 'nonce-r4nd0m...' generados por el servidor en cada respuesta.

  • Hashes: 'sha256-...', 'sha384-...', etc. sobre el contenido exacto del script/estilo.

  • 'strict-dynamic': si un script con nonce o hash válido añade más scripts, esos heredan confianza (evita listas interminables de orígenes).

En LunaShop, object-src 'none' eliminó advertencias de un viejo widget Flash olvidado en una landing histórica.


Strict CSP con nonces/hashes (y strict-dynamic): la estrategia recomendada

Las allowlists de hosts (script-src https://cdn1.com https://cdn2.com …) no escalan y no detienen inyecciones si un host confiable se compromete. La estrategia moderna es:

  1. Eliminar inline inseguro: no uses onclick=… ni eval(). Migra a event listeners y APIs seguras.

  2. Aplicar nonces o hashes a cada script/estilo que controles:

    • Nonces: el servidor genera un valor aleatorio por respuesta (por ejemplo, en Nginx/Node) y lo inserta en la cabecera y en los <script nonce="...">.

    • Hashes: útiles para bloques inline estáticos (el hash debe corresponder al contenido exacto).

  3. Añadir 'strict-dynamic' en script-src para que scripts con nonce/hash puedan cargar dependencias sin abrir la política.

  4. Cerrar puertas: object-src 'none'; base-uri 'none'; y, si no usas eval, evita 'unsafe-eval'.

Ejemplo de cabecera (enforcement)

Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self' 'sha256-…';
img-src 'self' data:;
connect-src 'self' https://api.payment.example https://analytics.example;
frame-ancestors 'none';
base-uri 'none';
object-src 'none';

En el caso ficticio, cuando el chat externo empezó a inyectar un loader adicional, 'strict-dynamic' lo permitió solo porque el script padre llevaba nonce válido. Si el proveedor intentaba cargar desde otro dominio no previsto sin pasar por el script con nonce, el navegador lo bloqueaba.


Despliegue seguro: de report-only a enforcement sin romper nada

El gran miedo con CSP es “¿y si bloqueo producción?”. Por eso existe Content-Security-Policy-Report-Only:

  1. Activa Report-Only: misma política pero sin bloqueo; el navegador reporta violaciones a tu endpoint.

  2. Centraliza los reportes: usa Reporting API (report-to / Reporting-Endpoints) o, por compatibilidad, report-uri si aún lo necesitas.

  3. Agrupa y filtra: detecta patrones (misma directiva, mismo origen bloqueado) para priorizar arreglos.

  4. Arregla en código: migra inline handlers, añade nonces/hashes, declara orígenes necesarios en connect-src, etc.

  5. Pasa a enforcement gradualmente: primero a una parte del tráfico (canary), luego al 100%.

Ejemplo (report-only)

Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self';
report-to csp-endpoint; report-uri https://reports.example/csp
Reporting-Endpoints: csp-endpoint="https://reports.example/csp"

En LunaShop, 2 semanas de report-only fueron suficientes: el 80% de las violaciones venía de onclick históricos. Tras migrarlos, el paso a enforcement no rompió el checkout.


Recetas rápidas de CSP para casos comunes

Starter policy segura

default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';

Permitir Google Analytics (gtag/analytics.js) sin unsafe-inline

script-src 'nonce-{RANDOM}' 'strict-dynamic' https://www.googletagmanager.com https://www.google-analytics.com;
connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com;
img-src 'self' https://www.google-analytics.com data:;

Google Fonts (CSS + font files)

style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;

Iframe de pasarela de pago (ejemplo genérico)

frame-src https://secure.payment.example;
connect-src 'self' https://api.payment.example;

Tip del ejemplo: para etiquetas de marketing impredecibles, en LunaShop opté por cargar todo marketing a través de un único script con nonce que administro yo; ese script decide qué proveedores activar, reduciendo variabilidad.


Depuración: errores típicos en consola y cómo resolverlos

Mensaje/violaciónDirectiva implicadaCausa comúnSolución práctica
Refused to execute inline scriptscript-srconclick, <script>…</script> inlineMigrar a listeners; usar hash si es 100% estático; mejor nonce en <script>
Refused to load the script from ‘https://x’script-srcCDN/tercero no declaradoAñadir origen a script-src o cargar a través de tu script con nonce + 'strict-dynamic'
Refused to connect to ‘https://api.x’connect-srcFetch/XHR a API externaDeclarar el dominio en connect-src
Refused to load the imageimg-srcPíxel/analytics o data: no permitidoAñadir dominio o data: a img-src
Blocked by frame-ancestorsframe-ancestorsIntentan embeberteDefine quién puede (o 'none')
base-uri violatedbase-uriUso de <base>Fija base-uri 'none' o al dominio que corresponda

En mi “sandbox” de LunaShop, un error insistente de eval venía de una lib legacy. Se resolvió cambiando la lib por una versión que no usaba new Function() y manteniendo fuera 'unsafe-eval'.


Caso práctico (ficticio): un e-commerce pasando a Strict CSP

Contexto

  • Stack: Next.js (SSR), Nginx, CDN.

  • Terceros: Analytics, chat de soporte, mapa de tienda física, pasarela de pago embebida.

Pasos aplicados

  1. Habilitar report-only con script-src 'nonce-…' 'strict-dynamic'.

  2. Instrumentar Reporting API hacia /csp-reports.

  3. Corregir violaciones: eliminar onclick, mover estilos críticos a archivos, añadir hash a un snippet estático.

  4. Canary de enforcement en el 10% del tráfico; luego 100%.

Resultados (del ejemplo)

  • Violaciones por minuto: −86% tras 1 semana.

  • Eliminación total de 'unsafe-inline' y 'unsafe-eval'.

  • Checkout y chat funcionando bajo la nueva política.

Snippet Nginx (ilustrativo)

set $nonce ""; # genera tu nonce en app y pásalo aquí
add_header Content-Security-Policy
"default-src 'self'; script-src 'nonce-$nonce' 'strict-dynamic'; style-src 'self';
img-src 'self' data:; connect-src 'self' https://api.payment.example https://analytics.example;
frame-ancestors 'none'; base-uri 'none'; object-src 'none';" always;

Moraleja del caso: nonces + strict-dynamic te permiten controlar el cómo se cargan scripts, no solo desde dónde.


Checklist final y FAQs de CSP

Checklist

  • Empezar con report-only y endpoint de reportes.

  • Quitar onclick/inline; evitar eval.

  • Añadir nonces a todos los scripts propios y hashes solo si el contenido es fijo.

  • Incluir 'strict-dynamic' en script-src.

  • Cerrar object-src 'none' y base-uri 'none'.

  • Declarar orígenes mínimos en connect-src, img-src, font-src, frame-src.

  • Canary → enforcement total.

  • Monitorizar reportes y revisar en releases futuros.

FAQs

  • ¿Puedo usar solo allowlists de dominios? Sí, pero no es lo ideal: no te protegen ante inyección si un dominio permitido sirve contenido malicioso.

  • ¿Nonce o hash? Nonce para scripts generados dinámicamente o externos controlados; hash para bloques inline estáticos.

  • ¿Qué rompe más a menudo? Handlers inline y librerías que usan eval. Empieza refactorizando eso.

  • ¿Meta tag o cabecera HTTP? Cabecera HTTP es preferible; la meta puede llegar tarde en algunos escenarios y no cubre todo.


Conclusión

CSP bien aplicada no es una lista de hosts, es una estrategia: el servidor autoriza (nonce/hash), el navegador aplica (strict-dynamic) y los reportes te guían. Si avanzas con report-only, arreglas lo obvio y pasas a enforcement con canary, obtendrás una política robusta y mantenible sin sustos en producción.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *