Blog · 11 may 2026 · 9 min de lectura

CSP en WordPress: cómo escribir un header útil sin romper el sitio

Content-Security-Policy es de los headers más útiles para frenar XSS y data exfil, pero también el más fácil de inutilizar. En WordPress, además, la mitad de los plugins inyecta inline scripts. Esta es una receta concreta para llegar a una CSP estricta sin dejar el panel inservible.

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:

  1. CSP ausente — nadie la puso, el sitio queda con la postura por defecto del browser.
  2. CSP nominalmente puesta con default-src * o script-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:

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:

  1. 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.
  2. 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.
  3. wp_add_inline_script y wp_localize_script. WordPress mismo expone APIs para que los plugins inyecten configuración inline. El core lo hace, los themes lo hacen.
  4. 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.
  5. 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:

  1. Diseñar una política propuesta (más estricta de lo que crees que tolera el sitio).
  2. Configurar un endpoint para recibir reports.
  3. Publicar la política en modo Report-Only durante ~2 semanas.
  4. Revisar los reports, ajustar la política para cubrir falsos positivos legítimos.
  5. 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:

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:

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:

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í.

¿Quieres una segunda opinión sobre tu sitio?

Si lo que leíste te dejó preguntas sobre tu propio dominio, podemos partir conversando — sin compromiso.

Conversemos