Apuntes DAM
Volver al inicio

Libro de visitas seguro: validación, spam y escape de HTML

Ejercicio de PHPMedioUnos 55 minutos

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

  1. 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.
  2. Si telefono2 no está vacío, escribe Mensaje N descartado: spam y no se valida nada más.
  3. 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 son obligatorio, entre 2 y 50 caracteres, no es un email válido, solo direcciones http o https y como mucho 200 caracteres.
  4. 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.
  5. 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 con htmlspecialchars (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 &lt;3</li>
  <li><strong>Seán O&apos;Brien</strong>: &lt;b&gt;Great&lt;/b&gt; &amp; 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).

php
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 &lt;, las comillas en &quot; y &apos;… 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.

🐘PHPLibro de visitas seguro: validación, spam y escape de HTMLMedio

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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 &lt;3</li>
  <li><strong>Seán O&apos;Brien</strong>: &lt;b&gt;Great&lt;/b&gt; &amp; quiet</li>
</ul>
Publicados: 2 · Rechazados: 2 · Spam: 1
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
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.

Dónde se explica