CSP estricta con nonces en Express: guía práctica contra XSS

CSP estricta con nonces en Express y protección contra XSS

Una política de seguridad de contenido (CSP) puede reducir el impacto de muchos ataques XSS, pero copiar una lista enorme de dominios permitidos no convierte automáticamente una aplicación en segura. En una aplicación Express que renderiza HTML en el servidor, una estrategia más mantenible es generar un nonce por respuesta y autorizar únicamente los scripts que reciban ese valor.

Esta guía muestra cómo diseñar una CSP estricta sin romper la aplicación a ciegas. Verás cómo generar el nonce, pasarlo a la plantilla, probar primero en modo de reporte, migrar eventos inline y diagnosticar los bloqueos que suelen aparecer cuando hay analítica, widgets o scripts dinámicos.

Índice de Contenido 💻
  1. Qué protege una CSP y qué no corrige
  2. Nonce y hash: cuándo elegir cada uno
  3. Generar un nonce fuerte en Express
  4. Pasar el nonce a la plantilla
  5. Qué hace strict-dynamic
  6. Probar sin romper producción
  7. Migrar handlers inline
  8. Scripts de analítica y terceros
  9. Caché, HTML y nonces
  10. Diagnóstico rápido de bloqueos
  11. Checklist de producción
  12. Conclusión

Qué protege una CSP y qué no corrige

CSP es una capa que indica al navegador qué recursos puede cargar o ejecutar. Puede bloquear scripts inline no autorizados, usos peligrosos de eval(), objetos embebidos y marcos que intenten cargar tu página cuando no están permitidos. MDN explica que una política estricta basada en nonce o hash suele ser más manejable que una allowlist extensa.

La CSP no sustituye el escape de salida, la validación de entradas ni una arquitectura segura. Si tu servidor imprime datos del usuario dentro de un atributo o plantilla sin escapar, la política puede reducir el daño, pero la vulnerabilidad original sigue existiendo. Trátala como defensa adicional, no como permiso para dejar de corregir XSS.

Nonce y hash: cuándo elegir cada uno

Un nonce es un valor aleatorio que el servidor genera para una respuesta HTML. El mismo valor aparece en el encabezado CSP y en los elementos script legítimos de esa página. Un hash autoriza el contenido exacto de un script inline, por lo que es útil cuando la página es estática o se sirve desde una caché.

Para HTML renderizado en servidor, un nonce suele ser más práctico. Para una página estática que debe conservar la misma respuesta en una CDN, los hashes evitan que el encabezado tenga que cambiar en cada solicitud. web.dev recomienda elegir según si la página se genera dinámicamente o se sirve de forma estática.

Generar un nonce fuerte en Express

No uses Math.random(), una marca de tiempo ni un contador. El nonce debe ser impredecible, nuevo para cada respuesta y suficientemente largo. Node.js incluye un generador criptográfico apropiado:

import express from 'express';
import crypto from 'node:crypto';

const app = express();

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

  res.locals.cspNonce = nonce;
  res.setHeader(
    'Content-Security-Policy',
    [
      `script-src 'nonce-${nonce}' 'strict-dynamic'`,
      "object-src 'none'",
      "base-uri 'none'",
      "frame-ancestors 'self'"
    ].join('; ')
  );

  next();
});

La especificación CSP Level 3 indica que el servidor debe generar un valor único cada vez que transmite una política con nonce y recomienda al menos 128 bits producidos por un generador criptográficamente seguro.

Pasar el nonce a la plantilla

El encabezado por sí solo no autoriza ningún script. La plantilla debe insertar el mismo valor en los scripts que realmente pertenecen a la aplicación:

<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8">
    <title>Panel</title>
  </head>
  <body>
    <main id="app"></main>
    <script nonce="<%= cspNonce %>" src="/assets/app.js"></script>
    <script nonce="<%= cspNonce %>">
      window.appConfig = { locale: 'es' };
    </script>
  </body>
</html>

No hagas un reemplazo global que añada el nonce a cada etiqueta script. Si una entrada inyectada consigue convertirse en script y recibe el nonce por accidente, habrás anulado la protección. La plantilla debe decidir qué nodos legítimos reciben el valor.

Qué hace strict-dynamic

Con 'strict-dynamic', un script autorizado mediante nonce puede cargar otros scripts de forma dinámica. Esto resulta útil cuando un loader confiable incorpora módulos o dependencias. Sin embargo, también amplía la confianza: si ese script construye una URL a partir de datos controlados por el usuario, un atacante podría intentar aprovechar esa ruta.

const script = document.createElement('script');
script.src = '/assets/feature-module.js';
script.async = false;
document.head.appendChild(script);

La cadena de carga debe estar bajo control de tu código. No uses strict-dynamic como excusa para permitir cualquier URL recibida desde una consulta, un parámetro o un contenido de terceros.

Probar sin romper producción

Antes de bloquear recursos, puedes publicar la misma idea en modo de observación:

res.setHeader(
  'Content-Security-Policy-Report-Only',
  [
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'none'",
    "report-to csp-endpoint"
  ].join('; ')
);

web.dev documenta que el modo Report-Only permite observar infracciones sin bloquear todavía los recursos. Revisa la consola, captura los reportes, elimina falsos positivos y prueba los recorridos importantes antes de cambiar a Content-Security-Policy.

Migrar handlers inline

Una CSP con script-src bloquea normalmente el JavaScript inline que no tenga nonce o hash. Eso incluye handlers como estos:

<button onclick="abrirMenu()">Menú</button>

Es mejor separar el comportamiento:

<button id="menu-button" type="button">Menú</button>

const button = document.querySelector('#menu-button');
button.addEventListener('click', abrirMenu);

También revisa enlaces javascript:, llamadas a eval(), constructores Function() y temporizadores que reciban cadenas. Añadir 'unsafe-inline' o 'unsafe-eval' puede silenciar errores, pero reduce la protección que intentas conseguir.

Scripts de analítica y terceros

Los proveedores externos suelen ser la parte más difícil. Antes de añadir un dominio a una allowlist, comprueba exactamente qué script carga y qué otros dominios contacta. Un script de terceros autorizado con nonce recibe confianza dentro de la política; por eso debes revisar sus actualizaciones y la forma en que crea etiquetas dinámicas.

Para código estático de terceros, considera integrity con Subresource Integrity cuando el proveedor lo permita. Para gestores que cambian dinámicamente, limita su alcance, carga solo lo necesario y registra los cambios. La CSP no convierte un proveedor no confiable en confiable.

Caché, HTML y nonces

Un nonce nuevo por respuesta no combina directamente con servir el HTML completo desde una caché compartida. Si una CDN devuelve un documento almacenado con el nonce de otra respuesta, el encabezado y el HTML pueden dejar de coincidir, o varios usuarios pueden recibir el mismo valor.

Si necesitas cachear HTML, evalúa una CSP basada en hashes para scripts inline estables, separa el shell estático de los datos dinámicos o configura la caché para no almacenar respuestas que contengan nonces. No reutilices un nonce como si fuera una clave de configuración.

Diagnóstico rápido de bloqueos

  1. Confirma que el encabezado llega en la respuesta HTML y que no hay otra CSP más restrictiva añadida por el proxy.
  2. Comprueba que el nonce del encabezado coincide exactamente con el atributo, incluida la codificación.
  3. Identifica si el bloqueo corresponde a un script inline, un handler, eval(), un iframe u otro tipo de recurso.
  4. Corrige el código que lo necesita en vez de añadir unsafe-inline automáticamente.
  5. Repite la prueba en una sesión limpia y en las rutas autenticadas, donde suelen aparecer scripts diferentes.
  6. Solo después cambia de Report-Only a enforcement.

Checklist de producción

  • Nonce nuevo por respuesta y generado con un CSPRNG.
  • Mismo nonce únicamente en los scripts legítimos de esa respuesta.
  • Sin unsafe-inline ni unsafe-eval salvo una excepción documentada.
  • object-src 'none' y base-uri 'none' cuando la aplicación lo permita.
  • Prueba inicial con Content-Security-Policy-Report-Only.
  • Plantillas sin handlers inline ni URLs javascript:.
  • Revisión específica de loaders y scripts de terceros.
  • Configuración de caché compatible con nonces o uso de hashes para HTML estático.

Conclusión

Una CSP estricta con nonces funciona mejor cuando se implementa como un contrato entre el servidor, la plantilla y el navegador. Express genera un valor impredecible, la plantilla lo entrega solo a scripts conocidos y el navegador bloquea lo demás. La migración debe comenzar en modo de reporte y terminar con pruebas reales, porque una política segura que rompe el login o la analítica tampoco es una solución operativa.

Fuentes técnicas: MDN Content Security Policy, web.dev Strict CSP y W3C CSP Level 3.

Si quieres conocer otros artículos parecidos a CSP estricta con nonces en Express: guía práctica contra XSS puedes visitar la categoría Blog.

También puede gustarte:

Deja una respuesta

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

Subir