Como endurecer headers de seguridad (CSP, HSTS) desde PHP en WordPress

Contents

Introducción — por qué endurecer cabeceras desde PHP en WordPress

Endurecer las cabeceras de seguridad (Content-Security-Policy, HSTS y otras) reduce la superficie de ataque: previene XSS por ejecución de scripts no autorizados, fuerza HTTPS, evita clickjacking y protege contra ataques por mezcla de contenidos. En WordPress, aplicar estas cabeceras desde PHP tiene la ventaja de ejecutarse en la capa de aplicación (control total sobre cuándo y cómo), integrarse con la generación de páginas y permitir mecanismos dinámicos como nonces CSP para scripts y estilos encolados.

Consideraciones iniciales

  • Enviar HSTS sólo sobre HTTPS. Nunca envíes Strict-Transport-Security en conexiones HTTP—puede inhibir el acceso si se fuerza a HTTPS cuando no está configurado correctamente.
  • Pruebas progresivas. Empieza con políticas restrictivas en modo reporte (report-only) y solo cuando estés seguro pasa a modo enforcement.
  • Entorno WordPress. Ten en cuenta: admin-ajax, REST API (/wp-json), temas y plugins pueden inyectar scripts inline o fuentes externas. Hay que analizarlos y adaptarlos (nonces o hashes).
  • Mu-plugin recomendado. Para asegurar que las cabeceras se apliquen siempre (incluso si se desactiva un plugin estándar), usa un mu-plugin en wp-content/mu-plugins/.

Cabeceras esenciales y valores recomendados

Lista de cabeceras que conviene establecer desde PHP junto con explicaciones:

  • Strict-Transport-Security (HSTS). Ejemplo recomendado: max-age=63072000 includeSubDomains preload
  • Content-Security-Policy (CSP). Define orígenes permitidos por tipo de recurso (script-src, style-src, img-src, connect-src, font-src, frame-ancestors…).
  • Referrer-Policy. Controla el Referer enviado: no-referrer-when-downgrade o strict-origin-when-cross-origin son opciones seguras.
  • X-Content-Type-Options: nosniff. Evita que navegadores detecten tipo MIME incorrecto.
  • X-Frame-Options o frame-ancestors (CSP). Previene clickjacking. SAMEORIGIN o usar frame-ancestors none.
  • Permissions-Policy (antes Feature-Policy). Restringe APIs de navegador (geolocation, camera, microphone…).
  • Report-To / report-uri. Para recibir informes de violaciones CSP. Report-To es más moderno, pero report-uri sigue siendo muy usado.

Política CSP: enfoque práctico

Dos enfoques comunes:

  1. Hashes (sha256-…): apropiado cuando hay pocos inline scripts estáticos conocidos.
  2. Nonces (nonce-…): recomendable para aplicaciones dinámicas (WordPress) porque puedes generar un nonce por petición y añadirlo a scripts/styles encolados.

Recomendación práctica: usar nonces para scripts y estilos que imprime WordPress y evitar inline scripts en temas/plugins externalizar scripts a archivos y encolarlos. Para librerías externas (CDN, analíticas), explícitalas en script-src con origenes concretos o cargar mediante proxy.

Implementación completa desde PHP (mu-plugin)

Ejemplo de mu-plugin que:

  • Genera un nonce por petición.
  • Envía HSTS (si es SSL).
  • Envía una CSP que permite recursos propios y los scripts/estilos con nonce.
  • Añade el nonce a los tags de scripts y estilos encolados por WordPress.
  • Registra un endpoint REST para recibir reportes CSP.
lt?php
/
Plugin Name: WP Security Headers (mu)
Description: Añade HSTS, CSP con nonces y cabeceras complementarias.
Place in: wp-content/mu-plugins/wp-security-headers.php
/

// Genera y expone nonce por petición
add_action(muplugins_loaded, function() {
    if (! function_exists(random_bytes)) {
        return
    }
    // Generamos un nonce base64 URL-safe
    nonce = rtrim(strtr(base64_encode(random_bytes(18)),  /, -_), =)
    // Almacenar en variable global accesible en tiempo de render
    GLOBALS[wp_csp_nonce] = nonce
})

// Añade cabeceras (use send_headers para que WP permita modificarlas antes de salida)
add_action(send_headers, function() {
    // Solo enviar HSTS y CSP en HTTPS
    if (!is_ssl()) {
        // Podemos enviar otras cabeceras no relacionadas con HSTS si se desea
        return
    }

    // HSTS: 2 años recomendado como ejemplo ajustar según política
    header(Strict-Transport-Security: max-age=63072000 includeSubDomains preload)

    // Cabeceras complementarias
    header(X-Content-Type-Options: nosniff)
    header(Referrer-Policy: strict-origin-when-cross-origin)
    header(X-Frame-Options: SAMEORIGIN) // fallback
    header(Permissions-Policy: geolocation=(), camera=(), microphone=())

    // Construir CSP usando nonce si existe
    nonce = isset(GLOBALS[wp_csp_nonce]) ? GLOBALS[wp_csp_nonce] : 

    // Ejemplo de política. En producción revisa y añade orígenes necesarios (CDNs, APIs).
    csp_parts = []
    csp_parts[] = default-src self
    csp_parts[] = script-src self nonce-{nonce} strict-dynamic
    csp_parts[] = style-src self nonce-{nonce}
    csp_parts[] = img-src self data:
    csp_parts[] = font-src self data:
    csp_parts[] = connect-src self https://api.example.com
    csp_parts[] = frame-ancestors none
    csp_parts[] = base-uri self
    csp_parts[] = form-action self
    // Modo de reporte (cambiar a report-to si se usa Report-To)
    csp_parts[] = report-uri /wp-json/security/v1/csp-report

    csp_header = implode( , csp_parts)
    header(Content-Security-Policy: {csp_header})
}, 100)


// Añadimos el nonce a los scripts encolados por WP
add_filter(script_loader_tag, function(tag, handle, src) {
    if (!empty(GLOBALS[wp_csp_nonce])) {
        // Inserta el atributo nonce en la etiqueta