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.
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.
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.
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.
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.
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.
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.
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 < > & ' ".
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.
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.
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.
/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.
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.
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.
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.
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.
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).
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.
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.
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.
Comprobaciones que se pueden repetir en cualquier web.
curl -sI https://hjs.es | grep -iE "strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy"
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.
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.
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.