CodeIgniter CSRF (XSRF) library

They say a lot of developers work on things to scratch their own itch. This is me scratching.

I'm a fan of the CodeIgniter project but I find it odd that such a well developed framework lacks CSRF protection in the core. There are a few CI CSRF libs but most of them are outdated so I wrote my own. A few ideas came from Michael Wales' toolkit but this library is written from scratch. One of the major differences is that this toolkit uses form IDs instead of a timer to handle forms in different tabs which I find more robust since it doesn't rely on time.

Download csrf-v4.tgz

Update 2009.07.13

The first version would fill up the cookie if session DB was not used and users opened too many forms without submitting them. This update clears old tokens and limits the number of tokens to 3 or 10 depending whether session data are stored in cookies or a database.

Installation

This library consists of 3 required files and 2 example files.

  • Csrf.php - CI library class
  • MY_Form_validation.php - CI library extension
  • csrf_helper.php - CI helper
  • csrf_test.php - Example CI controller
  • csrf_form.php - Example CI view

  1. Copy Csrf.php and MY_Form_validation.php to application/libraries/
  2. Copy csrf_helper.php to application/helpers/

Usage

Load both the csrf library and helper in classes which use forms. The sesson and form_validation libraries and form helper are required by the csrf library so these should be loaded as well. MY_Form_validation is automatically loaded since it's an extension of form_validation. Tokens are validated when form_validation->run() is called.

Example controller csrf_test.php

<?php  if ( ! defined('BASEPATH')) exit('No direct script access allowed');

/**
* Csrf test controller
*
* Example controller with CSRF protection
*/

class Csrf_test extends Controller {

    function Csrf_test()
    {
        parent::Controller();    
    }

    function index()
    {
        $data = array();
       
        $this->load->library('session');
        $this->load->library('form_validation');
        $this->load->library('csrf');    
        $this->load->helper('form');
        $this->load->helper('csrf_helper');
       
        // Set validation rules
        $this->form_validation->set_rules('foo', 'Foo', 'trim|required|min_length[3]|max_length[32]|alpha_dash');
       
        // Run validation
        if ($this->form_validation->run()) {        
            $data['message'] = 'Valid';
        }
        else {
            $data['message'] = validation_errors();
        }
       
        // Show login form
        $this->load->view('csrf_form', $data);
    }
}

Then, in every form, print form_token()

Example view csrf_form.php

<?php  if ( ! defined('BASEPATH')) exit('No direct script access allowed'); ?>
<HTML>
<HEAD></HEAD>
<BODY>
<p><?=$message;?></p>
<div>
    <?=form_open('/csrf_test/');?>
        <?=form_token();?>
        <br />
        <?=form_label('Foo', 'foo');?>
        <br />
        <?=form_input(array('name'=>'foo', 'value'=>set_value('foo')));?>
        <br />
        <?=form_submit(array('name'=>'submit', 'value'=>'Send'));?>
    <?=form_close();?>
</div>
</BODY>
</HTML>

That's it.

 

Csrf.php library source

<?php if (!defined('BASEPATH')) exit('No direct script access allowed');

/**
* Csrf library class
*
* Protects against cross site request forgery attempts
*/

class Csrf
{
    var $formID = NULL;
    var $token  = NULL;
    var $CI     = NULL;

    function Csrf()
    {
        log_message('debug', 'Csrf class initialized');
        // Get the CI super object
        $this->CI =& get_instance();
    }
   
       /**
     * Creates and saves a form ID & token
     *
     * @access    public
     * @return    array
     */
   
    function create_token() {    
        log_message('debug', 'Csrf::create_token() called');
       
        // Get existing tokens from the session
        $tokens = $this->CI->session->userdata('tokens');
        if(!is_array($tokens)) $tokens = array();
       
        // Remove old tokens
        $now = time();
        foreach(array_keys($tokens) as $key) {
            if($tokens[$key]['ts'] > $now + 86400) {
                unset($tokens[$key]);
            }
        }
         
        // Limit the tokens saved. Less if stored in a cookie
        $numTokens = 3;
        if($this->CI->config->item('sess_use_database')) {
            $numTokens = 10;
        }
        if(count($tokens) >= $numTokens) {
            // Trim and re-index the array but keep the array order.
            $tokens = array_values(array_slice($tokens, 0, $numTokens, TRUE));            
        }
       
        // Generate data for the new token
        $token  = md5(uniqid(rand(), TRUE));
        $formID = uniqid(rand());
       
        // Add the new token to the token array and save to the session
        $tokens[] = array('ts'=>$now, 'token'=>$token, 'formID'=>$formID);
        $this->CI->session->set_userdata('tokens', $tokens);
       
        // Save the token data for this instance
        $this->formID = $formID;
        $this->token  = $token;                            
        return array('formID'=>$formID, 'token'=>$token);
       
    }
   
    /**
     * Returns the current form ID and token
     *
     * @access    public
     * @return    array
     */

    function get_token() {
        log_message('debug', 'Csrf::get_token() called');
        if(! $this->formID || ! $this->token) {
            log_message('debug', 'Csrf::get_token() invalid token');
            return FALSE;
           
        }
        return array('formID' => $this->formID, 'token' => $this->token);
    }

    /**
     * Saves a form ID and token
     *
     * @access    public
     * @param   string  formID
     * @param   string  token
     * @return    void
     */

    function save_token($formID, $token) {
        log_message('debug', 'Csrf::save_token() called');
        $this->formID = $formID;
        $this->token  = $token;    
    }
   
    /**
     * Clears form ID and token after successful validation
     *
     * @access    public
     * @return    void
     */

    function clear_token() {
        log_message('debug', 'Csrf::clear_token() called');
       
        // Get existing tokens
        $tokens = $this->CI->session->userdata('tokens');
       
        // No existing tokens. Clear current tokens and return.
        if(!is_array($tokens)) {
            $this->formID = $this->token = NULL;
            return NULL;
        }
       
        // Loop through existing tokens and remove this one only
        foreach(array_keys($tokens) as $key) {
            if($tokens[$key]['formID'] == $this->formID) {
                unset($tokens[$key]);
            }            
        }
        // Reindex the remaining tokens and save them to the session
        $tokens = array_values($tokens);
        $this->CI->session->set_userdata('tokens', $tokens);
       
        // Clear current tokens
        $this->formID = $this->token = NULL;
    }

    /**
     * Validates token sent in POST
     *
     * @access      public
     * @param       string      formID
     * @param       string      token
     * @return      void
     */

    function validate_token($formID, $token) {
        log_message('debug', 'Csrf::validate_token() called');
       
        // Get existing tokens
        $tokens = $this->CI->session->userdata('tokens');
       
        // No known tokens
        if(!is_array($tokens)) return FALSE;
       
        // Loop through tokens for a match
        foreach(array_keys($tokens) as $key) {
            if($tokens[$key]['formID'] == $formID && $tokens[$key]['token'] == $token) {
                return TRUE;
            }            
        }
        return FALSE;
    }      
}

csrf_helper.php helper source

<?php if (!defined('BASEPATH')) exit('No direct script access allowed');

/**
 * CSRF helper
 *
 * Inserts formID and token hidden form fields
 *
 * @access    public
 * @return    string  HTML hidden input elements
 */

function form_token() {
    $CI =& get_instance();
   
    // Form helper and csrf lib should be loaded from the controller
    // $CI->load->helper('form');
    // $CI->load->library('csrf');
   
    // Get the token from the csrf class
    $tokenArray = $CI->csrf->get_token();    
    if(!$tokenArray) {
        // Token is bad. Create a new one
        $tokenArray = $CI->csrf->create_token();    
    }
   
    // Return token hidden form field strings
    $input_formID = form_input(array('name'=>'formid', 'id'=>'formid', 'value'=>$tokenArray['formID'], 'type'=>'hidden'));
    $input_token  = form_input(array('name'=>'token', 'id'=>'token', 'value'=>$tokenArray['token'], 'type'=>'hidden'));
   
    // Visible form fields for testing. Should not be used in production
    // $input_formID = form_input(array('name'=>'formid', 'id'=>'formid', 'value'=>$tokenArray['formID'], 'type'=>'input'));
    // $input_token  = form_input(array('name'=>'token', 'id'=>'token', 'value'=>$tokenArray['token'], 'type'=>'input'));
   
    return "\n $input_formID \n $input_token\n";
}

MY_Form_validation.php library extension source

<?php  if ( ! defined('BASEPATH')) exit('No direct script access allowed');

/**
 * MY Form Validation Class
 *
 * Extends the CI Form Validation Class
 */


class MY_Form_validation extends CI_Form_validation {

    function MY_Form_validation()
    {
        parent::CI_Form_validation();
        log_message('debug', 'Class MY_Form_validation initialized');
    }

    /**
     * Override the CI run() function
     *
     * Mostly copied/pasted from orig but adds CSRF token checks.
     *
     * @access    public
     * @return    bool
     */
   
    function run($group = '')
    {
        log_message('debug', 'My_Form_validation::run() called');
       
        // Do we even have any data to process?  Mm?
        if (count($_POST) == 0)
        {
            return FALSE;
        }
       
        // Validate the token
        $this->_validate_token();
       
        // Does the _field_data array containing the validation rules exist?
        // If not, we look to see if they were assigned via a config file
        if (count($this->_field_data) == 0)
        {
                   
            // No validation rules?  We're done...
            if (count($this->_config_rules) == 0)
            {
                return FALSE;
            }
           
            // Is there a validation rule for the particular URI being accessed?
            $uri = ($group == '') ? trim($this->CI->uri->ruri_string(), '/') : $group;
           
            if ($uri != '' AND isset($this->_config_rules[$uri]))
            {
                $this->set_rules($this->_config_rules[$uri]);
            }
            else
            {
                $this->set_rules($this->_config_rules);
            }
   
            // We're we able to set the rules correctly?
            if (count($this->_field_data) == 0)
            {
                log_message('debug', "Unable to find validation rules");
                return FALSE;
            }
        }
   
        // Load the language file containing error messages
        $this->CI->lang->load('form_validation');
                           
        // Cycle through the rules for each field, match the
        // corresponding $_POST item and test for errors
        foreach ($this->_field_data as $field => $row)
        {        
            // Fetch the data from the corresponding $_POST array and cache it in the _field_data array.
            // Depending on whether the field name is an array or a string will determine where we get it from.
           
            if ($row['is_array'] == TRUE)
            {
                $this->_field_data[$field]['postdata'] = $this->_reduce_array($_POST, $row['keys']);
            }
            else
            {
                if (isset($_POST[$field]) AND $_POST[$field] != "")
                {
                    $this->_field_data[$field]['postdata'] = $_POST[$field];
                }
            }
       
            $this->_execute($row, explode('|', $row['rules']), $this->_field_data[$field]['postdata']);        
        }

        // Did we end up with any errors?
        $total_errors = count($this->_error_array);

        if ($total_errors > 0)
        {
            $this->_safe_form_data = TRUE;
        }

        // Now we need to re-set the POST data with the new, processed data
        $this->_reset_post_array();
       
        // No errors, validation passes!
        if ($total_errors == 0)
        {
            $this->CI->csrf->clear_token();
            return TRUE;
        }

        // Validation fails
        return FALSE;
    }
   
    /**
     * Validates the token sent from POST
     *
     * @access    private
     * @return    void
     */
   
    function _validate_token() {
        log_message('debug', 'My_Form_validation::_validate_token() called');
       
        // Form ID and token from the POST input
        $in_formID = $this->CI->input->post('formid');
        $in_token  = $this->CI->input->post('token');
       
        // Validate token from POST
        if(!$this->CI->csrf->validate_token($in_formID, $in_token)) {
            log_message('debug', 'My_Form_validation::_validate_token() bad token');
           
            // Create a new token and set the error
            $this->CI->csrf->create_token();
            $this->_error_array[] = $this->CI->lang->line('my_form_validation_bad_token');
        }
       
        // Token is fine. Save it for reuse in case other validation tests fail
        else {
            $this->CI->csrf->save_token($in_formID, $in_token);
        }      
    }
}

All code on this site is free for use at your own risk and provided as-is under the WTFPL license unless otherwise stated. Attribution is appreciated but not required.
Blog content, with the exception of externally quoted material, is licensed under the Creative Commons Attribution 3.0 license