StackPractices
advanced Por Mathias Paulenko

Content Security Policy: cabeceras CSP, nonces, hashes

Desplegá Content Security Policy sin romper tu sitio: directivas CSP, nonces, hashes, strict-dynamic, reportes e integración por framework.

Introducción

Un comentario sin escapar, un innerHTML envenenado, un script de tag manager comprometido: eso es todo lo que hace falta para que JavaScript inyectado empiece a leer tokens de sesión y mandarlos a otra parte. Para el momento en que tu escapado de salida falla existe Content Security Policy (CSP): una lista de permitidos que el navegador hace cumplir y que le dice al cliente exactamente qué orígenes pueden ejecutar scripts, cargar estilos, abrir conexiones o embeber frames — así un atacante que logra inyectar markup se encuentra con un navegador que se niega a ejecutarlo.

La sanitización sigue siendo tu primera defensa; CSP es el muro de atrás, para limitar el radio de daño de cualquier XSS que se cuele. Lo que sigue es el mecanismo completo — directivas, nonces, hashes, strict-dynamic, Trusted Types y reportes de violaciones — más la secuencia de despliegue en producción que instala una política estricta sin tirar el sitio abajo.

Cuándo usar CSP

Todo sitio que renderice HTML debería publicar una CSP. La pregunta casi nunca es si conviene sino qué tan estricta puede ser:

  • Toda app que renderice input de usuarios. Comentarios, perfiles, texto enriquecido, resultados de búsqueda. Un escape que se te escapa y CSP es lo que queda entre la inyección y la sesión del usuario.
  • Apps que cargan scripts de terceros. Analytics, widgets de chat, herramientas de A/B testing. CSP acota lo que un tercero comprometido puede hacer en tu origen.
  • Paneles de administración y áreas autenticadas. Ahí viven las sesiones más valiosas y son las más atacadas; default-src 'none' más excepciones explícitas es realista en esas páginas.
  • SPAs y sitios estáticos. No necesitás un servidor para mandar headers — una etiqueta <meta> cubre la mayor parte, con las salvedades que detallo más abajo.

Dónde cambia la cuenta:

  • Un sitio totalmente estático y sin scripts inline puede ir directo a script-src 'self' — es la política fuerte más barata que existe.
  • Apps heredadas llenas de handlers inline no pueden saltar a una política de nonces sin refactor. Empezá con report-only, medí, y después decidí entre migrar a scripts externos o convivir con 'unsafe-hashes' en los atributos que no puedas mover.
  • CSP no reemplaza el escapado de salida. Una política con 'unsafe-inline' en script-src no frena nada. Y CSP no arregla DOM clobbering, bugs de lógica ni la exfiltración de datos por canales permitidos como img-src https: — tomalo como un filtro sobre ejecución y carga, no como una garantía.

Cómo funciona CSP

Tu servidor emite una cabecera Content-Security-Policy junto a la respuesta. El navegador la parsea antes de ejecutar cualquier cosa en la página y después evalúa contra ella cada carga de recurso y cada ejecución inline:

flowchart diagram: El navegador pide la página

Dos detalles importan más de lo que sugiere el diagrama. Primero, la aplicación la hace el navegador, así que CSP protege al usuario final aunque el atacante controle por completo el markup inyectado — la política nunca queda bajo su control. Segundo, bloquear y reportar son cosas independientes: una política enviada como Content-Security-Policy-Report-Only reporta violaciones sin bloquear nada, que es la forma segura de probar una política antes de hacerla cumplir.

Directivas CSP

Las directivas fetch controlan la carga de recursos; las directivas de documento y navegación controlan lo que la página misma puede hacer:

DirectivaControla
default-srcFallback para todas las directivas fetch
script-srcOrígenes de JavaScript y scripts inline
script-src-elemSolo elementos <script> y requests
script-src-attrSolo handlers inline (onclick, onload)
style-srcOrígenes de CSS y estilos inline
style-src-elem / style-src-attr<style>/<link> vs atributos style=""
img-srcOrígenes de imágenes
font-srcOrígenes de fuentes
connect-srcDestinos de fetch, XMLHttpRequest, WebSocket, EventSource
worker-srcWorkers — cae a child-src y después a script-src
frame-srcOrígenes para contextos de navegación anidados
object-srcPlugins — ponelo en 'none'
media-srcOrígenes de audio y video
manifest-srcWeb app manifest
child-srcLegado; reemplazada por frame-src + worker-src
base-uriURIs permitidas en la etiqueta <base>
form-actionDestinos permitidos de envío de formularios
frame-ancestorsQuién puede embeber esta página en un frame
sandboxAplica sandboxing tipo iframe a la página
require-trusted-types-forFuerza Trusted Types en los sinks del DOM
trusted-typesLista de nombres de políticas Trusted Types permitidas
report-uriDestino legado de reportes de violación
report-toGrupo de endpoints de la Reporting API
report-sampleIncluye una muestra de código en los reportes
upgrade-insecure-requestsSube automáticamente URLs HTTP de subrecursos a HTTPS

Algunas trampas: script-src-elem y script-src-attr caen a script-src cuando no están, así que podés separar reglas inline y externas sin duplicar la política. frame-ancestors y sandbox no hacen nada dentro de una etiqueta <meta>. Y report-uri está deprecada en la spec — conservala solo como fallback para navegadores viejos, junto a la Reporting API (más abajo).

Una política base

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  style-src 'self' 'unsafe-inline' https://cdn.example.com;
  img-src 'self' data: https:;
  font-src 'self' https://cdn.example.com;
  connect-src 'self' https://api.example.com wss://ws.example.com;
  frame-ancestors 'none';
  base-uri 'none';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests;

Fijate en lo que esto no permite: 'unsafe-inline' aparece solo en style-src, una concesión pragmática — la inyección de CSS es mucho menos peligrosa que la de scripts, y administrar un nonce para cada estilo inline rara vez rinde. script-src, en cambio, omite 'unsafe-inline' por completo — la única palabra clave que decide si la política frena XSS en absoluto.

CSP con nonces

Un nonce es un valor aleatorio generado por respuesta, estampado en la etiqueta <script> o <style> y repetido en la política. Solo se ejecutan los elementos que llevan el nonce correcto.

// middleware/cspNonce.ts — genera un nonce por request y escribe la cabecera
import crypto from 'crypto';

function cspNonceMiddleware(req: Request, res: Response, next: NextFunction) {
  const nonce = crypto.randomBytes(16).toString('base64');

  res.locals.nonce = nonce;

  res.setHeader('Content-Security-Policy', [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
    `style-src 'self' 'nonce-${nonce}' https://cdn.example.com`,
    `img-src 'self' data: https:`,
    `font-src 'self' https://cdn.example.com`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
    `base-uri 'none'`,
    `form-action 'self'`,
    `object-src 'none'`,
    `upgrade-insecure-requests`,
  ].join('; '));

  next();
}

app.use(cspNonceMiddleware);
<!-- Las etiquetas deben llevar el nonce correspondiente -->
<script nonce="<%= nonce %>">
  console.log('Este script inline está permitido');
</script>

<style nonce="<%= nonce %>">
  body { margin: 0; }
</style>

Tres reglas deciden si los nonces te protegen de verdad:

  1. Impredecible. Usá un CSPRNG (crypto.randomBytes), nunca un timestamp ni un contador. Un nonce adivinable es una entrada en la lista de permitidos para el atacante.
  2. Uno por respuesta. Un nonce servido dos veces — entre requests o entre usuarios — se convierte en un token reutilizable que el atacante solo necesita conocer una vez. Eso también significa que las páginas con nonce no pueden cachearse como HTML compartido — o el nonce tiene que variar con la salida cacheada.
  3. Nunca en markup que el atacante pueda leer temprano. Si el markup inyectado puede leer el DOM antes de que corran tus scripts, un payload astuto puede levantar el atributo nonce de una etiqueta legítima y reusarlo. 'strict-dynamic' (abajo) se diseñó en parte para cerrar ese agujero.

CSP con hashes

Un hash habilita un bloque inline por sus bytes exactos — sirve para snippets estáticos que nunca cambian, como configuración de arranque o un pequeño inicializador de tema.

# Hasheá los bytes exactos del contenido del script
echo -n "console.log('hello');" | openssl dgst -sha256 -binary | openssl base64 -A
# uYeF7eHzVgKpiBg5fikv2NTctmJnxCfX1UhhlrizvNE=
Content-Security-Policy:
  script-src 'self' 'sha256-uYeF7eHzVgKpiBg5fikv2NTctmJnxCfX1UhhlrizvNE=';
<!-- Solo se ejecuta si el contenido coincide byte por byte -->
<script>console.log('hello');</script>

Las trampas: el digest cubre los bytes exactos, así que cambiar un espacio o una comilla rompe el bloque en silencio. sha256, sha384 y sha512 funcionan todos, aunque sha256 sobra aquí. Ojo que los hashes cubren el contenido de <script> y <style> — para permitir handlers inline (onclick="...") o atributos style="" por hash necesitás 'unsafe-hashes' encima, que reabre la inyección por atributos y conviene evitar.

Elegí nonces para cualquier cosa renderizada por request; elegí hashes para snippets estáticos o plataformas donde no controlás las cabeceras por respuesta.

Strict-Dynamic

strict-dynamic extiende la confianza de un script con nonce o hash a los scripts que él carga, sin listarlos uno por uno en la política:

Content-Security-Policy:
  script-src 'nonce-abc123' 'strict-dynamic' 'self' https:;

Con strict-dynamic activo:

  • Los scripts con el nonce ejecutan, y los que crean en runtime (vía createElement, import() dinámico) heredan la confianza.
  • Los navegadores que lo entienden ignoran la lista de hosts — 'self' y https: quedan ahí solo para que los navegadores sin soporte de strict-dynamic caigan a una CSP común y burda.
  • La confianza no se propaga a markup insertado por el parser, así que un atacante que inyecta <script src> directo en el HTML sigue bloqueado. Ese es exactamente el punto: separa “los scripts que carga tu código confiable” de “los scripts que contiene el markup”.

Es la válvula de escape que hace vivible una CSP estricta para apps con loaders dinámicos, module federation o widgets de terceros que traen sus propias dependencias — ponés nonce al loader una vez en vez de mantener una lista de CDNs que se mueve.

Trusted Types

El hermano nuevo de CSP apunta al DOM XSS en concreto. require-trusted-types-for 'script' hace que el navegador rechace asignaciones de strings en sinks peligrosos — innerHTML, eval, script.src, document.write — salvo que el string haya pasado por una política de Trusted Types:

Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types default dompurify;
// Una política decide qué valores sanitizados pueden llegar a los sinks del DOM
const sanitizer = trustedTypes.createPolicy('dompurify', {
  createHTML: (dirty) => DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true }),
});

element.innerHTML = sanitizer.createHTML(userInput);   // permitido
element.innerHTML = userInput;                          // bloqueado

La directiva trusted-types restringe qué nombres de política pueden crearse, así que un atacante no puede acuñar su propio createHTML que devuelva el input intacto. La adopción todavía es dispareja entre stacks, pero en apps que procesan HTML no confiable en el navegador es el control más fuerte contra DOM XSS que existe — y funciona junto a la CSP de orígenes de script, no en su lugar.

Reportes de CSP

Reporting API (actual)

La Reporting API manda las violaciones como lotes JSON a un grupo de endpoints nombrado:

Reporting-Endpoints: csp-endpoint="https://example.com/api/csp-reports"
Content-Security-Policy: ...; report-to csp-endpoint; report-sample;

report-sample adjunta un snippet de ~40 caracteres del código infractor a cada reporte, lo que ahorra tiempo real cuando estás cazando qué bloque inline disparó una violación.

report-uri (fallback legado)

report-uri está deprecada en la spec de CSP pero sigue siendo el único mecanismo que respetan algunos navegadores viejos, y es lo que ingieren la mayoría de los colectores de reportes hosted (como report-uri.com):

Content-Security-Policy: ...;
  report-uri /api/csp-reports;
  report-to csp-endpoint;

Podés mandar ambas sin problema — cada navegador elige el mecanismo que soporta. Ojo que la cabecera Report-To en sí está deprecada a favor de Reporting-Endpoints; los despliegues nuevos deberían saltearla.

Endpoint de reportes

Los dos mecanismos envían payloads distintos: el endpoint legado recibe un objeto {"csp-report": {...}} por violación, mientras que los reportes de la Reporting API llegan en lote como un array de {"type": "csp-violation", "body": {...}}. Aceptá ambos:

// routes/cspReports.ts — acepta payloads legados y de la Reporting API
app.post('/api/csp-reports',
  express.json({ type: ['application/csp-report', 'application/reports+json'] }),
  (req, res) => {
    const payload = req.body;
    const reports = Array.isArray(payload)
      ? payload.map((r) => r.body)
      : [payload['csp-report']];

    for (const report of reports) {
      console.warn('CSP violation:', {
        documentUri: report['document-uri'],
        directive: report['effective-directive'] ?? report['violated-directive'],
        blockedUri: report['blocked-uri'],
        disposition: report.disposition,
        sample: report['script-sample'],
      });
    }

    res.status(204).end();
  });

Llevá los reportes a un stream de logs que alguien mire de verdad. Una CSP que nadie monitorea es una suposición, no una política.

CSP sin servidor (etiqueta meta)

El hosting estático que no puede escribir cabeceras de respuesta — GitHub Pages, S3 pelado, algunos CDNs — igual puede publicar una política a través del markup:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'none';">

Tres limitaciones a saber: frame-ancestors, sandbox y las directivas de reportes se ignoran dentro de <meta> (necesitan una cabecera real), la política no puede variar por request así que los nonces quedan afuera (los hashes sí funcionan), y una segunda <meta> CSP más abajo en el documento solo puede endurecer la política — útil para reforzar páginas puntuales.

Despliegue en producción

Paso 1: Inventariá lo que carga la página

# Chrome DevTools → Network: filtrá por dominio y anotá cada origen
# Lighthouse → Best Practices marca CSP ausente o débil
# Más rápido: corré el CSP Evaluator de Google sobre una cabecera
# existente, o el generador de report-uri.com para bosquejar una

Listá cada dominio externo de scripts, estilos, imágenes, fuentes, llamadas a APIs, WebSockets y frames. La mayoría de los fallos de CSP vienen de un origen olvidado descubierto en producción, no de una directiva mal escrita.

Paso 2: Desplegá en report-only

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  res.setHeader('Content-Security-Policy-Report-Only', [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https:`,
    `font-src 'self' https://cdn.example.com`,
    `connect-src 'self' https://api.example.com wss://ws.example.com`,
    `frame-ancestors 'none'`,
    `base-uri 'none'`,
    `form-action 'self'`,
    `report-uri /api/csp-reports`,
    `report-to csp-endpoint`,
  ].join('; '));

  next();
});

Paso 3: Corregí lo que muestren los reportes

Violaciones comunes y sus arreglos:

1. Script inline sin nonce
   → Ponele nonce a la etiqueta o movelo a un archivo externo

2. Estilo inline sin nonce
   → Ponele nonce, movelo, o aceptá 'unsafe-inline' solo en style-src

3. Llamada a eval()
   → Eliminala; 'unsafe-eval' es último recurso, no arreglo

4. Script de un dominio no listado
   → Agregalo a la lista, o cargalo a través de un loader con nonce y 'strict-dynamic'

5. WebSocket a un dominio no listado
   → Agregá el origen wss:// a connect-src

6. Imágenes data: o blob:
   → Agregá data: / blob: a img-src de forma deliberada, no global

7. Handler inline (onclick, onload)
   → Refactorizá a addEventListener — 'unsafe-hashes' existe pero debilita la política

Dejá report-only corriendo días, no horas — las páginas poco visitadas (checkout, admin, flujos de error) muestran violaciones tarde.

Paso 4: Pasá a enforcing

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}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https:`,
    `font-src 'self' https://cdn.example.com`,
    `connect-src 'self' https://api.example.com wss://ws.example.com`,
    `frame-ancestors 'none'`,
    `base-uri 'none'`,
    `form-action 'self'`,
    `object-src 'none'`,
    `upgrade-insecure-requests`,
  ].join('; '));

  next();
});

Dejá las directivas de reporte cuando pases a enforcing. Las violaciones posteriores son señal: o un intento de ataque real o un recurso que se te pasó.

Integración con frameworks

Express con helmet

El middleware de CSP de helmet construye la cabecera desde un objeto de directivas, incluidas funciones de nonce:

import helmet from 'helmet';

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"],
    styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
    imgSrc: ["'self'", 'data:', 'https:'],
    fontSrc: ["'self'", 'https://cdn.example.com'],
    connectSrc: ["'self'", 'https://api.example.com', 'wss://ws.example.com'],
    frameAncestors: ["'none'"],
    baseUri: ["'none'"],
    formAction: ["'self'"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

El array vacío en upgradeInsecureRequests es intencional — es como helmet emite una directiva sin valor.

Next.js (App Router)

next.config.js se evalúa en build time — un nonce no puede vivir ahí. Según la documentación de Next.js, el nonce se genera en el middleware, se reenvía por una cabecera de request y lo leen los Server Components:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64');
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https:`,
    `font-src 'self'`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ');

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  requestHeaders.set('Content-Security-Policy', csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}
// app/layout.tsx — leé el nonce y pasalo a los scripts
import { headers } from 'next/headers';
import Script from 'next/script';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce');
  return (
    <html lang="es">
      <body>
        {children}
        <Script src="https://cdn.example.com/app.js" nonce={nonce ?? undefined} strategy="beforeInteractive" />
      </body>
    </html>
  );
}

Leer headers() convierte la ruta en dinámica, que es el trade-off que los nonces imponen en todos lados — no hay forma de tener nonces por request y caché estático completo en la misma respuesta.

Bypasses conocidos y límites

Cada entrada que permitís es un agujero que la política deja abierto. Los puntos débiles aparecen acá:

  • Gadgets JSONP y AngularJS en dominios permitidos. Una librería hosted con un endpoint JSONP (cdn.example.com/jsonp?callback=) o una versión vieja de AngularJS puede abusarse para ejecutar callbacks elegidos por el atacante — la razón por la que la investigación de CSP de Google empujó strict-dynamic por encima de las listas de dominios.
  • Robo de nonces. Markup inyectado que corre antes que tus scripts a veces puede leer los atributos nonce de etiquetas legítimas y clonarlos. strict-dynamic más parser-blocking lo hace mucho más difícil.
  • Exfiltración por canales permitidos. img-src https: permite requests a cualquier host HTTPS — un atacante que no puede correr JS igual puede filtrar datos con un <img> o CSS inyectado. Endurecé img-src y connect-src a orígenes nombrados donde puedas.
  • Inyección de <base>. Sin base-uri 'none', un solo <base href> inyectado resuelve cada URL relativa contra el host del atacante — tus propias etiquetas de script incluidas.
  • Lo que CSP no puede ver. DOM clobbering, fallas de lógica, prototype pollution a través de scripts permitidos y cualquier cosa que un script de terceros permitido decida hacer. CSP achica la superficie de ataque; no va a auditar lo que entre por las puertas que abriste.

Buenas prácticas

  • Empezá en modo report-only y dejá que recolecte violaciones antes de enforzar nada.
  • Preferí nonces para renderizado por request y hashes para bloques inline estáticos; elegí un mecanismo y sé consistente.
  • Usá strict-dynamic apenas tus scripts carguen sus propias dependencias — elimina la lista de dominios que es un blanco móvil.
  • Cerrá frame-ancestors 'none' contra clickjacking, base-uri 'none' contra inyección de <base>, object-src 'none' contra plugins y form-action 'self' contra redirección de formularios.
  • Activá upgrade-insecure-requests para que las referencias de contenido mixto no puedan degradar la página.
  • Monitoreá los reportes con alertas, no solo con logging — un pico de violaciones después de un deploy suele ser una feature rota, no un ataque.
  • Volvé a correr un evaluador de políticas (CSP Evaluator de Google, Mozilla Observatory) después de cada cambio significativo en la cabecera.

Errores comunes

  • 'unsafe-inline' en script-src. Con ella presente la política no frena nada. Si una página heredada no puede vivir sin ella, acotá la regla a esa página, no al sitio.
  • Enforzar antes de medir. Una política estricta desplegada en frío rompe producción. Report-only existe justamente para la fase de descubrimiento — usalo.
  • Reusar o predecir nonces. Un nonce sacado de Date.now() o compartido entre respuestas le da al atacante una entrada válida en la lista de permitidos. CSPRNG, una por respuesta, nunca cacheado.
  • Permitir CDNs enteros. script-src https://cdn.example.com confía en cada librería y endpoint JSONP de ese host, incluidas las viejas con gadgets de bypass conocidos.
  • Olvidar connect-src. El primer fetch a una API no listada falla en runtime y los usuarios reportan una app rota, no una violación de CSP.
  • Ignorar los reportes. Un endpoint de reportes que nadie lee convierte la CSP en teatro — aplicaste algo y nunca verificaste si funciona.

Lecturas adicionales

Preguntas frecuentes

¿Qué es Content Security Policy?

Una cabecera de respuesta (o política <meta>) que le dice al navegador qué orígenes pueden cargar y ejecutar cada tipo de recurso — scripts, estilos, imágenes, conexiones, frames. Cuando markup inyectado intenta correr un script no autorizado, el navegador lo bloquea antes de ejecutarlo, que es lo que convierte a CSP en la segunda línea de defensa más fuerte contra XSS.

¿Nonce o hash: cuál usar?

Los nonces son valores aleatorios por respuesta para páginas dinámicas; los hashes son digests de contenido exacto para snippets inline estáticos. Los nonces necesitan generación del lado del servidor en cada respuesta — lo que descarta el caché completo del HTML — mientras que los hashes se calculan en build time y pueden servirse en CDN. Si tu página se renderiza por request, usá nonces; si es estática, usá hashes o mové el código a externo.

¿strict-dynamic vuelve inútil mi lista de permitidos?

Sí, en todo navegador que lo entiende — es el trade intencional. strict-dynamic hace que el navegador ignore las fuentes por host ('self', dominios, esquemas) y confíe en lo que carguen tus scripts con nonce. Los navegadores sin soporte caen a aplicar la lista como una CSP común, así que obtenés enforcement fuerte donde cuenta y comportamiento degradado pero funcional en el resto.

¿CSP puede frenar todo el XSS?

No. Frena la ejecución de scripts desde orígenes no autorizados — nada más. Una política con 'unsafe-inline' en script-src no frena nada en absoluto; los scripts de terceros permitidos igual pueden portarse mal; los datos pueden seguir filtrándose por canales permitidos de img-src o connect-src; y el DOM clobbering o los bugs de lógica quedan fuera de su alcance. Tomá CSP como reducción del radio de daño encima del escapado y la sanitización, no como el arreglo principal.

¿Puedo usar CSP en un sitio estático?

Sí, con <meta http-equiv="Content-Security-Policy">. Perdés frame-ancestors, sandbox y los reportes (todos solo por cabecera), y no podés usar nonces por request — pero script-src 'self' más hashes cubre la mayoría de los sitios estáticos, que rara vez necesitan scripts inline de todos modos.

¿Cómo debuggeo una CSP que rompe la página?

Desplegala como Content-Security-Policy-Report-Only con report-sample activado y leé las violaciones — cada reporte nombra la directiva, la URL bloqueada y un snippet del código infractor. Chrome DevTools también loguea en consola cada recurso bloqueado con la directiva que lo mató.