OWASP ESAPI PHP: Asegurando las entradas

Si probamos la aplicación desarrollada en la entrada anterior observamos que es vulnerable a un simple ataque SQL Injection como "1' AND 1='1" para logearnos o XSS como "<script>javascript:alert(1);</script>" tanto en los posts como en los comentarios. En esta entrega vamos pues a esterilizar (sanitize) las entradas del usuario.

Usaremos las clases de referencia de ESAPI (/src/reference) siempre que podamos ya que son sencillas implementaciones de las interfaces definidas en /src y suficientes para nuestros propósitos con pequeños cambios. Empezamos modificando ligeramente la clase  DefaultSecurityConfiguration ( /src/reference/DefaultSecurityConfiguration.php) para arreglar un pequeño bug que el equipo de OWASP no ha considerado necesario arreglar desde que se escribió el artículo original. Abrimos el archivo y ejecutamos un buscar + sustituir cambiando todos los $this->logSpecial por $this->_logSpecial. La función _logSpecial imprime un mensaje de error en pantalla y nos interesa que lo haga en un log así que la cambiamos por:
private function _logSpecial($msg)
{
     ESAPI::getAuditor('DefaultSecurityConfiguration')->warning(Auditor::SECURITY, false, $msg);
}



Adaptando la clase DefaultValidator


La clase DefaultValidator (/src/reference/DefaultValidator.php) se usa para aplicar criterios de comprobación a nuestro datos. La documentación puede encontrarse en /src/Validator.php o en phpdocs. Para adaptarla a nuestros requerimientos vamos a modificar la manera en que gestiona las excepciones. Cuando una comprobación falla se lanza una ValidationException que contiene un mensaje seguro para mostrar al usuario. La clase DefaultValidator atrapa la excepción y devuelve false. Por ello, si queremos enviar un mensaje al usuario advirtiendo de algún problema en su entrada, no podemos especificar el motivo de dicho problema. Por ejemplo, si comprobamos un valor entero para verificar que se halle entre 1 y 20 y el resultado es false, no tenemos forma de saber si el usuario a escrito 0, 50 o 'hola'. Para solucionarlo vamos a capturar la excepción y podremos crear nuestros propios mensajes de error.

Hacemos pues una copia del archivo DefaultValidator.php, lo renombramos a BlogValidator.php y añadimos una nueva propiedad privada:

private $_lastError = null;

A continuación añadimos dos funciones: una para leer el último mensaje de error generado y otra para limpiar la propiedad $_lastError:

/**
* Clears the last error
*
* @return does not return a value
*/
public function clearLastError() {
     $this->_lastError = null;
}

/**
* Gets the lastError property
*
* @return string lastError property
*/
public function getLastError() {
     return $this->_lastError;
}
Para terminar, modificamos todo los fragmentos de código donde se atrapa la excepción para poder leer nuestro mensaje. Sustituimos todos los:

catch ( Exception $e)
{
     return false;
}
por

catch ( Exception $e )
{
     $this->lastError = $e->getUserMessage();
     return false;
}

Validando los posts del usuario


Ahora vamos a implementar la clase BlogValidator en la clase Content para validar los posts a nuestro blog. Lo primero es incluir los archivos ESAPI.php y BlogValidator.php en Content.php. Para ello he creado el archivo /lib/conf.php en la aplicación. Definimos en él dos constantes, una con la ruta a los módulos ESAPI (donde está ESAPI.php) y otra con la ruta completa del fichero ESAPI.xml:
define("ESAPI_SRC_path", "[ruta relativa a OWASP_ESAPI]/src");
define("ESAPI_XML_path", "[ruta absoluta a OWASP_ESAPI]/test/testresources/ESAPI.xml");
y en Content.php añadimos:

require("lib/conf.php");
require_once(ESAPI_SRC_path . "/ESAPI.php");
require_once(ESAPI_SRC_path . "/reference/BlogValidator.php");
A continuación agregamos nuevas propiedas a Content para gestionar los controles ESAPI y una propiedad $error_list para los mensajes generados por ValidationException:
private $content_id = null;
private $user_id = null;
private $title = null;
private $content = null;
private $date_created = null;
private $esapi = null;
private $encoder = null;
private $validator = null;
private $error_list = null;
Ahora ya podemos inicializar los objetos ESAPI en nuestro constructor. Usamos los métodos get de la librería para establecer los dos tipos de controles que necesitamos en esta entrega. También modificamos ligeramente el constructor para facilitar la validación: no se van a pasar todos los valores de inicialización sino que deberán usarse métodos get/set:
function __construct($content_id = '') {
     $this->esapi = new ESAPI(ESAPI_XML_path);
     ESAPI::setEncoder(new DefaultEncoder());
     ESAPI::setValidator(new BlogValidator());
     $this->encoder = ESAPI::getEncoder();
     $this->validator = ESAPI::getValidator();
     if($content_id) {
          $this->retrieve_content($content_id);
     }
}
Lo que hacemos es instanciar una clase ESAPI y obtener sus objetos Encoder y Validator. Aún no hemos hablado del Encoder, de momento es suficiente saber que nos permite canonicalizar la entrada del usuario antes de validarla. Ahora debemos añadir dos métodos para gestionar la propiedad $error_list, uno para limpiarla y otro para leerla, tal como hicimos en BlogValidator:
function clear_error_list() {
     $this->error_list = null;
}

function get_error_list() {
     return $this->error_list;
}
Pasamos a ocuparnos de los métodos setter. Algunos de los métodos definidos no son usados en la aplicación, incluso pueden eliminarse sin que afecte a su funcionamiento, pero ello no significa que debamos hacerlo o ignorarlos a la hora de asegurarla. Puede que más adelante necesitemos de alguno para añadir nuevas funcionalidades, o, si la modifica otro programador, quiera saber qué puede hacer. Si pensamos ahora en lo que podemos necesitar será más fácil modificarlo en el futuro. Disponemos pues de las siguientes funciones:
  • set_content_id
  • set_user_id
  • set_title
  • set_content
  • set_date_created
Existe un importante punto que hemos estado obviando hasta aquí y que, desafortunadamente, es común a la mayoría de programadores: nunca se piensa realmente en los tipos de valores que nuestras propiedades pueden contener. Por ejemplo, establecemos un límite de 140 caracteres para el título de los posts. ¿Es razonable? ¿Cómo saberlo? ¿Debemos permitir código javascript en los comentarios? Probablemente no, pero ¿nos hemos parado a pensarlo? El único lugar dónde están definidas las propiedades de nuestro objeto es en la base de datos. Si pensamos en estas cosas mientras la diseñamos será más fácil el trabajo posterior.


Empecemos por content_id. Si vemos su definición en la base de datos vemos que es un entero con signo, clave primaria (por lo que debe ser única), no puede ser nulo y que MySQL la incrementa por nosotros. Consideremos ahora qué valores debe contener y modificaremos la base de datos en consecuencia.


Un entero con signo en MySQL tiene un rango de -2,147,483,648 a 2,147,438,648. Excesivo para nuestras necesidades asi que podriamos redefinirlo como entero sin signo y smallint. Ya que es un identificador único para nuestros datos nos conviene que sea una clave primaria y también nos interesa que se autoincremente para no preocuparnos nosotros de generar ids únicos, así que modificamos la definición de content_id en la base de datos:
alter table content change column id id smallint signed auto_increment;
y establecemos su validación en nuestro método setter:

function set_content_id($content_id) {
     $content_id = $this->canonicalize($content_id);
     if($this->validator->isValidNumber("Content ID", $content_id, 1, 65535, false)) {
          $this->content_id = $content_id;
     } else {
          $this->error_list[] = $this->validator->getLastError();
     }
}


La única parte del nuevo método que no hemos visto aún es la función canonicalize. Esta función ejecuta una canonicalización de los datos de forma que quedan en su estado más básico antes de validarlos. Básicamente, si un usuario introduce código, esta función lo reduce a un formato que puede ser comprobado. El codificador lanza una IntrusionException si se detectan ciertos problemas, así que debemos atrapar esta excepción y gestionarla. La función canonicalize queda pues como:

function canonicalize($input) {
     try {
          $input = $this->encoder->canonicalize($input);
     } catch (IntrusionException $e) {
          echo($e->getUserMessage());
          exit();
     }
     return $input;
}


Hacemos lo mismo con el resto de las propiedades: user_id debe ser tratada como content_id (excepto clave primaria y autoincremento) ya que se usa para referenciar los ids de cada tabla:

alter table content change column user_id user_id smallint signed;
alter table user change column id id smallint signed auto_increment;
y la nueva función set_user_id queda como:

function set_user_id($user_id) {
     $user_id = $this->canonicalize($user_id);
     if($this->validator->isValidNumber("User ID", $user_id, 1, 10000, false)) {
          $this->user_id = $user_id;
     } else {
          $this->error_list[] = $this->validator->getLastError();
     }
}


La propiedad title la hemos definido como string de 140 caracteres que puede ser nulo. Puede que 140 sea razonable para un título pero no nos interesa que sea NULL:
alter table content change column title title varchar(140) not null;
Cuando tratamos con números es bastante simple comprobar que la entrada es válida, pero con texto se complica ligeramente. Hemos de considerar tambíén la salida. Para nuestro blog establecemos la siguiente regla: el post no puede contener código HTML ni javascript en su título. Esto significa que debemos comprobar si existe una entrada, que ésta no exceda la longitud máxima y que no contenga ningún caracter no imprimible. Salvo estas limitaciones el usuario puede escribir lo que desee y los objectos de codificación y esterilización se encargarán de ello. Podrá intentar introducir código malicioso, pero vamos a manipularlo de forma segura.
La función setter para title queda pues como:

function set_title($title) {
     $title = $this->canonicalize($title);
     if($this->validator->isValidPrintable("Post Title", $title, 140, false)) {
          $this->title = $title;
     } else {
          $this->error_list[] = $this->validator->getLastError();
     } 
}


Con la propiedad content hacemos exactamente lo mismo, modificamos la base de datos para que no pueda ser nulo:
alter table content change column content content text not null;


y modificamos la función setter para asegurarnos que sólo contiene caracteres imprimibles:
function set_content($content) {
     $content = $this->canonicalize($content);
     if($this->validator->isValidPrintable("Post Content", $content, 65535, false)) {
          $this->content = $content;
     } else {
          $this->error_list[] = $this->validator->getLastError();
     }
}


La última función setter que cambiamos es date_created. No es necesario modificar la base de datos en este caso:


function set_date_created($date_created) {
     $date_created = $this->canonicalize($date_created);
     if($this->validator->isValidDate("Content date created", $date_created, "Y-m-d H:i:s", false)) {
          $this->date_created = $date_created;
     } else {
          $this->error_list[] = $this->validator->getLastError();
     }
}



Usamos la función isValidDate para comprobar el formato de la fecha introducida. El formato es el mismo que el de la función date de PHP.


Recodificando las salidas

Hasta aquí hemos visto cómo usar Encoder para canonicalizar las entradas, ahora veremos cómo usarlo para codificar las salidas de los datos y poderlos mostrar con seguridad. La aplicación es muy vulnerable a ataques XSS asi que tratando los datos de salida antes de mostrarlos podremos reducir los riesgos.

En nuestra aplicación sólo hay un lugar en el que se envían los datos posteados por el usuario a la pantalla (al navegador): el fichero index.php. Hemos establecido que no vamos a permitir que el usuario añada código HTML ni javascript funcional a sus entradas, así que vamos a recodificarlas para poderlas mostrar sin problemas. Básicamente, en cualquier lugar dentro de index.php dónde se envíe al navegador algún dato del usuario, lo modificamos previamente mediante encodeForHTML de la clase DefaultEncoder. El único lugar donde se generan las salidas es en la función construct_content_display:

function construct_content_display($content_arr) {
     $output = '';
     $encoder = ESAPI::getEncoder();
     for($i=0;$i<count($content_arr);$i++) {
          $output .= "<p><b>" . $encoder->encodeForHTML($content_arr[$i]->get_title()) . "</b></p>";
          $output .= "<br><p>" . $encoder->encodeForHTML($content_arr[$i]->get_content()) . "</p>";
          $comment_arr = get_all_comments($content_arr[$i]->get_content_id());
          for($j=0;$j<count($comment_arr);$j++) {
               $output .= "<br><p>" . $encoder->encodeForHTML($comment_arr[$j]->get_comment()) . " - " . $encoder->encodeForHTML($comment_arr[$j]->get_date_created()) . "</p>";
          }
          $output .= "<br><a href=\"comment.php?content_id=" .  $content_arr[$i]->get_content_id() . "\">Comment</a>";
     }
     return $output;
}

Para terminar, modificamos de la misma forma la clase Comment (lib/Comment.php) y los controladores post.php y comment.php con sus respectivos html. El código de esta entrega puede descargarse aquí.

Anterior: OWASP ESAPI PHP: Introducción
Siguiente: OWASP ESAPI PHP: Accesos a la base de datos

No hay comentarios:

Publicar un comentario