Uso indebido de la función mail() de PHP

uso indebido cabecera email Con el formulario de contacto de ejemplo del tema anterior me propongo realizar algunas pruebas de usos indebidos de la función mail() de PHP. Usaré el servidor de correo Mercury montado a modo de localemail y dos aplicaciones MUA (Mail User Agent) Outlook Express y Pegasus Mail para que se conecten mediante POP3 a ese servidor. Los usuarios de prueba serán admin@smtp.localemail que consultaré desde Outlook Express y user2@smtp.localemail desde Pegasus Mail.

La prueba consistirá en enviar un mensaje desde el formulario de contacto insertando indebidamente una cabecera CC en el campo del email, como se observa en la imagen. Recuerde que configuramos el script de la función mail() de PHP para que nos remitiera un correo a nuestra dirección admin@smtp.localemail. También comenté que el campo para el email es un <textarea>, por lo que tras la primera dirección user1@smtp.localemail podemos poner un salto de línea y Cc: user2@smtp.localemail. De esta forma estamos enviando una copia del correo a ese otro usuario . Esto es lo que vemos en los dos MUA's, en el Outlook Express (donde muestra la hora 11:31 del envío desde el POP3):

uso indebido cabecera outlook

Y en el Pegasus Mail (la hora 11:30 que muestra es la de la cabecera Date, momento en el que se recibe el mensaje en el SMTP-Server. No es la del envío de POP3 que fue a las 11:31):

uso indebido cabecera pegasus

La duplicación de mensajes se entiende si vemos el estado del cliente POP3 en Mercury. A las 11:31:08 se conecta el usuario admin@smtp.localemail (que está en el Outlook) y el servidor le entrega el mensaje y a su vez remite una copia a user2@smtp.localemail. A las 11:31:09 se conecta el usuario que está en Pegasus user2@localemail, el servidor le entrega el mensaje que va su nombre y envía a su vez una copia a admin@smtp.localemail.

2 cabeceras Mercury

Puede ver una copia de las cabeceras de estos mensajes. En todo caso aquí lo importante no es esta duplicación, sino el hecho de que podrían permitirse enviar múltiples correos a modo de spam. Vea como en la primera de las cabeceras encontramos dos X-Envelope-To, es decir, los dos destinatarios admin@smtp.localemail y user2@smtp.localemail:

Received: from spooler by smtp.localemail (Mercury/32 v4.72); 6 Dec 2011 11:31:11 -0000
X-Envelope-To: admin@smtp.localemail
Received: from POP3D by smtp.localemail with MercuryD (v4.72); 6 Dec 2011 11:31:10 -0000
Received: from spooler by smtp.localemail (Mercury/32 v4.72); 6 Dec 2011 11:31:00 -0000
X-Envelope-To: <user2@smtp.localemail>
Return-path: <php@localhost>
Received: from HP92155003154 (127.0.0.4) by smtp.localemail (Mercury/32 v4.72) ID MG00000A;
   6 Dec 2011 11:30:54 -0000
Date: Tue, 06 Dec 2011 11:30:54 +0000
Subject: Mensaje de contacto
To: admin@smtp.localemail
From: user1@smtp.localemail
Cc: user2@smtp.localemail

El problema es que el campo donde va la dirección email no debe permitir saltos de línea, pues alguién podría insertar una lista de cabeceras Cc para hacer salir múltiples copias del mensaje. En esto y otros agujeros de seguridad se basa el spam. No basta con cambiar el <textarea> por un <input type="text">, el cual suprime los saltos de línea. Por un lado podrían ponernos una lista de direcciones de correo separadas por comas. Por otro lado este ejemplo envía los datos por GET, por lo que podrían también hacer una petición directa con cabeceras Cc, Bcc o con una lista de direcciones. Por ejemplo:

http://.../mail-01.php?nombre=A&email=user1%40smtp.localemail%0D%0A
Cc%3A+user2%40smtp.localemail&mensaje=XXXXXXX&envio=Enviar

Esta cadena la he separado en dos líneas pero realmente es una única línea. Los puntos suspensivos sería la ruta donde se encontraría el script mail-01.php que vimos en el tema anterior. Veáse resaltado el salto de línea codificado para enviar por URL %0D%0A antes de la cabecera de copia Cc. Algo que podemos hacer es cambiar el método de envío a POST, pero aún así se pueden insertar cabeceras. Con algo como Telnet podemos enviar una petición como esta:

POST /.../mail-01.php HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 102

nombre=A&email=user1%40smtp.localemail%0D%0A...&envio=Enviar

Esta petición incluye el tipo de contenido application/x-www-form-urlencoded que es para poner los campos y valores codificados tal como lo hacemos en las peticiones GET. La longitud de 102 caracteres es la que se corresponde con los caracteres de la cadena completa, la cual hemos cortado aquí para simplificar. Antes de esa cadena hay que poner un salto de línea.

Evitar la inserción de cabeceras en mail() de PHP

Por lo tanto sea con GET o POST hemos de impedir que el usuario pueda modificar las cabeceras que incluiremos en la función mail(). Recordemos como era nuestro script en ese punto:

$destino = "admin@smtp.localemail";
$cabeceras = "From: ".$email."\n";
$asunto = "Mensaje de contacto";
$cuerpo = "MENSAJE DEL FORMULARIO DE CONTACTO\n".
          "NOMBRE: ".$nombre."\n".
          "MENSAJE: \n".$mensaje;
$enviado = @mail($destino, $asunto, $cuerpo, $cabeceras);

Para evitar exponer el argumento de $cabeceras (headers) con el email que nos ponga el usuario del formulario de contacto, podemos incluir esa dirección dentro del cuerpo de texto del mensaje:

$destino = "admin@smtp.localemail";
$cabeceras = "From: admin@smtp.localemail\n";
$asunto = "Mensaje de contacto";
$cuerpo = "MENSAJE DEL FORMULARIO DE CONTACTO\n".
          "NOMBRE: ".$nombre."\n".
          "EMAIL: ".$email."\n".
          "MENSAJE: \n".$mensaje;
$enviado = @mail($destino, $asunto, $cuerpo, $cabeceras);

De esta forma cualquier cosa que introduzcan en nuestro formulario de contacto irá a parar al cuerpo del mensaje, no tocándo en ningún caso las cabeceras ni por supuesto la línea del $asunto (subject) que lo dejamos con una cadena fija, pues ahí también podrían insertarse cabeceras con saltos de línea. Hacemos ese cambio en el script anterior y lo llamamos ahora mail-02.php (ver código). Por ejemplo, enviando un mensaje con el formulario de prueba con 4 direcciones en el campo de email y habiendo hecho esos cambios en el script tenemos el siguiente mensaje recibido en el correo del administrador del sitio (la cuenta admin@smtp.localemail):

correo contacto

Todas las direcciones introducidas en el campo $email recibido del formulario van a parar dentro del texto del cuerpo del mensaje. El remitente y el destinatario son ambos admin@smtp.localemail. Por supuesto que en una versión final del formulario de contacto podemos impedir que se ponga más de una dirección en el campo email, pero eso lo veremos a continuación con una versión mejorada del formulario.

Medidas de seguridad para usar mail() en el formulario de contacto

Ahora tenemos una nueva versión del script que controla el formulario. Se trata de la página email-03.php (ver código) que tiene este PHP al inicio

$nombre = "";
$max_longitud_nombre = 50;
$email = "";
$max_longitud_email = 50;
$mensaje = "";
$max_longitud_mensaje = 500;
$form_iniciado = false;
$enviado = false;
$mensaje_error = "";
if (isset($_POST) && isset($_POST["envio"]) && 
    ($_POST["envio"]=="Enviar")){
    foreach($_POST as $campo=>$valor){
        $valor = htmlspecialchars($valor, ENT_QUOTES);
        $longitud = strlen(utf8_decode($valor));
        switch ($campo) {
            case "nombre": 
                $nombre = $valor;
                if ($nombre == ""){
                    $mensaje_error .= "El nombre es requerido.<br />";
                } else if ($longitud > $max_longitud_nombre) {
                    $mensaje_error .= "Nombre sobrepasa ".
                        $max_longitud_nombre." letras.<br />";
                }
                break;
            case "email": 
                $email = $valor;
                $patron = "/^\w+(?:[\-\.]?\w+)*@\w+(?:[\-\.]?\w+)".
                    "*(?:\.[a-zA-Z]{2,4})+$/";
                if ($email == ""){
                    $mensaje_error .= "El email es requerido.<br />";
                } else if ($longitud > $max_longitud_email) {
                    $mensaje_error .= "Email sobrepasa ".
                        $max_longitud_email." letras.<br />";
                } else if (!preg_match($patron, $email)) {
                    $mensaje_error .= "Email no válido.<br />";
                }               
                break;
            case "mensaje":
                $mensaje = $valor;
                if ($mensaje == ""){
                    $mensaje_error .= "El mensaje es requerido.<br />";
                } else if ($longitud > $max_longitud_mensaje) {
                    $mensaje_error .= "Mensaje sobrepasa ".
                        $max_longitud_mensaje." letras.<br />";
                }                
                $mensaje = wordwrap($mensaje, 70, PHP_EOL, true);                
                break;
        }
    }
    if ($mensaje_error == ""){
        $form_iniciado = true;
        $destino = "admin@smtp.localemail";
        $cabeceras = "From: admin@smtp.localemail".PHP_EOL.
                     "X-Mailer: PHP-mail".PHP_EOL.
                     "MIME-Version: 1.0".PHP_EOL.
                     "Content-type: text/plain; charset=UTF-8".PHP_EOL.
                     "Content-transfer-encoding: 8BIT".PHP_EOL;
        $asunto = "Mensaje de contacto";
        $cuerpo = "MENSAJE DEL FORMULARIO DE CONTACTO".PHP_EOL.
                  "NOMBRE: ".$nombre.PHP_EOL.
                  "EMAIL: ".$email.PHP_EOL.
                  "MENSAJE: ".PHP_EOL.$mensaje;
        $enviado = @mail($destino, $asunto, $cuerpo, $cabeceras);
        if (!$enviado){
            $mensaje_error = "No se pudo enviar el mensaje. ";
            //En producción no debería mostrarse el error
            $arr_error = error_get_last();
            $mensaje_error .= $arr_error["message"];   
        }
    }
}

En este ejemplo escapamos en los valores recibidos todas las referencias a caracteres reservados de HTML con la función htmlspecialchars(). Es una medida extra de seguridad más bien orientada a que esos valores se van a devolver en la misma página en caso de error o cuando se finalice el envío correcto del formulario. Puede ver más sobre esto en este mismo sitio en filtrar entradas con htmlspecialchars(). También controlamos las longitudes máximas de los campos contando los caracteres con $longitud=strlen(utf8_decode($valor)). Sobre esto comentaré algo más en un apartado posterior. También forzamos a que se nos envíe una dirección de email correcta. Aunque luego la vamos a insertar en el cuerpo y por tanto no importaría que no lo fuera, es obvio que esperamos que ahí haya una dirección de email y no otra cosa. El patrón usado es:

/^\w+(?:[\-\.]?\w+)*@\w+(?:[\-\.]?\w+)*(?:\.[a-zA-Z]{2,4})+$/

Puede ver más sobre esto en el tema de validar formularios, en la sección de expresiones regulares. En este punto he de decir que estoy presentando este ejemplo sin hacer uso del script para validar formularios expuesto en esos temas. La razón es que quiero ir viendo los pormenores antes de pasar a usar ese sistema de validación con el formulario de contacto ya en producción en este sitio.

Siguiendo con este script pasamos a validar el campo $mensaje. Por un lado controlamos el tamaño máximo. Luego recortamos a líneas de 70 caracteres. En principio la especificación RFC5322 (la que sustituye a la 2822) dice que una línea no puede tener más de 998 caracteres (1000 con el salto CRLF), aunque en todo caso no debería tener más de 78 (80 con CRLF). Al mismo tiempo en la página del manual de PHP de la función mail() pone un ejemplo haciendo un recorte de línea a 70 caracteres. El caso es que es posible que los MUA que reciben y muestran el correo podrían gestionar líneas incluso más largas de los 1000 caracteres. Pero sinceramente no sé si podría afectar en algo, por lo que para empezar es mejor hacer ese recorte.

La función wordwrap($mensaje, 70, PHP_EOL, true) recorta en líneas de 70 caracteres insertando un salto de línea que viene definido por la constante PHP_EOL propia de PHP. Es tal que el salto de línea queda definido según donde actúe PHP (p.e., CRLF en Windows y LF en Unix). El último argumento ordena que corte la palabra aún el caso de que tenga más de 70 caracteres.

Si no hay ningún mensaje de error pasamos a enviar el mensaje. Las cabeceras se separan por un salto de línea, usándose la constante PHP_EOL. Aparte de la cabecera From: admin@smtp.localemail donde ponemos la misma dirección que en $destino, hemos puesto también X-Mailer que nos servirá para identificar (con ese o cualquier otro término) este correo como proveniente de nuestro formulario. Luego vienen tres cabeceras para configurar la codificación de caracteres. Se trata de MIME-Version, Content-type y Content-transfer-encoding de 8 bits para UTF-8. La cabecera MIME es necesaria para activar las características de codificación distintas a ASCII. El tipo de contenido en este caso es text/plain pues no deseamos que nos envíen código HTML que sería con text/html. El charset=UTF-8 coincide con el de la página y por tanto el del formulario.

Por último señalar que con @mail() desactivamos que se muestre cualquer posible error. Aunque mientras lo probamos en localhost lo recuperamos con error_get_last() para luego mostrarlo.

Codificación UTF-8 en el formulario

En cuanto al HTML que le sigue al script anterior es igual que el del ejemplo del tema anterior, pero sólo cambia lo que aparece resaltado

...
<body>
    <h3>Formulario de contacto</h3>
    <?php if(!$form_iniciado){ ?>
        <form action="mail-03.php" method="post" 
        accept-charset="utf-8">
        ...
    ...
</body>       
</html>

Aquí cambiamos el GET por el POST. Aunque éste método no evita que alguién use algo como Telnet para hacer peticiones, tiene la ventaja de que los campos no aparecen en la URL. Los buscadores a veces indexan estas URL y no siempre conviene exponer datos privados de esta forma en índices públicos.

El atributo accept-charset fuerza al navegador a que use sólo la codificación dada UTF-8 para los datos del formulario. Si este atributo no está presente el navegador usa la codificación de la página. Podría pensarse que no es necesario hacer más nada si ya la página está en UTF-8. Pero también es posible que el usuario cambie manualmente la codificación en el menu de herramientas del navegador. Por ejemplo, en Chrome cambiando a una codificación no occidental como Árabe (Windows-1256), remitimos los siguientes valores:

NOMBRE: andrés
EMAIL: abc@def.gh
MENSAJE:
Cañón
Barça

Este mensaje se recibe en Outlook Express con las siguientes cabeceras relacionadas con la codificación, realmente las que pusimos en el script PHP:

...
MIME-Version: 1.0
Content-type: text/plain; charset=UTF-8
Content-transfer-encoding: 8BIT
...

Pero lo que vemos en el correo del Outlook son caracteres que han desaparecido o están sustituidos por su referencia Unicode en los campos de nombre y mensaje:

mala codificación utf8

La razón es que el contenido del formulario se envía con la codificación seleccionada por el usuario Árabe (Windows-1256), de tal forma que la codificación UTF-8 resulta mal formada. Para evitar esto ponemos ese atributo accept-charset en el formulario. Así aunque el usuario cambie la codificación, esto no afecta a los valores del mismo que siguen estando codificados en UTF-8.

La longitud de un texto en UTF-8

Al preparar el script anterior me he dado cuenta de que la medición de longitud de caracteres la estaba haciendo erróneamente con la función de PHP strlen($cadena). Sin embargo para una cadena como "cañón" que contiene la "ñ" y la "ó" que no pertenecen a ASCII códigos 1-127, UTF-8 las codifica con 2 bytes, con lo que la función strlen("cañón") nos da una longitud de 7 caracteres.

utf8 Hace ya tiempo que hice unas pruebas con algoritmos de transformación UTF-8 para entender un poco todo eso. Veámos esto otra vez. La imagen de la izquierda presenta cuatro caracteres UTF-8. Corresponden a los códigos UNICODE con números decimales 65, 937, 35486 y 66436. Su representación de texto es AΩ語𐎄. Quizás los dos últimos caracteres no se presenten en su navegador y se verán como un recuadro o un signo de interrogación. Para verlos es necesario ajustar su navegador para que los represente, pero en todo caso esos caracteres están ahí. ¿Cuál es la longitud de esta cadena de texto?. La respuesta es obvia, tiene 4 caracteres. Pero si usamos la función de PHP strlen($cadena) resulta que nos dará 10 caracteres. Realmente esa función está contando bytes, pues son exactamente 10 los que contiene: 41,CE,A9,E8,AA,9E,F0,90,8E,84 (expresados en hexadecimal).

Para contar los caracteres de una cadena UTF-8 podemos decodificarla primero a ISO-8859-1, que son caracteres codificados en 1 byte (8 bits), en el rango 0-255 (es el equivalente al ASCII extendido con 8 bits). Esa conversión la podemos hacer con la función de PHP utf8_decode() que tomará caracter a caracter UTF-8 para intentar hacerlos corresponder con los 256 de ISO-8859-1. Luego le pasaríamos el strlen() para contarlos. Si hacemos utf8_decode("AΩ語𐎄") obtenemos A???. Los signos de interrogación son los caracteres UTF-8 que PHP no pudo traducir pues no están en la tabla 0-255. Pero a efectos de contar caracteres no nos importa en que se traduzcan, pues haciendo strlen(utf8_decode("AΩ語𐎄")) obtenemos la longitud de 4 caracteres que estamos buscando.