Content-Security-Policy (CSP) es uno de los headers que más nos toca discutir con equipos de desarrollo. Es de los pocos controles que mitigan XSS aunque el código siga vulnerable: aunque un atacante consiga inyectar un <script> malicioso, el browser se niega a ejecutarlo si no encaja en la política. También bloquea exfiltración a dominios no listados, frena form-jacking y reduce el blast radius de un plugin comprometido.
El problema: en WordPress muy pocas instalaciones la tienen bien configurada. Las dos rutas habituales son:
- CSP ausente — nadie la puso, el sitio queda con la postura por defecto del browser.
- CSP nominalmente puesta con
default-src *oscript-src 'self' 'unsafe-inline' 'unsafe-eval'— escrita para que “no rompa nada”, lo que en la práctica equivale a no tener CSP.
Este post es la receta que usamos cuando tenemos que llevar un WordPress real desde “sin CSP” hasta “CSP estricta funcional”, sin pasar por el infierno del soporte llamando porque el admin no carga.
Qué hace CSP, en 60 segundos
La política se envía como cabecera HTTP (Content-Security-Policy: ...) o como <meta>. Cada directiva define qué orígenes están autorizados para cargar un tipo de recurso:
script-src 'self'— sólo JS del mismo origen.script-src 'self' https://www.googletagmanager.com— JS del mismo origen + GTM.script-src 'self' 'sha256-abc...'— JS del mismo origen + un script inline cuyo contenido tiene ese hash.script-src 'self' 'nonce-XYZ'— JS del mismo origen + scripts inline marcados connonce="XYZ".
Cuando el browser ve un recurso que no encaja, lo bloquea y opcionalmente reporta. Mientras más estricta la política, mejor protege, pero también más fácil es que algo deje de funcionar. El truco está en saber qué relajar y qué mantener.
Por qué WordPress la rompe
WP es un campo minado para CSP por cinco razones, todas estructurales:
- Inline scripts en el admin. El bloque
wp-admin/está lleno de<script>inline con configuración por request (nonces,userSettings, traducciones). Sin'unsafe-inline'ni hashes/nonces, el panel deja de funcionar. - Plugins que inyectan inline. Yoast, Elementor, WooCommerce, RankMath, varios plugins de analítica y ads inyectan
<script>inline con datos por request. El contenido cambia entre páginas — los hashes estáticos no sirven. wp_add_inline_scriptywp_localize_script. WordPress mismo expone APIs para que los plugins inyecten configuración inline. El core lo hace, los themes lo hacen.- jQuery y bundled libraries. WP bundlea jQuery, jQuery UI, Underscore, Backbone, etc. Si tu theme los usa con
?ver=cachebuster, vas a tener URLs ligeramente distintas por release. - CDNs de terceros. Google Fonts, Font Awesome, Bootstrap CDN, embeds (YouTube, Vimeo, Maps), pasarelas de pago, reCAPTCHA, livechat — todos suman orígenes externos.
La conclusión práctica: una CSP estricta para WordPress requiere conocer qué carga tu instalación específica. No hay una política universal copy-paste que funcione bien.
Estrategia: Report-Only primero, enforce después
El error más caro es publicar la CSP en modo enforcement sin medir antes. Si te equivocas, el sitio se cae para los visitantes (o peor: para los admins, y vuelves a quedar bloqueado tú).
El header Content-Security-Policy-Report-Only hace exactamente lo mismo pero sin bloquear. El browser sólo reporta las violaciones que habrían ocurrido. Con eso medimos durante 1-2 semanas qué se rompería con la política propuesta, y la ajustamos antes de enforcement.
Flujo recomendado:
- Diseñar una política propuesta (más estricta de lo que crees que tolera el sitio).
- Configurar un endpoint para recibir reports.
- Publicar la política en modo
Report-Onlydurante ~2 semanas. - Revisar los reports, ajustar la política para cubrir falsos positivos legítimos.
- Pasar a enforce (
Content-Security-Policy) cuando los reports queden estables.
Endpoint de reports
Lo mínimo: un script que reciba el JSON POST y lo escriba a log. Si no quieres mantener uno, report-uri.com ofrece un free tier suficiente. Para WordPress, un endpoint custom en un plugin propio basta:
add_action('rest_api_init', function () {
register_rest_route('csp/v1', '/report', [
'methods' => 'POST',
'callback' => function ($request) {
$body = $request->get_body();
error_log('[CSP] ' . $body, 3, WP_CONTENT_DIR . '/csp-reports.log');
return new WP_REST_Response(null, 204);
},
'permission_callback' => '__return_true',
]);
});
Y en la cabecera: report-uri https://tu-sitio.cl/wp-json/csp/v1/report;. Para CSP 3, también: report-to csp-endpoint; con el grupo definido vía Report-To (más nuevo, no todos los browsers lo soportan aún — mantener ambos por un tiempo).
Identificar inline scripts legítimos
Para no caer en 'unsafe-inline', necesitas que los inline scripts que sí son legítimos pasen. Hay tres formas:
Hashes
'sha256-<base64>'. Funciona bien para bloques fijos, ejemplo: un <script type="application/ld+json"> con tu Schema.org. Mientras el contenido no cambie, el hash sirve.
Para calcular el hash de un bloque (Python):
import hashlib, base64
script = '...' # contenido exacto entre <script>...</script>, sin las tags
print('sha256-' + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode())
Limitación: si el bloque tiene cualquier cosa dinámica (timestamp, sessionId, nonce de form), el hash cambia por request y no sirve.
Nonces
'nonce-<aleatorio-por-request>'. WordPress puede emitir un nonce CSP en cada response, y los plugins que cooperan lo van pegando a sus inline scripts:
add_action('init', function () {
if (!defined('CSP_NONCE')) {
define('CSP_NONCE', base64_encode(random_bytes(16)));
}
});
Y luego en el <script> que inyectas: <script nonce="<?php echo esc_attr(CSP_NONCE); ?>">. Cabecera: script-src 'self' 'nonce-<?php echo CSP_NONCE; ?>';.
El problema en WP: los plugins de terceros no usan tu nonce. Tienes que parchearlos uno por uno, o usar un plugin “CSP Manager” que intercepta wp_add_inline_script y le agrega el nonce. La práctica nuestra: vale la pena para inline propio, no perseguir cada plugin.
strict-dynamic
CSP 3 introduce 'strict-dynamic': si un script con nonce/hash crea otros scripts, los hijos heredan la confianza automáticamente. Es la dirección recomendada por el W3C para apps modernas. En WordPress es complicado porque WP no fue diseñado pensando en esto — el nonce tiene que llegar a todo el árbol de carga, lo que rara vez es práctico.
Un header funcional para un WP típico
Suponiendo un WP institucional con Yoast, Cloudflare, Google Analytics y un form de contacto. La política mínima estricta razonable:
Content-Security-Policy:
default-src 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'self';
form-action 'self';
img-src 'self' data: https://secure.gravatar.com https://*.googleusercontent.com;
font-src 'self' https://fonts.gstatic.com data:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
script-src 'self' 'nonce-XYZ' https://www.googletagmanager.com https://www.google-analytics.com;
connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com;
frame-src 'self' https://www.youtube-nocookie.com;
report-uri /wp-json/csp/v1/report;
upgrade-insecure-requests;
Notas sobre cada directiva:
default-src 'self': catch-all conservador. Cualquier tipo no listado cae acá.base-uri 'self'yform-action 'self': previenen ataques de manipulación del<base>y de redirección de submits.object-src 'none': nadie usa<object>/<embed>legítimamente en 2026. Cerrarlo.frame-ancestors 'self': reemplaza el viejoX-Frame-Options: SAMEORIGIN. Si tu sitio nunca se embebe, mejor'none'.img-src 'self' data:: eldata:se necesita para imágenes inline base64 que algunos plugins usan. Los hosts adicionales son típicos de Gravatar y Google sign-in avatars.style-src 'self' 'unsafe-inline': lamentablemente WordPress y la mayoría de los themes inyectan estilos inline. Por ahora'unsafe-inline'queda. Una versión más estricta usa nonces para<style>también, pero el ROI es bajo (XSS via CSS exfil existe pero es marginal).script-src 'self' 'nonce-XYZ'+ hosts GTM/GA: la línea más sensible. Aquí es donde el modo Report-Only te dice qué más necesitas.connect-src: dónde puede hacerfetch/XMLHttpRequest/WebSocket. GA usaregion1.google-analytics.compara EU/CL traffic — no olvidar.frame-src: si embebes YouTube, usaryoutube-nocookie.com(privacidad-first). Para Vimeo:https://player.vimeo.com.upgrade-insecure-requests: si algúnsrc="http://..."quedó hardcoded, el browser lo upgradea automáticamente. Útil mientras limpias el sitio.
Lo que no está: 'unsafe-inline' en script-src. Esa es la línea que más cuesta defender ante un equipo de devs apurados, pero también la que da el 80% del valor del header.
Trampas comunes en WordPress
El admin se cae sin 'unsafe-inline'
Casi inevitable. La solución pragmática: aplicar dos políticas distintas según el path. En Apache:
<LocationMatch "^/wp-admin">
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'self'"
</LocationMatch>
<LocationMatch "^/(?!wp-admin)">
Header always set Content-Security-Policy "[política estricta del frontend]"
</LocationMatch>
El admin queda con una política más laxa, pero los visitantes anónimos (99% del tráfico) reciben la estricta. Es un trade-off aceptable mientras planificas migrar el admin a nonces.
Google Tag Manager carga otros scripts
GTM es un container que carga scripts dinámicamente (Analytics, Ads, Hotjar, Facebook Pixel, …). Cada tag agregada en GTM añade un origen al script-src y al connect-src. La práctica nuestra: cada vez que el equipo de marketing agrega una tag en GTM, recibimos el report y agregamos el host. Si esto se vuelve un dolor crónico, vale la pena usar 'strict-dynamic' con nonce en GTM (Google lo soporta).
reCAPTCHA v3
Necesita: script-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/, frame-src https://www.google.com/recaptcha/. Si lo olvidas, los forms con captcha quedan rotos sin error visible (el captcha simplemente no carga).
Embeds (YouTube, Vimeo, Maps, SoundCloud)
Cada uno agrega frame-src y muchas veces script-src/connect-src. La regla: prefiere el embed nocookie si existe. YouTube tiene youtube-nocookie.com, Vimeo respeta privacy mode. Reduce footprint de cookies y simplifica la política.
Stripe / Webpay / Khipu
Pasarelas chilenas: Webpay 3D Secure abre un iframe a https://www.webpay.cl/. Khipu API: connect-src https://payment-api.khipu.com/. Stripe Elements: script-src https://js.stripe.com/, frame-src https://js.stripe.com/. Documenta esto explícitamente en tus runbooks — son los que más cuesta diagnosticar.
Cloudflare Rocket Loader
Si tienes Rocket Loader activado en Cloudflare, reescribe los <script> reales y los inyecta dinámicamente. Eso choca con CSP estricta. Dos opciones: deshabilitarlo (recomendado, su beneficio de performance es marginal con HTTP/2+Brotli activos) o marcar los scripts críticos con data-cfasync="false" para que no los toque.
Mantenimiento
CSP no es “ponla y olvídala”. Cada nuevo plugin, cada feature nueva, cada tag de marketing puede romperla. La rutina mínima:
- Revisión mensual del log de reports. Patrones nuevos → ajuste de política.
- Pre-deploy: si tocas plugins, themes o agregas un tag externo, deja la política nueva primero en
Report-Only, valida 48h, luego enforce. - Audit anual del header: ¿siguen siendo necesarios todos los hosts listados? Cada origen es superficie de ataque (si lo comprometen, sirven JS a tus visitantes).
- Alertas: agrega una métrica simple “violaciones por hora” en tu logging stack. Un spike de violaciones desde una IP particular es señal de un ataque XSS en curso siendo bloqueado.
Cómo lo verificas
El Asentic FreeScan revisa headers de seguridad incluido CSP. Detecta los tres patrones más comunes que vimos arriba: header ausente, header con 'unsafe-inline' o 'unsafe-eval', y header sin default-src o sin script-src (que dejan el resto sin restricción). El reporte distingue entre “no implementado” y “implementado pero ineficaz” — porque el segundo caso es engañoso para auditores que sólo verifican presencia del header.
Para validación manual:
- csp-evaluator.withgoogle.com — analiza tu política y marca debilidades específicas.
curl -sI https://tu-sitio.cl/— confirma que el header está siendo enviado.- DevTools del browser → Network → Response Headers — verifica la política en una request real (a veces hay middlewares que la sobreescriben).
- Console del browser — los bloqueos CSP aparecen como
Refused to execute inline script because it violates the following Content Security Policy directive….
Cierre
CSP es una de esas herramientas que dan una ganancia de seguridad real con un costo de implementación que siempre subestimamos. Vale la pena pagarlo: cuando llegue el día en que un plugin se vea comprometido o un XSS pase la primera línea, el header es lo que decide entre un incidente menor y una filtración de datos.
Si tienes un WordPress en producción sin CSP, el primer paso es siempre el mismo: poner una política propuesta en Report-Only, dejarla correr una semana, y leer los reports. Todo lo demás sale de ahí.