OWASP ESAPI PHP: Accesos a la base de datos

En esta entrega vamos a tratar cómo defendernos de ataques SQL injection. Hay varias maneras de intentar protegerse ante ataques de este tipo, una de ellas es con el Encoder de ESAPI, pero si vemos la documentación encontraremos la siguiente advertencia:

No se recomienda este método. La aproximación adecuada es usar la interface PreparedStatement. Si, por alguna razón, su uso no es posible, entonces este método es una solución menos mala.

Así pues vamos a olvidarnos temporalmente de ESAPI para centrarnos en los PreparedStatement (parte de la librería mysqli de PHP). Su uso nos permite separar totalmente los datos de las sentencias SQL con lo que las consultas se vuelven mucho más seguras. Su implementación no es nada difícil, sólo requiere pensar de manera ligeramente distinta.


Empecemos por modificar nuestra clase DB para poder usar mysqli:

private $host = "localhost";
private $username = "insecureapp";
private $password = "supersecretpw";
private $db_name = "insecure";
private static $instance;

private function __construct() {
     $this->connection = new mysqli($this->host, $this->username, $this->password, $this->db_name);
}

static function get_instance() {
     if(!self::$instance) {
          self::$instance = new DB;
     }
     return self::$instance->connection;
}

Puede verse que se ha cambiado el usuario de la base de datos a otro con menos privilegios (este punto no se sigue en el código modificado). Es importante que dicho usuario tenga el menor nivel de privilegios posible.

Los pasos básicos para usar un PreparedStatement son:

  • Preparar la consulta
  • Enlazar los parámetros
  • Ejecutar la consulta

Como ejemplo, veamos la función get_all_content en index.php. La original es:

function get_all_content() {
     $db = DB::get_instance();
     $sql = "SELECT id FROM content order by date_created";
     $result = $db->query($sql);
     while($row=$db->fetch_assoc($result)) {
          $content_arr[] = new Content($row['id']);
     }
     return $content_arr;
}

y la nueva, usando PreparedStatement, es:

function get_all_content() {
     $db = DB::get_instance();
     $sql = $db->prepare("SELECT id, user_id, title, content, date_created FROM content ORDER BY date_created");
     $sql->execute();
     $sql->bind_result($id, $user_id, $title, $content, $date_created);
     while($sql->fetch()) {
          $post = new Content();
          $post->set_content_id($id);
          $post->set_user_id($user_id);
          $post->set_title($title);
          $post->set_content($content);
          $post->set_date_created($date_created);
          $content_arr[] = $post;
     }
     $sql->close();
     return $content_arr;
}

Preparamos la consulta con la función prepare y la ejecutamos (execute). A continuación enlazamos el resultado mediante bind_result con nuestras variables. El bucle controlado por fetch asigna los valores de cada columna  a las variables enlazadas. Una vez leído el resultado cerramos la consulta. La misma operación la realizamos en la función get_all_comments:

function get_all_comments($content_id) {
     $db = DB::get_instance();
     $sql = $db->prepare("SELECT id, comment, content_id, date_created FROM comments WHERE content_id = ? order by date_created");
     $sql->bind_param('i', $content_id);
     $sql->execute();
     $sql->bind_result($id, $comment_body, $content_id, $date_created);
     while($sql->fetch()) {
          $comment = new Comment();
          $comment->set_comment_id($id);
          $comment->set_comment($comment_body);
          $comment->set_content_id($content_id);
          $comment->set_date_created($date_created);
          $comment_arr[] = $comment;
     }
     $sql->close();
     return $comment_arr;
}

Veamos ahora nuestras tres clases. Empecemos por Content.php, en la que usamos consultas SQL en dos funciones: retrieve_content y write:

private function retrieve_content($content_id) {
     $db = DB::get_instance();
     $sql = $db->prepare("SELECT id, user_id, title, content, date_created FROM content WHERE id = ?");
     $sql->bind_param('i', $content_id);
     if(!$sql->execute()) {
          $this->error_list[] = "Could not retrieve content.";
          $sql->close;
          return false;
     }
     if(!$sql->num_rows() == 0) {
          $this->error_list[] = "Content not found.";
          $sql->close;
          return false;
     }
     $sql->bind_result($this->content_id, $this->user_id, $this->title, $this->content, $this->date_created);
     $sql->fetch();
     $sql->close();
     return true;
}

Vemos cómo, si sólo necesitamos una fila de la consulta, la función bind_result nos permite asignar el resultado a las propiedades de nuestro objeto. También podemos añadir algún tipo de gestión de errores si lo deseamos. Y ahora la función write:

function write() {
     $db = DB::get_instance();

     $sql = $db->prepare("INSERT INTO content (user_id, title, content, date_created) values (?, ?, ?, ?)");
     $sql->bind_param('isss', $this->user_id, $this->title, $this->content, date("Y-m-d"));
     if(!$sql->execute()) {
          $this->error_list[] = "Could not save post, please try again.";
          $sql->close();
          return false;
     }
     $sql->close();
     return true;
}

Nada diferente a lo que hemos visto hasta ahora. Pasamos a la clase Comment en la que los cambios son casi idénticos. Las funciones a modificar son retrieve_comment y write:

function retrieve_comment($comment_id) {
     $db = DB::get_instance();
     $sql = $db->prepare("SELECT id, comment, content_id, date_created from comments where id = ?");
     $sql->bind_param('i', $comment_id);
     if(!$sql->execute()) {
          $this->error_list[] = "Could not retrieve comment";
          $sql->close();
          return false;
     }
     $sql->bind_result($this->comment_id, $this->comment, $this->content_id, $this->date_created);
     $sql->fetch();
     $sql->close();
}

function write() {
     $db = DB::get_instance();
     $sql = $db->prepare("INSERT INTO comments (comment, content_id, date_created) VALUES (?, ?, ?)");
     $sql->band_param("sis", $this->comment, $this->content_id, date("Y-m-d"));
     if(!$sql->execute) {
          $this->error_list[] = "Could not write comment.";
          $sql->close();
          return false;
     }
     $sql->close();
     return true;
}

Y finalmente la clase User. Vamos a implementar también gestión de errores añadiéndole la propiedad error_list tal como hicimos en las clases Comment y Content:

private $error_list = null;

y las dos funciones de gestión:

function clear_error_list() {
     $this->error_list = null;
}

function get_error_list() {
     return $this->error_list;
}

Ahora mejoramos la consulta en la función de login y disponemos de gestión de errores:

function login($username, $password) {
     $db = DB::get_instance();
     $sql = $db->prepare("SELECT id, username, password FROM user WHERE username = ? AND password = ?");
     $sql->bind_param('ss', $username, $password);
     if(!$sql->execute()) {
          $this->error_list[] = "Could not login.";
          $sql->close();
          return false;
     }
     $sql->store_result();
     if($sql->num_rows() == 0) {
          $this->error_list[] = "Username or password not found";;
          $sql->close();
          return false;
     }
     $sql->bind_result($this->user_id, $this->username, $this->password);
     $sql->fetch();
     $this->create_user_session();
     return true;
}

Sencillo. Sólo nos queda por modificar los controladores para gestionar los errores que se puedan producir. El código de esta entrega puede descargarse aquí.

Anterior: OWASP ESAPI PHP: Asegurando las entradas
Siguiente: OWAPS ESAPI PHP: Autentificación y control de sesiones

No hay comentarios:

Publicar un comentario