Monday, July 12, 2010

Using the OWASP PHP ESAPI - Part 3

And here we are at the 3rd part of the OWASP ESAPI PHP tutorial series. If you haven't read the first two yet, you can find Part One here and Part Two here. This week we're going to cover encoding our output, and making our database queries safer. Let's get right into it. This week's files can be found here.

Encoding our output
We've seen the encoder in use a little bit so far - we've used it canonicalize our input. This week, we're going to use it to encode user submitted output so that we can display it safely. Our blog app is extremely vulnerable to XSS attacks right now; for example, any commentor can post malicious code. By encoding our output before we display it we can significantly cut that risk.

First step, let's examine our application and figure out where we're outputting any user input. Of our four pieces, only one of them displays user submitted data; index.php.

Last week, we said that we would not allow the user to add actual functioning HTML or JavaScript into their comments or posts, so if there is any of that, we're just going to encode it to display safely on the screen. So basically, anywhere in index.php that we're displaying something a user submitted, we're going to run it through the DefaultEncoder's encodeForHTML function. All of that is contained in our construct_content_display function, which will now look like this:

 
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;
}

And that's all there is to it! There are lots of other useful functions in the Encoder security control and I highly encourage you to check them all out.

Making our database queries safe
The other thing we're going to go over this week isn't completely ESAPI PHP centric. There are several tactics we could use to protect ourselves against SQL injection attacks and generally make them more error-resistant. We could use the ESAPI PHP encoder to escape our queries, but the ESAPI PHP docs for the encodeForSQL function state it best when they say:

"This method is not recommended. The use of the PreparedStatement interface is the preferred approach. However, if for some reason this is impossible, then this method is provided as a weaker alternative."

We're going to depart slightly from the ESAPI PHP for a moment to discuss using prepared statements. Prepared statements are used with the mysqli built in PHP library. They allow you to completely separate the data from the SQL making queries much safer by default. Using prepared statements is not any more difficult than using the standard PHP mysql functions, it just requires you to think a little differently. If you haven't used mysqli, you can find all the documentation here.

First thing we're going to do is modify our DB class to use mysqli now. Our new class looks like this:

 
<?php

  /**
   *
   * DB.php
   * 
   * This code is part of a tutorial on using the Open Web Application Security Project (OWASP)
   * Enterprise Security API (ESAPI) project. It is extremely insecure! Please do not use
   * this in any kind of production environment. 
   * 
   * @author jackwillk
   * @created 2010
   * 
   */

class DB {
  
  /** 
   * This class deals with mysqli singleton
   */ 

  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;
  }

  }
?>

Simple! You'll notice the other thing we did was create a new user for our app which does not have root privileges. When you create the user, it's important to give the user the minimum possible privilege.

The basic steps involved in using a prepared statement are as follows:
  1. Prepare the statement
  2. Bind parameters
  3. Execute the query

Let's look at a simple example, our get_all_content function in index.php. Here's the original:

 
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;
}

And our new function looks like:

 
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;
}

We prepare the statement with the prepare function and execute. After the query executes, we use the bind_result function to bind the id column to a variable called $id. The while loop using the fetch function will assign the values for each column into the variables we defined in the bind_result function, so from here we just use the setter methods to set up our object. After we've looped through our result, we clean up by closing our sql statement.

And we're going to do the same thing to our get_all_comments function:

 
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;
}

Now we move onto our three classes. We'll start with Content.php. We use SQL queries in two of our functions, retrieve_content and write. Our new retrieve_content function looks like this:

 
  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;
  }

One nice thing about the bind_result function is if we're only querying for one row, we can assign the result directly to our object properties. We're also adding some error checking into our function.

Now our write function. This is the first query where we'll actually be writing to the database with our prepared statement.

 
  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;
  }

Nothing too different then we've seen before. We're going to move onto the Comment class, the functions are nearly identical, retrieve_comment and write. retrieve_comment looks like:

 
  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();
  }

And write looks like:

 
  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;
  }

And finally, our User class. We're going to add more error handling to our class, so we're going to add the same error_list property we added to the Comment and Content classes last week. So, in the property list at the top of the class:

 
  private $error_list = null;

And our two handler functions:

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

Now, we improve our login function query and add better error handling:

 
  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;
    }
    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;
  }

Simple! Now, the last thing we'll do is edit our controllers to handle the new errors we're generating in our classes. So, in post.php, we'll check the return value of write() and display the error if it exists. Here's the relevant portion of post.php

 
<?php
if($_POST['submit']) {
  $content = new Content();
  $content->set_user_id($_POST['user_id']);
  $content->set_title($_POST['title']);
  $content->set_content($_POST['content']);

  $error_list = $content->get_error_list();
  
  if(!count($error_list)) {
    if($content->write()) {
      header("Location:index.php");
    } else {
      $error_list = $content->get_error_list();
    }
  } 
 }
?>


The added error handling in comment.php looks like this:

 
if($_POST['submit']) {
  //save the comment
  $comment = new Comment();
  $comment->set_comment($_POST['comment']);
  $comment->set_content_id($_POST['content_id']);
  
  $error_list = $comment->get_error_list();
  if(!count($error_list)) {
    if($comment->write()) {
      header("Location:index.php");
    } else {
      $error_list = $comment->get_error_list();
    }
  }
 }


And finally, our new login page looks like this.

 
<?php

  /**
   *
   * login.php
   * 
   * This code is part of a tutorial on using the Open Web Application Security Project (OWASP)
   * Enterprise Security API (ESAPI) project. It is extremely insecure! Please do not use
   * this in any kind of production environment 
   * 
   * @author jackwillk
   * @created 2010
   *
   */

require("lib/DB.php");
require("lib/User.php");


$user = new User();
if($user->get_user_id()) {
  echo("You are already logged in.");
  exit();
 }

if($_POST['submit']) {
  if($user->login($_POST['username'], $_POST['password'])) {
    header("Location:index.php");
  } else {
    $error_list = $user->get_error_list();
  }
 }

include("login.html");
if($error_list) {
  $content->clear_error_list();
 }
?>

And we add the error list display code to the login html page:

 
<html>
<head>
<title>Login</title>
</head>
<body>
    <?php if($error_list) { 
   for($i=0;$i<count($error_list);$i++) {
   ?>
    <font color="red"><?= $error_list[$i] ?></font><br>
    <?php } } ?>
<b>This is my awesome login page!</b>
<form name="login" method="post" action="login.php">
  username:<input type="text" name="username"><br>
  password:<input type="text" name="password"><br>
  <input type="submit" name="submit" value="submit">
</form>
</body>
</html>


And that's it for this week! This is a busy time for me - meaning it will probably be a little longer until the next post. Next time, we'll be covering logging, form security and general hardening. There's going to be one or two more posts in this series - we'll see how it goes. I would love to hear some feedback on this blog, so feel free to leave a comment, send me an email (jackwillksecurity at gmail dot com) or reach out to me on Twitter (@jackwillk)

2 comments:

  1. Hi, can you talk about the encoder object next? There is almost enough here between the three tutorials to begin using in a production environment, but I would like to see a custom encoder which only accepts UTF-8 and the entire process of taking data from $_POST, canonicalizing, validating, sanitizing, inserting it into a database, retrieving it from the database then outputting it in one fell swoop.

    By the way great tutorials so far.

    ReplyDelete
  2. Hi Brian,
    There's a little bit more work to do in the series, but I believe the place we're going to end up is pretty close to what we're asking. I think that customizing the encoder might be a bit out of scope for this series, but the internal phpdocs are actually pretty easy to follow and there are some functions that come pretty close to what you're looking for.

    ReplyDelete