Libro de visitas seguro: validación, spam y escape de HTML
Procesa los envíos del formulario de un libro de visitas como los recibiría PHP: valida con filter_var, rechaza enlaces javascript:, descarta el spam con un campo trampa, limita los mensajes por email y publica el resto en HTML escapado para que ningún mensaje pueda inyectar código.
- parse_str y $_POST
- filter_var
- htmlspecialchars y XSS
- Campo trampa (honeypot)
- mb_strlen
- Arrays asociativos
Enunciado
La web de una casa rural tiene un libro de visitas: los huéspedes dejan su nombre, su email, su web (opcional) y un mensaje, y los mensajes se publican en la página. Es el ejemplo perfecto de por qué no hay que fiarse de nada de lo que llega en un formulario.
Un visitante malintencionado puede escribir <script> en el mensaje o poner javascript:alert(1) como web: si se publica tal cual, el código se ejecuta en el navegador de todos los que vean la página (un ataque XSS). La defensa es doble: validar al recibir y escapar al mostrar con htmlspecialchars.
Además, los robots de spam rellenan todos los campos que encuentran. Un truco eficaz es el campo trampa (honeypot): un campo telefono2 que se oculta con CSS, que ninguna persona ve ni rellena. Si llega con algo, el envío es de un robot.
Qué tiene que hacer el programa
- Cada línea no vacía es un envío del formulario codificado como lo envía el navegador (
nombre=Ana&email=ana%40correo.es&mensaje=Hola), con los campos nombre, email, web, mensaje y telefono2. Los envíos se numeran desde 1. A todos los campos se les quitan los espacios de los extremos. - Si
telefono2no está vacío, escribeMensaje N descartado: spamy no se valida nada más. - Reglas, en este orden: nombre obligatorio y de 2 a 50 caracteres; email obligatorio y válido según
FILTER_VALIDATE_EMAIL; web opcional, pero si se escribe tiene que ser una URL válida con esquema http o https (solo direcciones http o https); mensaje obligatorio y de 200 caracteres como mucho. Los mensajes de error sonobligatorio,entre 2 y 50 caracteres,no es un email válido,solo direcciones http o httpsycomo mucho 200 caracteres. - Un email (sin distinguir mayúsculas) solo puede publicar 2 mensajes: si el envío es correcto pero ese email ya tiene 2 publicados, el error es
email: ya ha enviado 2 mensajes. - Por cada error,
Mensaje N rechazado: campo: motivo. Al final, la lista HTML de los mensajes publicados en orden:<ul class="visitas">, una línea por mensaje<li><strong>nombre</strong> (<a href="web" rel="nofollow ugc">web</a>): mensaje</li>(sin el paréntesis si no hay web) y</ul>, con el nombre, la web y el mensaje escapados conhtmlspecialchars(ENT_QUOTES | ENT_HTML5). Después,Publicados: P · Rechazados: R · Spam: S.
Entrada
Un envío del formulario por línea, como query string (campo=valor&campo=valor).
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Cinco envíos
Entrada
nombre=Ana+Gil&email=ana%40correo.es&web=https%3A%2F%2Fana.es&mensaje=Un+sitio+precioso%2C+volveremos+%3C3&telefono2= nombre=Hacker&email=h%40x.com&web=javascript%3Aalert(1)&mensaje=%3Cscript%3Ealert(1)%3C%2Fscript%3E&telefono2= nombre=Bot&email=bot%40spam.com&mensaje=Compra+ya&telefono2=600123123 nombre=Seán+O%27Brien&email=SEAN%40mail.ie&mensaje=%3Cb%3EGreat%3C%2Fb%3E+%26+quiet&telefono2= nombre=A&email=no-es-email&mensaje=
Salida por consola
Mensaje 2 rechazado: web: solo direcciones http o https Mensaje 3 descartado: spam Mensaje 5 rechazado: nombre: entre 2 y 50 caracteres Mensaje 5 rechazado: email: no es un email válido Mensaje 5 rechazado: mensaje: obligatorio <ul class="visitas"> <li><strong>Ana Gil</strong> (<a href="https://ana.es" rel="nofollow ugc">web</a>): Un sitio precioso, volveremos <3</li> <li><strong>Seán O'Brien</strong>: <b>Great</b> & quiet</li> </ul> Publicados: 2 · Rechazados: 2 · Spam: 1
Límite de mensajes por email
Entrada
nombre=Eva&email=eva%40x.es&mensaje=Uno nombre=Eva&email=EVA%40x.es&mensaje=Dos nombre=Eva&email=eva%40x.es&mensaje=Tres
Salida por consola
Mensaje 3 rechazado: email: ya ha enviado 2 mensajes <ul class="visitas"> <li><strong>Eva</strong>: Uno</li> <li><strong>Eva</strong>: Dos</li> </ul> Publicados: 2 · Rechazados: 1 · Spam: 0
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Lo que recibe PHP
Un formulario enviado por POST llega como nombre=Ana&mensaje=Hola+mundo, con los caracteres especiales codificados (%40 es @, + es un espacio). PHP lo decodifica y lo deja en $_POST; aquí haces lo mismo con parse_str($linea, $f).
2. Validar con filter_var
filter_var($email, FILTER_VALIDATE_EMAIL) devuelve el email o false. Con las URL no basta con FILTER_VALIDATE_URL: acepta javascript:alert(1). Comprueba además el esquema con parse_url($web, PHP_URL_SCHEME).
if ($web !== "" && (filter_var($web, FILTER_VALIDATE_URL) === false
|| !in_array(strtolower(parse_url($web, PHP_URL_SCHEME)), ["http", "https"], true))) {
$e["web"] = "solo direcciones http o https";
}3. Longitudes con mb_strlen
strlen("Íñigo") vale 7 porque cuenta bytes; mb_strlen cuenta caracteres. Para límites de longitud que ve el usuario, siempre mb_.
4. Escapar al mostrar
Todo lo que venga del usuario y se escriba en HTML pasa por htmlspecialchars($texto, ENT_QUOTES | ENT_HTML5, "UTF-8"): < se convierte en <, las comillas en " y '… y el navegador lo muestra como texto en lugar de interpretarlo. Hazte una función corta, h(), para usarla sin pereza.
5. El límite por email
Lleva un array email → mensajes publicados (con el email en minúsculas). Compruébalo solo cuando el resto del envío es correcto, y suma 1 al publicar.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
nombre=Ana+Gil&email=ana%40correo.es&web=https%3A%2F%2Fana.es&mensaje=Un+sitio+precioso%2C+volveremos+%3C3&telefono2= nombre=Hacker&email=h%40x.com&web=javascript%3Aalert(1)&mensaje=%3Cscript%3Ealert(1)%3C%2Fscript%3E&telefono2= nombre=Bot&email=bot%40spam.com&mensaje=Compra+ya&telefono2=600123123 nombre=Seán+O%27Brien&email=SEAN%40mail.ie&mensaje=%3Cb%3EGreat%3C%2Fb%3E+%26+quiet&telefono2= nombre=A&email=no-es-email&mensaje=
Mensaje 2 rechazado: web: solo direcciones http o https Mensaje 3 descartado: spam Mensaje 5 rechazado: nombre: entre 2 y 50 caracteres Mensaje 5 rechazado: email: no es un email válido Mensaje 5 rechazado: mensaje: obligatorio <ul class="visitas"> <li><strong>Ana Gil</strong> (<a href="https://ana.es" rel="nofollow ugc">web</a>): Un sitio precioso, volveremos <3</li> <li><strong>Seán O'Brien</strong>: <b>Great</b> & quiet</li> </ul> Publicados: 2 · Rechazados: 2 · Spam: 1
Solución explicada
Ver la solución completa
1<?php
2const MAX_POR_EMAIL = 2;
3
4/** Errores de un mensaje (campo → motivo), en el orden de los campos. */
5function validar(array $f): array {
6 $e = [];
7 $nombre = trim($f["nombre"] ?? "");
8 if ($nombre === "") $e["nombre"] = "obligatorio";
9 elseif (mb_strlen($nombre) < 2 || mb_strlen($nombre) > 50) $e["nombre"] = "entre 2 y 50 caracteres";
10
11 $email = trim($f["email"] ?? "");
12 if ($email === "") $e["email"] = "obligatorio";
13 elseif (filter_var($email, FILTER_VALIDATE_EMAIL) === false) $e["email"] = "no es un email válido";
14
15 // La web es opcional, pero solo http o https: «javascript:» en un enlace es una puerta a XSS
16 $web = trim($f["web"] ?? "");
17 if ($web !== "" && (filter_var($web, FILTER_VALIDATE_URL) === false || !in_array(strtolower(parse_url($web, PHP_URL_SCHEME)), ["http", "https"], true))) {
18 $e["web"] = "solo direcciones http o https";
19 }
20
21 $mensaje = trim($f["mensaje"] ?? "");
22 if ($mensaje === "") $e["mensaje"] = "obligatorio";
23 elseif (mb_strlen($mensaje) > 200) $e["mensaje"] = "como mucho 200 caracteres";
24 return $e;
25}
26
27/** Escapa para HTML: <, >, &, comillas dobles y simples. */
28function h(string $texto): string {
29 return htmlspecialchars($texto, ENT_QUOTES | ENT_HTML5, "UTF-8");
30}
31
32$publicados = [];
33$porEmail = [];
34$rechazados = $spam = 0;
35$n = 0;
36foreach (explode("\n", stream_get_contents(STDIN)) as $linea) {
37 if (trim($linea) === "") continue;
38 $n++;
39 parse_str(trim($linea), $f);
40 // Campo trampa: está oculto con CSS, así que solo lo rellenan los robots
41 if (($f["telefono2"] ?? "") !== "") {
42 $spam++;
43 echo "Mensaje $n descartado: spam\n";
44 continue;
45 }
46 $errores = validar($f);
47 $email = strtolower(trim($f["email"] ?? ""));
48 if (!$errores && ($porEmail[$email] ?? 0) >= MAX_POR_EMAIL) $errores["email"] = "ya ha enviado " . MAX_POR_EMAIL . " mensajes";
49 if ($errores) {
50 $rechazados++;
51 foreach ($errores as $campo => $motivo) echo "Mensaje $n rechazado: $campo: $motivo\n";
52 continue;
53 }
54 $porEmail[$email] = ($porEmail[$email] ?? 0) + 1;
55 $publicados[] = $f;
56}
57
58echo "<ul class=\"visitas\">\n";
59foreach ($publicados as $f) {
60 $autor = "<strong>" . h(trim($f["nombre"])) . "</strong>";
61 $web = trim($f["web"] ?? "");
62 if ($web !== "") $autor .= " (<a href=\"" . h($web) . "\" rel=\"nofollow ugc\">web</a>)";
63 echo " <li>$autor: " . h(trim($f["mensaje"])) . "</li>\n";
64}
65echo "</ul>\n";
66echo "Publicados: " . count($publicados) . " · Rechazados: $rechazados · Spam: $spam\n";La seguridad tiene dos momentos: al recibir (validar que cada dato es lo que dice ser) y al mostrar (escapar para el contexto donde se escribe). Validar no sustituye a escapar: un nombre como O'Brien <3 es válido y aun así hay que escaparlo.
htmlspecialchars con ENT_QUOTES escapa también las comillas simples, necesarias cuando el valor va dentro de un atributo como href. Por eso el enlace se escribe con el valor escapado y, además, solo se aceptan esquemas http y https.
El campo trampa detecta robots sin molestar a las personas (a diferencia de un CAPTCHA). No es infalible, pero combinado con el límite por email frena la mayor parte del spam automático.
rel="nofollow ugc" le dice a los buscadores que el enlace lo ha puesto un usuario: así los spammers no ganan posicionamiento enlazando desde tu web.
Para ir más allá
- Guarda los mensajes en una base de datos con PDO y publícalos solo cuando un administrador los apruebe.
- Añade un token CSRF al formulario y compruébalo al recibir el envío.
- Admite un formato mínimo en el mensaje (negrita con **texto**) convirtiéndolo a HTML después de escapar, nunca antes.