Saltar al contenido
Luis ZambranaDevOps · Seguridad · Forense

Cómo blindé mi blog: de WordPress a PHP propio con nota A+ en seguridad

Migré el blog de WordPress a PHP propio y lo llevé a A+ en Mozilla Observatory: CSP sin unsafe-inline, login con Google sin contraseñas, formularios con varias capas contra el abuso y Cloudflare. Paso a paso y con checklist.

Escudo con la nota A+ de Mozilla HTTP Observatory para luiszambrana.ar

Desde 2024 este blog vivió en WordPress. Funcionaba, pero cada vez me cerraba menos: el diseño de las entradas era angosto y difícil de leer, había que mantener al día un tema y una pila de plugins, y cualquiera que mire los registros de un servidor sabe que /wp-login.php y /xmlrpc.php reciben ataques todo el día. Así que lo migré a un sitio propio en PHP, y aproveché para hacerlo bien desde el principio en materia de seguridad.

El resultado: A+ con 145 puntos en Mozilla HTTP Observatory, 12 de 12 pruebas superadas. Podés ver el análisis en vivo en este momento. Te cuento cómo lo hice, paso a paso, para que puedas aplicarlo en tus propios sitios.

Migrar sin reescribir nada

Lo primero que me preocupaba era tener que pasar a mano más de 70 entradas. No hizo falta: WordPress expone todo su contenido por la REST API (/wp-json/wp/v2/posts), así que con un script bajé entradas, categorías, etiquetas, comentarios e imágenes, y los cargué en la base nueva.

Dos cosas que conviene cuidar en una migración así:

  • Mantener las URLs. Cada entrada sigue en la misma dirección y las imágenes conservan su ruta. Las direcciones viejas de WordPress (/category/…, /tag/…, /page/2/) redirigen con un 301 a las nuevas, así Google no pierde nada.
  • Cerrar lo que ya no existe. /wp-login.php, /wp-admin/ y /xmlrpc.php devuelven 410 Gone: le dice a los bots que eso no va a volver, en lugar de dejarlos insistiendo.

Sin contraseñas: login con Google

El panel de administración y los comentarios usan inicio de sesión con Google (OpenID Connect). La ventaja es enorme: el sitio no guarda ninguna contraseña, así que no hay nada que robar ni que filtrar.

Para hacerlo bien:

  • Flujo de código con PKCE, más los parámetros state y nonce, que evitan que alguien inyecte una respuesta falsa o reutilice una vieja.
  • El token de identidad se valida del lado del servidor: emisor, destinatario, vencimiento, nonce y que el email esté verificado.
  • Después de iniciar sesión se regenera el ID de sesión, para evitar ataques de fijación de sesión.
  • El acceso al panel se decide por una lista de emails que se revisa en cada pedido: si saco a alguien de la lista, pierde el acceso al instante.

💡 Si tu sitio depende del login de Google, la seguridad real pasa a ser la de tu cuenta de Google: activá la verificación en dos pasos.

Cabeceras de seguridad

Es lo que más puntaje da en los analizadores y lo que más protege con menos esfuerzo. Estas son las que manda el sitio:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://challenges.cloudflare.com; style-src 'self' https://cdnjs.cloudflare.com https://fonts.googleapis.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self' https://accounts.google.com
Strict-Transport-Security: max-age=31536000
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin

(La CSP real tiene un par de directivas más para imágenes, fuentes y videos; acá dejé lo importante).

La CSP, sin unsafe-inline

La Content Security Policy le dice al navegador de dónde puede cargar scripts, estilos e imágenes. Sin 'unsafe-inline' en script-src, aunque alguien lograra inyectar HTML, el navegador no ejecuta su código: es la defensa más fuerte contra XSS.

Los estilos tienen la misma lógica. Dos entradas viejas traen su propio diseño con CSS embebido, así que hice que la política cambie según la página: en todo el sitio los estilos inline están bloqueados, y solo esas dos entradas tienen el permiso. Lo que antes resolvía con style="width: 40%" en el HTML, ahora lo aplica el JavaScript del sitio, que la CSP sí permite.

💡 Para encontrar qué rompe una CSP antes de publicarla, abrí la consola del navegador: cada bloqueo aparece con el mensaje Refused to… y la directiva que lo frenó.

Una decisión de diseño: COEP

Los analizadores también sugieren Cross-Origin-Embedder-Policy. La dejé afuera a propósito: exige que cada recurso de terceros autorice explícitamente su carga, y eso rompería los videos de YouTube, las fotos de perfil de Google en los comentarios y las tipografías. Solo hace falta para funciones muy específicas que este sitio no usa. Una cabecera de seguridad que rompe el sitio no es una mejora.

Formularios: cuatro capas contra el abuso

El formulario de contacto y los comentarios tienen varias capas, y ninguna depende de la otra:

  1. Token CSRF en cada formulario: un envío que no venga del propio sitio se rechaza.
  2. Cloudflare Turnstile en el contacto: verifica que haya una persona sin hacerle resolver acertijos.
  3. Campo trampa invisible: las personas no lo ven, los bots lo completan y se delatan.
  4. Límite de envíos por conexión. Para eso guardo un hash de la IP con una clave secreta (no la IP en sí) y se borra a las 24 horas.

Y lo que escriben los usuarios se muestra siempre escapado: un comentario con <script> aparece como texto, no se ejecuta. Además, el primer comentario de cada persona queda para moderar.

Imágenes subidas: nada de código

Subir archivos es una de las puertas favoritas de los atacantes. Acá cada imagen:

  • Se verifica que realmente sea una imagen (no solo la extensión).
  • Se vuelve a codificar desde cero, lo que elimina metadatos y cualquier contenido escondido.
  • Se guarda en una carpeta donde el servidor no ejecuta código:
# wp-content/.htaccess: en uploads solo se sirven archivos estáticos
<FilesMatch "\.(?i:php\d?|phtml|phar|pl|py|cgi|sh|html?|svg)$">
  Require all denied
</FilesMatch>

Lo probé subiendo a propósito un .php, un .html y un .svg de prueba: ninguno se pudo abrir.

Lo que no se ve: archivos internos y errores

  • La configuración, el código y las sesiones están fuera del alcance de la web: piden 403 Forbidden.
  • El archivo de configuración tiene permisos 600: solo lo lee el usuario del sitio.
  • Los errores nunca muestran rutas, versiones ni detalles internos.
  • La cookie de sesión va con Secure, HttpOnly y SameSite=Lax: no viaja por HTTP, JavaScript no la puede leer y no se manda desde otros sitios.

La red: Cloudflare y el WAF del hosting

El sitio está detrás de Cloudflare, que da HTTPS, caché y protección contra ataques de volumen. Delante del servidor, además, el hosting tiene su propio WAF: mientras hacía las pruebas de seguridad me bloqueó por unos minutos por "escanear" demasiado rápido. Molesto en el momento, pero es exactamente lo que tiene que hacer.

Para cifrar también el tramo entre Cloudflare y el servidor instalé un certificado de origen de Cloudflare y el modo SSL quedó en Full: todo el recorrido va cifrado. El nivel siguiente, Full (strict), además valida ese certificado; en mi caso depende de que el proveedor de hosting lo aplique también en su proxy, así que quedó como mejora para cuando lo habiliten.

💡 Si usás Cloudflare, revisá el modo SSL: con Flexible, el tramo entre Cloudflare y tu servidor viaja sin cifrar, aunque el navegador muestre el candado.

Transparencia: security.txt y privacidad

Dos detalles que suman confianza:

  • Un archivo /.well-known/security.txt (RFC 9116) que indica cómo reportar una vulnerabilidad. Los investigadores de seguridad lo buscan.
  • Una política de privacidad que dice exactamente qué datos se guardan, para qué y cómo pedir que se borren.

Checklist para tu sitio

Si querés hacer lo mismo, este es el orden en que lo encararía:

  • HTTPS en todo el recorrido (y si usás Cloudflare, nada de modo Flexible).
  • Cabeceras: HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy.
  • CSP sin unsafe-inline en scripts. Probala con la consola del navegador abierta.
  • Tokens CSRF en todos los formularios y límite de envíos.
  • Nada ejecutable en las carpetas de subidas.
  • Archivos de configuración fuera del alcance de la web y con permisos restrictivos.
  • Errores sin detalles en producción.
  • Verificación en dos pasos en todas las cuentas que dan acceso al sitio.
  • Analizá el resultado en Mozilla HTTP Observatory y securityheaders.com.

En resumen

Salir de WordPress no fue solo un cambio de diseño: fue la oportunidad de construir el sitio con la seguridad pensada desde el principio. No hay contraseñas guardadas, los scripts ajenos no se ejecutan, los formularios tienen varias capas contra el abuso y nada de lo que se sube puede ejecutarse. La nota A+ es la consecuencia, no el objetivo: el objetivo es que el sitio sea difícil de atacar y fácil de mantener.

Comentarios

Todavía no hay comentarios. ¡Sé el primero!

Seguí leyendo