La cabecera Content-Security-Policy pertenece a esa clase de cosas que se configuran una vez y no se vuelven a abrir. Se sirve, es sintácticamente válida, un evaluador automático le pone una nota aceptable. Precisamente por eso una política puede bloquear tráfico legítimo durante mucho tiempo sin que nadie se entere: las formas habituales de comprobarla son estructuralmente incapaces de detectar esta clase de defecto.
Lo que sigue es un caso concreto en mi propio sitio. La petición era estrecha: pasar la cabecera por el CSP Evaluator y corregir dos errores. Ambos resultaron vacíos. El defecto real apareció solo cuando la comprobación se hizo contra el comportamiento del navegador y no contra la cabecera.
Una entrada muerta en la allowlist no es un resto inofensivo
El evaluador señaló dos hallazgos de nivel error en script-src: un host conocido por sus endpoints JSONP y otro que sirve builds de Angular. El mecanismo de elusión es el mismo en ambos casos. Si un atacante puede inyectar un <script src> que apunte a un host permitido, la política lo deja pasar — y a partir de ahí un callback JSONP o el motor de plantillas de Angular ejecuta lo que el atacante necesite. Formalmente 'self' y los hashes están en su sitio; en la práctica la política tiene un agujero.
Revisar el código dio una respuesta aburrida: el servicio por el que esos hosts estaban ahí estaba desactivado por configuración — identificador de contador vacío, modo apagado. Uno de los dos hosts no aparecía en ninguna parte del código salvo en la propia plantilla de la política. Nunca había estado conectado.
Desactivar una integración y eliminar sus hosts es un solo cambio, no dos de los cuales el segundo se aplaza para siempre. Lo contrario también vale: al recuperar la integración hay que devolver los hosts a ambas directivas, script-src y connect-src. De lo contrario la etiqueta se bloquea en silencio mientras la interfaz sigue ofreciendo un interruptor que no enciende nada. Eso va en el comentario junto a la directiva — donde se hará la edición, no en un ticket.
Por qué la comprobación habitual no podía ver lo importante
El sitio funciona con un modelo de rechazo por defecto: un bootstrap inline pone todas las categorías de almacenamiento en denied, y las etiquetas de terceros las carga un script aparte del mismo dominio — solo después de que la persona pulse el botón de consentimiento.
De ahí se deriva algo fácil de pasar por alto: antes del consentimiento la página no envía ni una sola petición a terceros. Cualquier comprobación que termine en la carga de la página es, por tanto, físicamente incapaz de ver si la allowlist cubre lo que el sitio pide de verdad. Informará de cero violaciones, y será cierto y a la vez irrelevante.
| Comprobación | Qué ve | Qué no ve |
|---|---|---|
curl -I sobre la cabecera | sintaxis, la lista de hosts | si esa lista atiende las peticiones reales |
| CSP Evaluator | hosts eludibles en script-src | no evalúa connect-src en absoluto |
| Lighthouse, abrir la página | violaciones antes del consentimiento — no hay | todo lo que ocurre después del consentimiento |
nginx -t | que el fichero se parsea | nada sobre el comportamiento |
La forma general va más allá de la analítica: cualquier allowlist detrás de una barrera de consentimiento o de un feature flag solo se puede verificar en el estado activado. Un resultado negativo en el estado por defecto no prueba nada — ni que la política sea lo bastante estricta, ni que sea lo bastante amplia.
Qué encontró la medición con el consentimiento concedido
Medir en el estado «consentimiento concedido» produjo en la página del artículo una violación por la que nadie había preguntado:
Refused to connect because it violates the
document's Content Security Policy.
https://region1.google-analytics.com/g/collect
?v=2&...&en=page_view
El mecanismo es este. La analítica no envía el evento a un host fijo: desde una dirección europea va al endpoint regional region1.google-analytics.com. En connect-src estaba el host exacto www.google-analytics.com, que no cubre el regional.
Justo aquí se esconde una trampa de nombres parecidos, y es fácil caer en ella:
analytics.google.com != google-analytics.com
La política sí contenía https://*.analytics.google.com, lo que daba toda la impresión de que los endpoints regionales estaban cubiertos. Son dominios distintos, y esa máscara no cubre nada de lo necesario. La documentación prescribe para la medición https://*.google-analytics.com: cubre tanto www. como todos los regionN.. Los hosts de publicidad figuran ahí también, pero bajo una nota al pie aparte — con las categorías publicitarias desactivadas no hay razón para añadirlos.
La corrección se redujo a un solo token:
- connect-src 'self' https://www.google-analytics.com ...
+ connect-src 'self' https://*.google-analytics.com ...
Dos matices importan para una conclusión honesta. Primero: img-src en esta política admite cualquier imagen https, así que el transporte por píxel estaba abierto — y aun así, en la medición previa a la corrección la única petición a terceros era la propia etiqueta, sin ninguna llamada exitosa a la analítica. El evento se perdía por completo en lugar de tomar una vía degradada. Segundo: el host estrecho llevaba en la configuración desde su primer commit, 73 días. Pero el historial de configuración data la configuración, no el volumen de la pérdida: cuánto del tráfico era europeo no se deduce de estos datos, y afirmar una cifra sería inventarla.
El método: un navegador, un clic real, el registro de violaciones
La comprobación se reduce a ejecutar la misma cadena que recorre quien visita el sitio:
- Arrancar un navegador headless con puerto de depuración.
- Abrir la página y dejar que cargue.
- Pulsar el botón de consentimiento real en lugar de conceder el consentimiento por código: a partir de ahí corre el código propio del sitio, y esa es justo la cadena que la allowlist debe cubrir.
- Recoger las respuestas de terceros con sus códigos de estado, las peticiones bloqueadas con el motivo del bloqueo y las entradas de registro sobre violaciones de la política.
- Limpiar el almacenamiento antes de cada URL — de lo contrario el banner no reaparece y el clic se convierte en silencio en un no-op.
Conviene cubrir plantillas distintas — artículo, sección, página 404: detrás está la misma configuración del servidor web, pero el marcado difiere. El verde se ve así: «etiqueta → 200, evento → 204, violaciones 0».
El método tiene su propia trampa, y casi costó una conclusión falsa. El evento no sale de inmediato: con una espera de cinco segundos falta en la mayoría de las páginas. Es un artefacto de medición, no un defecto — la ventana útil ronda los quince segundos. El mismo artefacto enmascara violaciones reales: en la ejecución previa a la corrección el rechazo se registró en una página de tres precisamente porque en las demás la petición aún no había salido dentro de la ventana.
Una lección aparte, que no va de CSP: un requisito sin herramienta detrás no se cumple. «Verificar en el estado con consentimiento concedido» ya constaba en la documentación interna — pero no existía el script, así que tampoco existía la verificación. Esa brecha no la cierra una regla, sino un script ejecutable con sus ramas de fallo realmente ejercitadas. Si no, el código de salida prometido descansa sobre código que nadie ejecutó nunca. Por el mismo principio, las puertas del pipeline solo significan algo cuando has visto cómo fallan.
Dos límites que conviene conocer de antemano
nginx falla por la longitud del token, no por el tamaño de la cabecera. Si se hashea cada bloque inline, la lista de hashes crece linealmente con el número de páginas hasta chocar con un límite poco evidente: el parser de configuración limita cualquier token individual, cadenas entrecomilladas incluidas, a unos 4 KB — no 32 KB. En la práctica nginx -t falla con too long parameter. El reflejo de tirar de large_client_header_buffers no ayuda: ese ajuste concierne a las cabeceras de petición, mientras que el fallo ocurre al parsear la configuración, antes de que exista tráfico alguno.
El remedio es hashear solo los scripts inline ejecutables. Los bloques <script type="application/ld+json"> son datos no ejecutados, y script-src no se les aplica (verificado en Chrome 150). Cuando se hashea un único bootstrap compartido, el conjunto de hashes se queda en uno y no crece con el contenido.
strict-dynamic tiene un coste que los consejos suelen omitir. El evaluador lo recomienda frente a enumerar hosts, y encaja bien con la carga dinámica de etiquetas. Pero bajo él 'self' se ignora — y eso no es solo un endurecimiento. Desaparece la red de seguridad en la ventana entre desplegar el marcado y actualizar los hashes: un hash de bootstrap obsoleto significa que no se ejecuta nada en absoluto, incluido el propio banner de consentimiento. A eso se añade que el cambio toca las plantillas, así que exige redesplegar todo el corpus de páginas. La decisión depende de lo que quede en la política tras la limpieza: si al nivel error no queda nada, el precio no se justifica.
Qué llevarse
CSP es una política, y le vale lo mismo que a cualquier política como código: describe un estado que debería coincidir con la realidad, y se aparta de ella en silencio. Tres cosas salen más baratas hechas de inmediato:
- descargar la configuración en producción antes de editarla — puede que el generador no guarde copia propia, y
nginx -tsolo prueba que el fichero se parsea; - medir en el estado objetivo — consentimiento concedido, flag activado — y en distintas plantillas de página;
- contrastar la lista de hosts con la documentación del proveedor en vez de con la memoria: los endpoints regionales y los dominios de aspecto parecido son la causa más común de que una política parezca correcta y no funcione.