hjs.es ← inicio

Auditoría y hardening de mi web

Esta web, la de tatuajes y su panel de administración están hechos a mano en PHP, sin frameworks, sobre Plesk. Sin un framework detrás, cada defensa que no escribes simplemente no existe. Así que la audité entera como si fuera de otro: qué se podía leer, falsear o romper desde fuera, y qué capas faltaban.

PHP CSP CSRF HSTS SRI Plesk MariaDB

Contexto

Tres sitios en el mismo dominio: este portfolio (con estadísticas de visitas propias y un panel para editar los proyectos), una web de tatuajes con formulario de contacto, subida de diseños y su propio panel, y una herramienta interna con contraseña. Todos comparten servidor y base de datos, así que un fallo en cualquiera de ellos afectaba a los demás.

El método: leer todo el código buscando entradas que vienen de fuera (formularios, cabeceras, URL, archivos subidos), seguir cada una hasta dónde se guarda o se pinta, y comprobar cada arreglo con pruebas reales en un banco local (PHP y MariaDB portables y un navegador manejado por código), no a ojo.

Qué encontré

✗

Un registro con datos personales dentro de la carpeta pública

El formulario de contacto de la web de tatuajes guardaba cada mensaje (nombre, email, IP) en un archivo de texto dentro de la carpeta pública. Cualquiera que adivinara su nombre podía descargarlo.

Causa: la ruta del log era relativa al propio script, y el script vive en la carpeta que sirve el servidor web. Lo que está ahí dentro es público salvo que algo lo impida expresamente.
Solución: el log se escribe ahora en la raíz del dominio, un nivel por encima de cualquier carpeta pública, y el archivo antiguo se borró del servidor. De paso se limpian los saltos de línea y separadores de lo que escribe el visitante, para que nadie pueda inventarse líneas falsas en el registro.
✗

Los paneles se podían manejar desde otra web (CSRF)

Los paneles están protegidos con la contraseña de Plesk (autenticación básica de HTTP). El problema: el navegador manda esa contraseña siempre que se pide algo a ese dominio, también cuando la petición la lanza otra web. Con el panel abierto en otra pestaña, una página ajena podía borrar proyectos o estadísticas sin que yo hiciera nada.

Solución: un token CSRF aleatorio por sesión en cada formulario que cambia datos, comprobado con hash_equals antes de tocar nada; borrar pasó de un enlace (GET) a un formulario (POST); y la cookie de sesión va con SameSite=Lax, Secure y HttpOnly.
✗

La IP del visitante se podía falsear con una cabecera

Las estadísticas y el límite de envíos del formulario leían la IP de CF-Connecting-IP, la cabecera que añade Cloudflare. Pero cualquiera puede mandar esa cabecera inventada: bastaba con cambiarla en cada petición para saltarse el límite de envíos.

Solución: esa cabecera solo se cree si la conexión llega de verdad desde un rango de IPs de Cloudflare; si no, se usa la IP real de la conexión. Y en ambos casos se valida que sea una IP.
✗

Un enlace del panel podía acabar ejecutando código en la portada (XSS)

La URL de cada proyecto se escribe en el panel y acaba en un enlace público de la portada. El panel aceptaba cualquier texto, también javascript:…, que se ejecuta al hacer clic.

Solución: solo se aceptan URLs http(s):// válidas y las imágenes deben ser una URL o una ruta del propio sitio. Además, todo lo que viene de la base de datos se escapa al pintarlo, y los datos que van a las gráficas del panel se codifican con json_encode escapando < > & ' ".
✗

Si se quitaba la contraseña de Plesk, el panel quedaba abierto

Toda la seguridad de los paneles dependía de una casilla en Plesk. Un cambio de configuración o una migración que se la saltara dejaba el panel entero accesible para cualquiera.

Solución: defensa en profundidad. El propio código comprueba que el servidor le ha pasado un usuario autenticado y, si no, responde 403 antes de abrir la sesión. En la herramienta interna, además, en cuanto el servidor demuestra que pasa la variable que solo pone tras comprobar la contraseña, se deja una marca y una cabecera inventada deja de servir.
✗

Un salto de línea colaba texto que parecía validado

Una prueba automática encontró que, en PHP, el $ de una expresión regular acepta un salto de línea al final: "recidive\n" pasaba como nombre válido de un jail de Fail2ban, justo en la herramienta que genera comandos que luego se pegan como root.

Solución: el modificador /D en todas las validaciones de ese tipo (jail, nombres de archivos subidos, redirecciones). Por la interfaz no llegaba a pasar, pero es la última defensa antes de generar un comando.

Capas añadidas

01

Content Security Policy

El navegador solo ejecuta el JavaScript que viene de los archivos del propio sitio. Si alguien consiguiera colar un <script> en una página, no se ejecutaría. Para eso hubo que sacar a archivos todo el código que iba dentro del HTML (el del panel, con sus gráficas y confirmaciones); el único script que queda en la portada se permite por su huella sha256.

02

Cabeceras de seguridad

Strict-Transport-Security (siempre HTTPS), X-Content-Type-Options: nosniff, X-Frame-Options (nadie puede meter la web en un iframe para engañar con clics), Referrer-Policy y Permissions-Policy sin cámara, micrófono ni ubicación. Van por PHP, no por .htaccess, para no pisar el que gestiona Plesk.

03

Librerías externas con SRI

Chart.js se carga desde una CDN con su hash sha384 (Subresource Integrity): si la CDN sirviera otro archivo, el navegador no lo ejecuta. Hay una CDN de reserva, también con su hash.

04

Subidas de imágenes que no pueden ser otra cosa

Las imágenes que se suben al panel se vuelven a codificar con GD (lo que no sea una imagen de verdad no sobrevive), reciben un nombre aleatorio y se comprueba el tipo real, no la extensión. La carpeta donde acaban no ejecuta PHP.

05

Secretos fuera de la web

Las contraseñas y claves viven en un .env en la raíz del dominio, fuera de cualquier carpeta pública y con permisos 600. Las carpetas de código compartido niegan cualquier acceso directo por URL (Require all denied).

06

Mínimo privilegio en la base de datos

La herramienta interna tiene su propia base de datos, separada de la de la web pública, y el usuario con el que se conecta solo puede leer y escribir filas: no puede borrar tablas ni cambiar su estructura. Si alguien llegara a usarlo, el daño posible es mucho menor.

07

Límites y registro

Los puntos que reciben datos de cualquiera (estadísticas, clics, formulario) limitan cuántas peticiones aceptan por IP; los de estadísticas, además, solo aceptan las que vienen del propio sitio. Los cambios hechos desde el panel quedan apuntados en un registro fuera de la web: fecha, usuario, IP y qué se hizo.

Resultado

Ningún dato personal accesible desde fuera, paneles que no dependen de una sola barrera, entradas validadas en el servidor (no solo en el formulario) y un navegador que se niega a ejecutar código que no sea el mío. Cada arreglo se comprobó con pruebas automáticas contra una copia local: peticiones desde otro origen, tokens que faltan, cabeceras inventadas, archivos disfrazados y textos con saltos de línea.

Lo que más se aprende auditando lo propioLos fallos graves no estaban en el código "difícil", sino en suposiciones: que una carpeta no es pública, que una cabecera dice la verdad o que una casilla de Plesk siempre estará marcada.

Notas rápidas

Comprobaciones que se pueden repetir en cualquier web.

Ver las cabeceras de seguridad de una web
curl -sI https://hjs.es | grep -iE "strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy"
Huella sha256 de un script dentro del HTML (para la CSP)

El texto tiene que ser exactamente el que va entre <script> y </script>, espacios incluidos:

printf '%s' "document.documentElement.classList.add('js');" | openssl dgst -sha256 -binary | base64

El resultado va en la CSP como 'sha256-…'. Si cambia una letra del script, cambia la huella y el navegador deja de ejecutarlo.

Hash SRI de una librería de una CDN
curl -s https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js | openssl dgst -sha384 -binary | base64

Va en integrity="sha384-…" junto con crossorigin="anonymous". Al cambiar de versión, hay que recalcularlo.

Comprobar que un archivo privado no se puede descargar
curl -s -o /dev/null -w "%{http_code}\n" https://hjs.es/includes/db.php

Debe dar 403 (o 404). Un 200 significa que el servidor lo está sirviendo.