Qué es CSRF Cross-Site Request Forgeries

Después de haber visto como XSS se aprovecha de entradas de datos no filtradas para inyectar código que ejecutan scripts no deseados, ahora contemplaremos otra posibilidad de uso indebido de nuestros formularios. El término CSRF Cross-Site Request Forgeries puede traducirse como Falsificaciones de Peticiones Entre Sitios, aunque de forma resumida podríamos denominarlas como Falsificaciones de Peticiones.

La forma en la que un navegador se comunica con un servidor web se basa en el protocolo HTTP. Se trata de un sistema de peticiones del usuario y respuestas del servidor. Éste responde a las peticiones sin preguntarse de donde vienen. Para mantener la integridad entre peticiones y respuestas, esto es, crear un canal independiente para cada comunicación usuario-servidor, deben usarse otros mecanismos añadidos al protocolo HTTP, como puede ser el uso de sesiones con PHP. Pero en cualquier caso al el servidor le resultará indistinguible si unos datos de entrada de un formulario provienen de una página legítima de su sitio o de un formulario falsificado, a no ser que se tomen las medidas adecuadas.

¿Y qué problemas podría haber si recibimos un formulario falsificado? Supongamos que tengo un formulario donde recibo órdenes de compra de productos (podría hacerse con sesiones y autenticación, aunque no evitará el CSRF). Aparte de campos con los datos del cliente y de productos que quiere comprar, habrá alguno destinado a recoger el domicilio donde serán envíados estos productos. Si un atacante pudiera falsificar el envío de este formulario, solicitaría una compra con datos de otro usuario legitimo y falsearía el domicilio de envío. Así recibiría en su casa la compra que pagaría otro.

¿Cómo podría un atacante falsificar la petición? Una forma simple es enviando un email HTML a un usuario. El atacante podría incluir un elemento img con la URL compuesta tal como la requiere el sitio, donde abreviamos el contenido de los parámetros para simplificar este trozo de código:

<img src="http://sitio-de-compras.com/comprar.php?PHPSESSID=IS&
articulos=AR&domicilio-envio=DE&... />

Por supuesto si el sitio requiere un identificador de sesión (IS), debería suministrarle uno válido, para lo cual podría usar XSS para obtenerlo, como vimos en el tema I, en el apartado sobre el uso de XSS para robar cookies. Luego acompañaría la lista de parámetros tal como lo requiere el sitio, los artículos que va a comprar (AR) y el domicilio de envío (DE), que pondría el suyo. Cuando el usuario legítimo abra este email, si en ese momento tiene una sesión válida en el sitio y el atacante ha podido robarla, el usuario legítimo estará haciendo una petición legítima pero falseada por el atacante. Al servidor le resultará indistinguible y al final las mercancías acabarán en la casa del atacante. Cuando al usuario le llegue la factura por la compra podrá tener problemas con la reclamación, puesto que no podrá demostrar que no fue él quien efectuó la petición.

Si el XSS parece en principio más fácil de evitar, las falsificaciones de peticiones o CSRF puede resultar más complejo. El objetivo es tratar de evitar los formularios falsificados, es decir, impedir que el servidor responda a solicitudes no identificadas. Para entendernos con las nomenclaturas, diremos que un formulario envía datos que son parejas con un un nombre de campo y un valor. Entonces debemos hacer lo siguiente:

  • Sólo recibir campos esperados.
  • Verificar que los datos (campos y valores) recibidos se ajustan a los requeridos. (Esto lo veremos en un capítulo aparte donde validamos los datos recibidos de un formulario)
  • Sólo recibir formularios identificados.

Recibiendo campos no esperados

Después de filtrar los caracteres reservados de HTML de los valores recibidos en un formulario con htmlspecialchars() según vimos en el tema anterior para evitar XSS, el siguiente paso es recoger sólamente los campos que se esperan. Veámos un ejemplo de formulario donde no se lleva a cabo este control. Esta página PHP, que se llama a si misma al enviar el botón del formulario, tiene este contenido:

<!DOCTYPE html ...>
<html ...>
    ...
    <?php 
    if (isset($_GET["envio"])) {
        echo echo "<p>El usuario nos ha enviado los siguientes datos:</p><ul>";
        foreach($_GET as $campo => $valor){
            $campo = htmlspecialchars($campo, ENT_QUOTES);
            $valor = htmlspecialchars($valor, ENT_QUOTES);
            echo "<li>".$campo."= ".$valor."</li>";
        }
        echo "</ul>";
    } else {?>
        <form action="<?php $_SERVER["PHP_SELF"]; ?>" method="get">
            <label>Nombre: <input type="text" name="nombre" value="" />
                </label><br />
            <label>Dirección: <input type="text" name="direccion" value="" />
                </label><br />
            <label>Email: <input type="text" name="email" value="" />
                </label><br />            
            <input type="submit" name="envio" value="enviar" />
        </form> 
        <?php 
    }?>    
    ...
</html>

En este código se detecta si se ha recibido el botón de envío, cuyo nombre y valor es envio=enviar. En ese caso "pinta" todos los campos recibidos, incluido el de ese botón. Al observar la página de resultado podemos ver que funciona bien, devolviendo los campos que se envían. Pero en el servidor no podemos estar seguros que los valores recibidos se corresponden con ese formulario. Si en el ejemplo anterior envíamos los valores 1, 2, 3 en cada uno de los campos, la respuesta en el navegador nos dará lo siguiente en la barra de direcciones:

get

Se observa que la llamada hecha por el navegador desde el formulario fue .../action-mal.php?nombre=1&direccion=2&email=3&envio=enviar, separando cada pareja campo=valor con el ampersand & y agregando toda la cadena con el signo ? a la URL. Pero ¿qué sucede si desde esa misma barra de direcciones ponemos otra cosa?. Por ejemplo:

.../action-mal.php?uncampo=a&otrocampo=b&envio=enviar

Con esto obtenemos en esa página de respuesta esos campos no esperados o indebidos que se pusieron en la barra de direcciones del navegador. Esto puede parecer que no es grave, pero es una práctica que debemos desechar. En este caso no hay efectos pues lo único que hacemos con los valores recibidos es devolverlos. Pero en una situación real esos valores irán destinados a otros procesos y hemos de estar seguros que lo que se recibe es exactamente lo que se ha pedido.

Puede evitarse que utilicen la barra de direcciones del navegador si usamos el método POST en lugar de GET. Pero eso no impide que cualquiera pueda enviar lo que desee al servidor. Por ejemplo, otra forma de enviar campos no esperados es usando Telnet. Se trata de una aplicación para conectarse con un servidor, entre otras cosas, y hacer solicitudes GET, POST etc. Hice algunas pruebas que expongo en la sección de herramientas, donde hay un ejemplo de uso con telnet en Windows enviando parámetros por URL.

En definitiva, el protocolo HTTP está preparado para conectarse con el servidor y enviar cualquier cosa, pero es el servidor quién debe controlar lo que se recibe.

Identificando campos esperados

Por lo tanto el asunto está en identificar los nombres de los campos recibidos y que éstos son los que esperamos. Volvemos a repetir el formulario en un nuevo ejemplo con un código PHP diferente:

<?php
//Vemos si tiene magic_quotes activado el servidor
$magicq = (get_magic_quotes_gpc() == 1);
//Iniciamos las variables que contendrán los valores enviados por el usuario
$un_nombre = "";
$una_direccion = "";
$un_email = "";
//Iniciamos recogiendo los GET enviados por el usuario
foreach($_GET as $campo=>$valor){
    switch ($campo) {
        case "nombre":
            //La función htmlspecialchars() evita que
            //el usuario envíe caracteres no deseados
            $un_nombre = htmlspecialchars($valor, ENT_QUOTES);
            //Si el servidor tiene magic_quotes_gpc activado, quitamos
            //las barras invertidas
            if ($magicq) $un_nombre = stripslashes($un_nombre);         
            break;
        case "direccion":
            $una_direccion = htmlspecialchars($valor, ENT_QUOTES);
            if ($magicq) $una_direccion = stripslashes($una_direccion);         
            break;          
        case "email":
            $un_email = htmlspecialchars($valor, ENT_QUOTES);
            if ($magicq) $un_email = stripslashes($un_email);         
            break;          
    }
} 
//Ahora componemos la página de salida
?>
<!DOCTYPE html ...
<html ...
    ...
    <?php if (isset($_GET["envio"])) {?>
        <p>El usuario nos ha enviado los siguientes datos:</p>
        <ul>
            <li>Nombre: <?php echo $un_nombre; ?></li>
            <li>Dirección: <?php echo $una_direccion; ?></li>
            <li>Email: <?php echo $un_email; ?></li>                
        </ul>
    <?php } else { ?>
        <form action="<?php $_SERVER["PHP_SELF"]; ?>" method="get">
            <label>Nombre: <input type="text" name="nombre" value="" />
                </label><br />
            <label>Dirección: <input type="text" name="direccion" value="" />
                </label><br />
            <label>Email: <input type="text" name="email" value="" />
                </label><br />            
            <input type="submit" name="envio" value="enviar" />
        </form> 
    <?php } ?>
    ...    
    

Se observan las siguientes medidas de seguridad:

  1. Vemos si magic_quotes_gpc está activado en el servidor con objeto de quitar las barras invertidas con stripslashes(). Puede ver mas sobre esto en un capítulo posterior, en los apartados qué es magic-quotes y deshacer magic-quotes activado con stripslashes().
  2. Inicializar expresamente las variables que van a recoger los campos del formulario. Conviene no usar el mismo nombre que el del campo. La razón de esto se explica en ese capítulo posterior sobre qué es register-globals.
  3. Usar un bucle foreach que recoga los campos de la variable array GET (o POST preferiblemente), formada por parejas $campo => $valor. Así en la variable temporal $campo tendremos los nombres de los campos del formulario tal como se pusieron en el HTML. En la variable temporal $valor tendremos el valor recibido.
  4. Filtrar sólo los campos esperados con un selector como el switch. Así el resto de campos que no se esperan serán ignorados.
  5. Recoger cada $valor filtrando los caracteres reservados con htmlspecialchars(). Usar su argumento ENT_QUOTES para traducir también las comillas simples (').
  6. Si el servidor tiene magic_quotes_gpc activado y no necesitamos escapar las comillas, eliminar las barras invertidas con stripslashes().

Después de estas medidas tendremos las variables que hayamos inicializado con los valores que en su caso hayamos recibido desde un formulario, al menos, con los mismos nombres de campo que el que se esperaba. Y aunque en estos ejemplos hemos usado el método GET para poder probar el envío de parámetros por URL al formulario, debe usarse POST siempre que sea posible.

Identificando el formulario

En el ejemplo del apartado anterior revisábamos los campos para ver si tenían nombres como el del formulario original. Pero esto no quita que alguién se construya un formulario exactamente con los mismos campos y lo envíe, usando la barra de direcciones del navegador o bien cualquier otro método como Telnet. Una forma de paliar en parte el problema es tratar de identificar si el formulario es nuestro. El siguiente ejemplo tiene este código:

<?php
//Vemos si tiene magic_quotes activado el servidor
$magicq = (get_magic_quotes_gpc() == 1);
//Constante de identificación del formulario
define("IDENT_FORM", "abcd1234");
//Si no se identifica el formulario no precesaremos los datos
$form_identificado = false;
//Iniciamos las variables que contendrán los valores enviados
$un_identificador_form = "";
$un_nombre = "";
$una_direccion = "";
$un_email = "";
//Iniciamos recogiendo los GET enviados por el usuario
foreach($_GET as $campo=>$valor){
    switch ($campo) {
        case "identifica-form":
            //La función htmlspecialchars() evita que
            //el usuario envíe caracteres no deseados
            $un_identificador_form = htmlspecialchars($valor, ENT_QUOTES);
            //Si el servidor tiene magic_quotes_gpc activado,
            //quitamos las barras invertidas
            if ($magicq) $un_identificador_form = stripslashes($un_identificador_form);
            //Comprobamos la identificación del formulario
            if ($un_identificador_form == IDENT_FORM) $form_identificado = true;
            break;              
        case "nombre":
            $un_nombre = htmlspecialchars($valor, ENT_QUOTES);
            if ($magicq) $un_nombre = stripslashes($un_nombre);         
            break;
        case "direccion":
            $una_direccion = htmlspecialchars($valor, ENT_QUOTES);
            if ($magicq) $una_direccion = stripslashes($una_direccion);         
            break;
        case "email":
            $un_email = htmlspecialchars($valor, ENT_QUOTES);
            if ($magicq) $un_email = stripslashes($un_email);         
            break;          
    }
} 
//Ahora componemos la página de salida
?>
<!DOCTYPE html..
...
    <?php if (!isset($_POST["envio"])) { ?>
        <form action="<?php $_SERVER["PHP_SELF"]; ?>" method="post">
            <input type="hidden" name="identifica-form" value="abcd1234" /><br />
            <label>Nombre: <input type="text" name="nombre" value="" />
                </label><br />
            <label>Dirección: <input type="text" name="direccion" value="" />
                </label><br />
            <label>Email: <input type="text" name="email" value="" />
                </label><br />            
            <input type="submit" name="envio" value="enviar" />
        </form>    
    <?php } else { 
        if (!$form_identificado) { ?>
            <p style="color: red">EL FORMULARIO NO SE PUDO IDENTIFICAR</p>
        <?php } else {?>
            <p style="color: green">SE PROCESAN LOS DATOS RECIBIDOS:</p>
            <ul>
                <li>Nombre: <?php echo $un_nombre; ?></li>
                <li>Dirección: <?php echo $una_direccion; ?></li>
                <li>Email: <?php echo $un_email; ?></li>                
            </ul>
        <?php } 
    }?>
    ...
</html>

Como se observa, se trata de enviar un campo de texto oculto dentro del formulario (aunque en el ejemplo en ejecución lo ponemos visible para poder probar). Luego en el recibido de datos se revisará si se recibe ese campo, en cuyo caso podríamos suponer que los datos provienen de un formulario identificado. Es evidente que el campo para la identificación no está tan oculto como parece, pues cualquier puede ver su contenido en el código de la página del navegador. Al ser un identificador que no varía, alguién podría enviarlo en formularios falsificados tantas veces como quisiera.

Identificando el formulario con una sesión

Podemos mejorar el ejemplo del apartado anterior si usamos sesiones PHP para guardar algunos parámetros en las variables de la sesión y usarlas para identificar el formulario. Este ejemplo tiene el siguiente código:

<?php
//Necesitamos sólo cookies para la sesión
ini_set("session.use_cookies", 1);
ini_set("session.use_only_cookies", 1);
ini_set("session.use_trans_sid", 0); 
//Damos un nombre a estas sesiones e iniciamos sesión
define("SESION", "sesionIdent");
session_name(SESION);
//Iniciamos una sesión
session_start();
//Comprobamos si la sesión ya fue iniciada
$hay_sesion = (isset($_SESSION["ident1"]) && isset($_SESSION["ident2"]));
//Comprobar si entramos con POST
$hay_post = isset($_POST["identifica-form"]);
//En la página hay un segundo formulario con un único botón para finalizar
//la sesión y reiniciar el formulario de nuevo
$finaliza_sesion = isset($_POST["fin-sesion"]);
//para identificar el usuario en "ident1"
$usuario_identificado = false;
//para identificar el formulario en "ident2"
$form_identificado = false;
//Para detectar la regeneración de sesión
$hay_regeneracion = false;
//inicializar variables de formulario
$un_nombre = "";
$una_direccion = "";
$un_email = "";
//Vemos si tiene magic_quotes activado el servidor
$magicq = (get_magic_quotes_gpc() == 1);
if (!$hay_sesion || ($hay_sesion && !$hay_post) || $finaliza_sesion) {
    //Con sesiones no iniciadas, iniciadas sin post o finalizadas
    $hay_regeneracion = true;  
    session_regenerate_id(true);
    $_SESSION["ident1"] = md5($_SERVER["HTTP_USER_AGENT"]);
    $_SESSION["ident2"] = md5(uniqid(rand(), true));
} else {//CON SESIONES INICIADAS
    //¿es el mismo usuario?
    if (isset($_SERVER["HTTP_USER_AGENT"])) $usuario_identificado = 
                ($_SESSION["ident1"] == md5($_SERVER["HTTP_USER_AGENT"]));
    if ($usuario_identificado){
        //si se identifica el usuario, recibimos POST
        foreach($_POST as $campo=>$valor){
            switch ($campo) {
                case "identifica-form":
                    $un_identificador_form = htmlspecialchars($valor, ENT_QUOTES);
                    if ($magicq) $un_identificador_form = stripslashes($un_identificador_form);
                    //Comprobamos la identificación del formulario
                    $form_identificado = ($un_identificador_form == $_SESSION["ident2"]);
                    break;              
                case "nombre":
                    $un_nombre = htmlspecialchars($valor, ENT_QUOTES);
                    if ($magicq) $un_nombre = stripslashes($un_nombre);         
                    break;
                case "direccion":
                    $una_direccion = htmlspecialchars($valor, ENT_QUOTES);
                    if ($magicq) $una_direccion = stripslashes($una_direccion);         
                    break;
                case "email":
                    $un_email = htmlspecialchars($valor, ENT_QUOTES);
                    if ($magicq) $un_email = stripslashes($un_email);         
                    break;          
            }
        }
    }
    //En cualquier caso eliminamos la sesión actual
    $_SESSION = array();
    if (ini_get("session.use_cookies")) {
        $params = session_get_cookie_params();
        setcookie(SESION, '', time() - 42000,
            $params["path"], $params["domain"],
            $params["secure"], $params["httponly"]
        );
    }
    session_destroy(); 
 
}
?>
<!DOCTYPE html..
...
    ...
    <?php if (!$form_identificado) { 
        if (!$hay_regeneracion){
            echo "<p style='color: red'>El formulario no se pudo identificar. Por ".
            "favor, reinicie el formulario.</p>";
        } else { ?>    
            <form action="<?php $_SERVER["PHP_SELF"]; ?>" method="post">
                <input type="hidden" name="identifica-form" size="50"
                value="<?php echo $_SESSION["ident2"]; ?>" /><br />
                <label>Nombre: <input type="text" name="nombre" value="" />
                    </label><br />
                <label>Dirección: <input type="text" name="direccion" value="" />
                    </label><br />
                <label>Email: <input type="text" name="email" value="" />
                    </label><br />            
                <input type="submit" value="enviar" />
            </form>
        <?php } ?>
        <form  action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post">
            <input type="submit" name="fin-sesion" value="reiniciar formulario" />
        </form> 
    <?php } else {?>
        <p style="color: green">SE PROCESAN LOS DATOS RECIBIDOS:</p>
        <ul>
            <li>Nombre: <?php echo $un_nombre; ?></li>
            <li>Dirección: <?php echo $una_direccion; ?></li>
            <li>Email: <?php echo $un_email; ?></li>                
        </ul>
    <?php } ?>
    ...
</html>

Usamos la variable $form_identificado para saber si hemos recibido el campo oculto del formulario. Pero además usamos una identificación adicional, la del navegador del usuario cuyo resultado almacenamos en $usuario_identificado. Al entrar al script ambas variables se establecen a false. La primera vez que entremos en la página no habrán variables de sesión, y por tanto se establecen "ident1" e "ident2" con el navegador del usuario y con una cadena generada aleatoriamente.

Entonces cuando recibamos esta página comprobaremos con las variables almacenadas en la sesión si coincide el navegador usado y el valor del campo oculto del formulario, en cuyo caso procesamos los datos recibidos, que en este ejemplo se limita a devolverlos en la misma página al usuario. Ahora el identificador del campo oculto de formulario será diferente con cada nuevo usuario que solicite ese formulario. En este caso un atacante debe robar la cookie de sesión del usuario, el identificador oculto y hacer un falso envío de formulario usando un navegador que emita la misma cabecera de agente de usuario. Esto por supuesto que es posible, pero es más díficil de llevar a cabo que el ejemplo del apartado anterior. Realmente el atacante debe llevar a cabo una suplantación de sesión, bien mediante fijación o secuestro, para lo cual el script supuestamente trata de evitarlo (ver más sobre esto en el capítulo VI sobre asegurar sesiones, del tema sobre sesiones en PHP).

Al finalizar el script antes de volcar el HTML se dispone de un código para eliminar la sesión actual en cualquier caso, pero siempre con sesión iniciada. Con esto se regenerarán todos los identificadores, con lo que obligamos a que el proceso de recibido de datos se haga de una sóla vez. En este ejemplo hemos procedido así, pero no siempre será necesario finalizar la sesión en todo caso, por lo que este aspecto estará en función del proceso a que se aplique.

En cuanto a la estructura de la página HTML, vemos que se observa si el formulario está identificado, en cuyo caso es que se recibió POST correcto y presentamos los valores recibidos. Si no se identificó el formulario pudiera suceder que es la primera vez que entramos, lo que se detecta si se ha producido regeneración de identificadores. Así si $hay_regeneración es verdadero mostramos el formulario, pero si es falso es que no pudo identificarse. En cualquier caso ofrecemos un botón para reiniciar el formulario, lo que lanzará el formulario desde el inicio regenerando los identificadores.

Realmente lo que hace el botón de reiniciar formulario es finalizar la sesión y lanzar una nueva. Pero aquí aprovechamos su uso en lugar de poner el botón de borrado de formulario, el típico <input type="reset" />, sustituyéndolo por el segundo formulario de la página con un único botón de envío: <input type="submit" name="fin-sesion" value="reiniciar formulario" />. Veáse que le damos el nombre "fin-sesion", aunque aparecerá en la página con el título "reiniciar formulario" y no con el de "finalizar sesión".

Además si abrimos un formulario y no envíamos nada cerrando la página, y más tarde volvemos sin haber cerrado el mismo navegador y aún existiera la sesión, entonces seguirán estando guardadas las variables de sesión identificadoras de formulario y usuario. Por eso hemos incluido la revisión de tres posibilidades en el script para condicionar la regeneración de identificadores:

if (!$hay_sesion || 
    ($hay_sesion && !$hay_post) ||
    $finaliza_sesion)

Así regeneramos la sesión cuando no haya una sesión previa abierta, es decir, la primera vez que entramos en el formulario; o bien si habíamos entrado antes pero salimos sin enviar nada y más tarde volvemos a entrar con la misma sesión; y por último si recibimos el botón de finalizar sesión. Con esto garantizamos que el envío de datos se haga en el mismo acto en el que se abre el formulario.

La forma en que se estructura el proceso puede diferir según las necesidades y gustos, pero al final lo que se pretende es saber si podemos procesar los datos recibidos verificando los identificadores de usuario y formulario con los que teníamos almacenados en las variables de sesión generados en un lanzamiento anterior del formulario. El esquema del algoritmo sería este:

Si no hay sesión o hay sesión sin POST o finaliza sesión
    Regenerar identificador de sesión;
    Crear variable de sesión que identifica usuario (navegador);
    Crear variable de sesión que identifica formulario (valor aleatorio);
En otro caso
    Identificar el usuario  (navegador);
    Si se identificó el usuario
        Recibir POST;
        Identificar el formulario con un campo del POST
            Si se identificó el formulario [3]
                Destinar campos recibidos a algún proceso;
                (Redireccionar a otra página si fuera preciso);
            Fin si.
        Fin si.
    Fin si.
    Eliminar sesión;
Fin si.
Enviar inicio del HTML;
Si no se identificó al usuario (o al formulario)[1]
    Si no se regeneraron identificadores [2]
        Enviar un texto de no identificación;
    En otro caso
        Enviar formulario de datos;
    Fin si.
    Enviar formulario de finalizar sesión;
En otro caso (si no hay redirección)[4]
    Enviar un texto de POST recibido;
Fin si.
Enviar resto del HTML.

[1] Veáse que si no se identifica al usuario, el formulario se dará también por no identificado. Por lo tanto en el HTML sólo preguntamos si se identificó el formulario. [2] Por otro lado si no se regeneraron identificadores es que hemos entrado con sesión iniciada anteriormente y con valores en el POST, por lo que mostramos el mensaje de no identificación . [3] Si hemos recibido el POST identificado, podemos guardar los valores en una base de datos por ejemplo y redireccionar a otro proceso en otro script. [4] O bien presentar en el HTML un mensaje de POST recibido. En resumen, la forma en que estructure todo esto dependerá de las necesidades de procesamiento en cada contexto.