Protection Against Cross Site Request Forgery (CSRF)
In working on the Geeklog 2 codebase, much of work forms the basis for stuff I do at work, I finally figured it was time to add handling for CSRF to the codebase in a way that forces it's use without the developer having to explicitly do anything. In my experience security has, sadly, become one of the last things on the mind of PHP developers (kudos to you numerous exceptions out there) so finding a way to help the developers protect their application from CSRF in a way that avoids them from having to explicitly consider and handle it themselves was key. However, to better illustrate my needs let's discuss a few high level requirements, shall we?
- CSRF solution must apply to any and all forms in the system.
- CSRF solution must work even in the case where the page served has multiple forms
- CSRF solution must work for both GET and POST
- CSRF solution must be applied to all forms without the developer having to make any explicit method calls to CSRF-related functions.
- The security token must be autogenerated and inserted into each form
- Each command (we operate in an MVC model) must have the ability to specify where it expects the token to be (e.g. POST or GET). The default value for this must be in the $_POST (in fact I question if using GET at all makes any sense).
- The solution must not check for the token in the $_REQUEST
- The solution must allow each command to set the TTL for the token.
- Should no TTL be explicitly given, the system must use the default of 3 minutes
Ok, so armed with those requirements here is how the implementation went. Not unlike most MVC implementations we have a class representing a view (or a single page within the system). We have a few abstract classes in our codebase but the one of most importanc is BaseViewFlexy.php. The class in this page uses the Flexy template engine. In this class there is a compile() method that takes the given Flexy template and compiles it into PHP code. This is the point where we want to insert our security token and the best way to do this was to create our own Flexy compiler that does this work with the following method:
protected function injectSecurityToken($content)
{
$content = str_ireplace('</form>',
'<input type="hidden" name="glSecurityToken" /></form>',
$content);
return $content;
}
If you notice above I don't actually inject the value. Remember that we have bootstrapped the Flexy compile process so the above code ends up in the compiled Flexy template which, again, is PHP code. Now we need to insert the value for the token when we do our normal Flexy variable substitution. To do this our BaseViewFlexy.php file has a compile() method:
public function compile($templateToCompile)
{
// Compile the flexy template using our custom compiler
$this->flexyHandle->compile($templateToCompile);
$this->flexyElements = $this->flexyHandle->getElements();
// Use Flexy to inject token (if needed)
if (in_array('glSecurityToken',array_keys($this->flexyElements))) {
$this->flexyElements['glSecurityToken']->setValue($this->getSecurityToken());
}
}
You'll notice the call to getSecurityToken() above. That is the method where we generate the token, set the token and token creation time in the session:
protected function getSecurityToken()
{
$token = md5(uniqid(rand(), TRUE));
$_SESSION['glSecurityToken'] = $token;
$_SESSION['glSecurityTokenTime'] = time();
return $token;
}
Ok, so to this point we have insert the HTML hidden field that will hold the security token into our compiled Flexy template and we have set the appropriate value for the token and added the token and token creation time to the session. Because we did all this by overriding methods Flexy uses natively we have guaranteed this code will be executed for every form without the developer having to explicitly call anything. Now we need to do the checks upon submission of the form itself.
In our MVC implementation as with our View we have the notion of a command. Specifically we have an abstract class in BaseCommandUser.php that is aware of the user session already. All we need to do is add the security check for the token to it's constructor:
public function __construct($urlArgs = null)
{
$this->user = unserialize($_SESSION[getOption('user_session_var')]);
if ($this->getUserRequired() AND ($this->user->getUserId() == 2)) {
// NOTE this makes use of the global forward feature in MVCnPHP
throw new Exception('doForward:login');
}
if (!$this->authorizedToRun()) {
throw new Exception('You do not have sufficient privileges to perform this action.
Please note that all attempts to illegally perform actions are logged');
}
// We may have data submitted to us. Check the security token
$this->doSecurityTokenCheck();
parent::__construct($urlArgs);
}
As you can see above we call doSecurityTokenCheck() which will throw an exception if it should fail. There are two reasons for failure 1) the token in the form doesn't match the one in the session and 2) the token has expired:
protected function doSecurityTokenCheck($inputArray = self::INPUT_POST)
{
if (!$this->doSecurityTokenChecks) return;
$arrayToCheck = array();
if ($inputArray == self::INPUT_POST) {
$arrayToCheck = &$_POST;
} else {
if ($inputArray == self::INPUT_GET) {
$arrayToCheck = &$_GET;
}
}
// If we didn't get a token in the session before now set one
if (!isset($_SESSION['glSecurityToken'])) {
$_SESSION['glSecurityToken'] = md5(uniqid(rand(), TRUE));
}
if ($arrayToCheck['glSecurityToken'] == $_SESSION['glSecurityToken']) {
// Valid token. Validate age (if needed)
$tokenAge = time() - $_SESSION['glSecurityTokenTime'];
if (is_null($this->securityTokenTTL)) $this->setSecurityTokenTTL();
if (!($tokenAge <= $this->securityTokenTTL)) {
throw new Exception('The form you submitted has expired. Please go back and try again');
}
} else {
Geeklog_Log::log('Cross site forgery request (CSFR) detected', Zend_Log::CRIT);
throw new Exception('Cross site forgery request (CSFR) detected. Please note all
such attempts are logged.');
}
return;
}
As you can see from above you can set the class member doSecurityTokenChecks to avoid doing these checks at all (i.e. in the case of a view with no form fields). There is also a class member, securityTokenTTL, which can be set on a command-by-command basis. For sanity sake we default to 3 minutes.
That's it! I don't pretend this is perfect code but it does work and is in line with Chris Shiflett's approach which I liked. With a little bit of work we were able to build in protections against CSRF, which I don't claim to be 100% complete but do make such attacks considerably harder, and it is done in a way that secures all forms without the developer having to explicitly call anything. A true win-win in my opinion!