CSP estricto con nonces y Trusted Types

Avanzado
Spring Security
Spring Security
Actualizado: 19/04/2026

El Content Security Policy es la defensa más efectiva contra XSS una vez que la aplicación ya está en producción. Configurada correctamente, impide al navegador ejecutar cualquier script no autorizado, incluso si un atacante consigue inyectar HTML malicioso.

Spring Security incluye soporte nativo para CSP, pero la configuración por defecto suele ser demasiado laxa para ofrecer protección real. Esta lección cubre cómo desplegar una CSP estricta con nonces criptográficos y Trusted Types sin romper la aplicación.

Por qué la CSP por defecto no protege

La cabecera CSP típica que se ve en muchos proyectos:

Content-Security-Policy: default-src 'self'

Permite scripts del propio dominio. El problema es que casi cualquier XSS reflejado o almacenado vive en el dominio de la víctima. Esa CSP no detiene nada.

La CSP estricta moderna se basa en tres mecanismos:

  • Nonces criptográficos por petición: cada respuesta HTML emite un valor aleatorio único; los <script> legítimos lo declaran y los inyectados por el atacante no.
  • strict-dynamic: una vez que un script con nonce se ejecuta, los scripts que él cargue heredan la confianza, lo que evita una whitelist explícita de CDNs.
  • 'unsafe-inline' y 'unsafe-eval' ausentes: bloquea eval, Function() y handlers onclick="...".

La política recomendada por Google para apps modernas:

Content-Security-Policy:
  script-src 'nonce-RANDOM' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';
  trusted-types default;

'unsafe-inline' queda como fallback para navegadores antiguos que no soportan nonces; los modernos lo ignoran cuando hay nonce y strict-dynamic.

Generar nonces por petición en Spring

El nonce debe ser distinto en cada respuesta y disponible para las plantillas Thymeleaf. Implementamos un filtro que lo genera y lo añade a los atributos del request.

@Component
public class CspNonceFilter extends OncePerRequestFilter {

    public static final String NONCE_ATTR = "cspNonce";
    private final SecureRandom random = new SecureRandom();

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        byte[] bytes = new byte[16];
        random.nextBytes(bytes);
        String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        req.setAttribute(NONCE_ATTR, nonce);
        chain.doFilter(req, res);
    }
}

Y la SecurityFilterChain añade el nonce a la cabecera CSP, leyéndolo del request:

@Bean
SecurityFilterChain http(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            .contentSecurityPolicy(csp -> csp.policyDirectives(
                "script-src 'nonce-{nonce}' 'strict-dynamic' https: 'unsafe-inline'; "
                + "object-src 'none'; base-uri 'none'; "
                + "require-trusted-types-for 'script'; trusted-types default")))
        .addFilterBefore(new CspNonceFilter(), HeaderWriterFilter.class);
    return http.build();
}

Sustituye el {nonce} con el valor del request usando un HeaderWriter personalizado:

public class NonceCspHeaderWriter implements HeaderWriter {
    @Override
    public void writeHeaders(HttpServletRequest req, HttpServletResponse res) {
        String nonce = (String) req.getAttribute(CspNonceFilter.NONCE_ATTR);
        String policy = "script-src 'nonce-" + nonce + "' 'strict-dynamic' https: 'unsafe-inline'; "
                      + "object-src 'none'; base-uri 'none';";
        res.setHeader("Content-Security-Policy", policy);
    }
}

Inyectar el nonce en las plantillas Thymeleaf

<script th:nonce="${#httpServletRequest.getAttribute('cspNonce')}">
    document.addEventListener('DOMContentLoaded', () => {
        // tu codigo aqui
    });
</script>

Para evitar repetir la expresión, registra un dialecto custom:

@Component
public class NonceDialect extends AbstractProcessorDialect {
    public NonceDialect() { super("nonce", "csp", 1000); }

    @Override
    public Set<IProcessor> getProcessors(String dialectPrefix) {
        return Set.of(new NonceAttributeProcessor(dialectPrefix));
    }
}

Con eso, las plantillas pueden usar csp:nonce directamente.

Trusted Types

Trusted Types es una API del navegador que prohíbe pasar strings sin sanear a APIs DOM peligrosas (innerHTML, script.src, iframe.srcdoc). Solo aceptan objetos TrustedHTML, TrustedScript, TrustedScriptURL.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default;

A partir de aquí, este código rompe:

document.getElementById("salida").innerHTML = userInput;
// TypeError: Failed to set the 'innerHTML' property

La forma correcta:

const policy = trustedTypes.createPolicy("default", {
    createHTML: input => DOMPurify.sanitize(input)
});

document.getElementById("salida").innerHTML = policy.createHTML(userInput);

Trusted Types convierte el contrato implícito ("no me pases HTML del usuario sin sanear") en un contrato enforced por el navegador. Es la diferencia entre una buena práctica y una garantía.

Despliegue gradual con Content-Security-Policy-Report-Only

Una CSP estricta rompe casi cualquier código legacy. El despliegue sensato es report-only primero: el navegador reporta violaciones sin bloquear.

Content-Security-Policy-Report-Only:
  script-src 'self' 'nonce-RANDOM' 'strict-dynamic';
  report-uri /csp-report

Spring Security expone un endpoint para recibir reportes:

@RestController
public class CspReportController {

    private static final Logger log = LoggerFactory.getLogger("CSP");

    @PostMapping(value = "/csp-report", consumes = "application/csp-report")
    public ResponseEntity<Void> report(@RequestBody Map<String, Object> report) {
        log.warn("CSP violation: {}", report);
        return ResponseEntity.noContent().build();
    }
}

Tras dos semanas de monitorización, los reportes muestran qué scripts inline o qué eval quedan por migrar. Una vez limpio, se cambia el header a enforcement mode (sin -Report-Only).

CSP para SPA (Angular, React)

Las SPAs modernas suelen usar bundlers que generan código sin inline scripts y con todos los assets en el mismo dominio. Aún así, hay puntos sensibles:

  • Service Worker: requiere script-src https:.
  • Hot Module Replacement en desarrollo: usa eval(), incompatible con CSP estricto. Solo en dev.
  • Carga dinámica de chunks: los chunks heredan el nonce con strict-dynamic.

Angular tiene un objeto Trusted Types policy integrado desde la versión 14 que se activa con:

import { provideZoneChangeDetection, ApplicationConfig } from '@angular/core';

export const appConfig: ApplicationConfig = {
    providers: [
        provideZoneChangeDetection({ eventCoalescing: true }),
        // Trusted Types se activa automaticamente en builds de produccion
    ]
};

COOP, COEP y CORP

Tres cabeceras complementarias que aíslan tu app del resto del navegador.

  • Cross-Origin-Opener-Policy: same-origin evita que ventanas abiertas con window.open desde tu dominio compartan contexto con la apertura original.
  • Cross-Origin-Embedder-Policy: require-corp exige que cada recurso embebido (imagen, iframe) declare explícitamente su política CORP.
  • Cross-Origin-Resource-Policy: same-origin marca tus recursos como solo disponibles para tu propio origen.
.headers(headers -> headers
    .crossOriginOpenerPolicy(coop -> coop.policy(SAME_ORIGIN))
    .crossOriginEmbedderPolicy(coep -> coep.policy(REQUIRE_CORP))
    .crossOriginResourcePolicy(corp -> corp.policy(SAME_ORIGIN)))

Habilitan crossOriginIsolated en el navegador, requisito para usar SharedArrayBuffer y mitigar ataques tipo Spectre.

Auditoría de la política con herramientas

  • Mozilla Observatory: análisis online de la CSP de cualquier URL pública.
  • CSP Evaluator de Google: detecta puntos débiles en una política.
  • Chrome DevTools / Security tab: muestra las cabeceras y los recursos bloqueados.
  • Lighthouse Security audit: integrable en CI con lighthouse-ci.

Pasar una auditoría con A+ en Mozilla Observatory es un objetivo realista en una app moderna. Si tu nota es B o inferior, hay configuración pendiente.

Errores frecuentes

  • Reusar el mismo nonce entre peticiones: el nonce debe regenerarse en cada request. Reusarlo facilita CSP bypass.
  • Olvidar 'unsafe-inline' como fallback: navegadores antiguos sin soporte de nonce ignorarán el script si no hay fallback.
  • CSP en proxies CDN: si Cloudflare o similar reescribe HTML, debe respetar el nonce. Algunos optimizadores rompen la política.
  • Trusted Types sin polyfill: hasta 2024 Firefox no soportaba Trusted Types, lo que requería polyfill o degradación graceful.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Security

Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar Content Security Policy estricta en Spring Security con nonces criptográficos por petición, activar Trusted Types y desplegar la política gradualmente con report-only.