CakePHP’s security component is awesome. I use it all the time for its CSRF protection (Which will be the subject of a future post).
But sometimes, it does not work as expected. Frustration ensues.
In one of my projects, I have a form with a few text areas on it. The user uses this form to create or edit a story. After adding the security component to the controller, I noticed that when the user submits the form they get blackholed, and the story was not saved. With users pouring their hearts into the stories, this is really bad.
This behaviour seemed strange, to say the least.
The first thing I did was look for the session timing out on me. In core.php I have the following:
Configure::write('Session.timeout', '120');
based on Cake’s configuration, these should set the session timeout to 20 minutes (120 multiplied by 10). This may be a little too short for writing stories, so I raised the timeout value to 240 – resulting in 40 minutes, which is definitely enough. The stories are not THAT long.
However, this did not change the system’s behaviour. It almost seemed like the session times out long before the 40 minutes passed.
Enter Security component
When I removed the Security component from the controller, all was working great. It was time to delve into the code.
So, how does this security component work, anyway?
When you use the security component, Cake generates your forms differently.
Notice: In order for the security component to work correctly, you must use the form helper to create all of the fields on the form, as well as creating the form with $form->create() and closing it with $form->end().
Consider this in the view:
//add.ctp
echo $form->create('Story', array('target'=> '_parent') );
echo $form->input('title' , array('label' => false));
echo $form->input('excerpt', array('rows' => '20', 'cols' => '80', 'label'=> false));
echo $form->input('body', array('rows' => '20', 'cols' => '80', 'label'=> false));
echo $form->input('notes', array('rows' => '20', 'cols' => '80', 'label'=> false));
echo $form->end(array('name' => 'save'));
?>
When the security component is not activated, the resulting form looks like this:
<fieldset style="display:none;">
<input type="hidden" name="_method" value="POST" />
</fieldset>
<div class="input text">
<input name="data[Story][title]" type="text" value="" id="StoryTitle" />
</div>
<div class="input text">
<textarea name="data[Story][excerpt]" cols="80" rows="20" id="StoryExcerpt" ></textarea>
</div>
<div class="input text">
<textarea name="data[Story][body]" cols="80" rows="20" id="StoryBody" ></textarea>
</div>
<div class="input text">
<textarea name="data[Story][notes]" cols="80" rows="20" id="StoryNotes" ></textarea>
</div>
<div class="submit">
<input type="submit" name="save" value="Submit" />
</div>
</form>
As you can see, this is just a simple html form.
To have the security component work its magic, you add it to the uses array in the controller:
var $components = array('Security','Auth','RequestHandler');
With the Security component activated this way, the form is generated with a few extra fields:
<fieldset style="display:none;">
<input type="hidden" name="_method" value="POST" />
<input type="hidden" name="data[_Token][key]" value="5a364d27f9e39686bb6032ceed0a4ebcc1abba8e" id="Token1821306795" />
</fieldset>
<div class="input text">
<input name="data[Story][title]" type="text" value="" id="StoryTitle" />
</div>
<div class="input text">
<textarea name="data[Story][excerpt]" cols="80" rows="20" id="StoryExcerpt" ></textarea>
</div>
<div class="input text">
<textarea name="data[Story][body]" cols="80" rows="20" id="StoryBody" ></textarea>
</div>
<div class="input text">
<textarea name="data[Story][notes]" cols="80" rows="20" id="StoryNotes" ></textarea>
</div>
<div class="submit">
<input type="submit" name="save" value="Submit" />
</div>
<fieldset style="display:none;">
<input type="hidden" name="data[_Token][fields]" value="6e6401136c6c253e621550b05eaefa112da437c2%3An%3A0%3A%7B%7D" id="TokenFields1859539122" />
</fieldset>
</form>
Assuming you used the $form helper to generate the form and all its fields, the Security component adds two more hidden fields to it:
data[_Token][key] – this is a token that marks this form as generated using the Security component. The token is unique to each form, and when the user submits the form back, Security component verifies that the token is valid. This is to ensure that the form originated from your website, and prevents attackers from crafting similar forms themselves and submitting them to your application.
data[_Token][fields] is a hash of all the fields created using the $form helper. When the form is submitted a valid key, Security component also validates that the hash matches. This way, if an attacker managed to get a valid token, they can’t add fields to the form that weren’t there to begin with.
(Note: it is possible to instruct the Security component to disregard specific form-fields when generating and validating the data[_Token][fields] hash. You can do it in your beforeFilter() method by using the $this->Security->disabledFields array).
Blackholing Requests
If either the key or hash submitted with the form is invalid (or missing altogether), the form submit request will be blackholed. This will usually result in your users getting a blank page, and the data not saved anywhere. Obviously, this was happening to my stories, but why?
The token was valid, because I got it from the Security component to begin with. The hash was valid, as I never tempered with the form. All the signs pointed to a timeout…
Your token has a time to live as well!
Diving into Cake’s Security component code, I found the following:
..
function _generateToken(&$controller) {
...
$authKey = Security::generateAuthKey();
$expires = strtotime('+' . Security::inactiveMins() . ' minutes');
$token = array(
'key' => $authKey,
'expires' => $expires,
'allowedControllers' => $this->allowedControllers,
'allowedActions' => $this->allowedActions,
'disabledFields' => $this->disabledFields
);
...
So, when a token is generated, it gets an expiration period. Mmm… so let’s have a look at the inactiveMins() method.
..
function inactiveMins() {
$_this =& Security::getInstance();
switch (Configure::read('Security.level')) {
case 'high':
return 10;
break;
case 'medium':
return 100;
break;
case 'low':
default:
return 300;
break;
}
}
..
See what happens here? Regardless of the timeout you configure for your sessions, the token expiry time relies only on your Security.level settings. This means that my tokens may expire long before my session does. Which, apparently, is exactly what happens…
The way the session timeout is calculated, is that the number you set for Session.timeout in core.php is multiplied by a number depending on your Security.level setting.
For Security.level = high, this number is 10.
For Security.level = medium, this number is 100.
For Security.level = low, this number is 300.
See the correlation? Instead of returning the calculated session timeout, this method returns only part of the equation!
Now, there may be some very good reasons for that, but none of them help me when I want all the benefits of a high Security.level setting together with allowing my users ample time to write their stories.
It is time to change cake’s code!
..
function inactiveMins() {
$_this =& Security::getInstance();
$timeout = Configure::read('Session.timeout');
if (!isset($timeout)) {
$timeout = 1; // if not configured - use the original pattern
}
switch (Configure::read('Security.level')) {
case 'high':
$factor = 10 ;
break;
case 'medium':
$factor = 100 ;
break;
case 'low':
default:
$factor = 300 ;
break;
}
return $factor * $timeout / 60;
}
Using the piece of code above, inactiveMins(), when called, returns a number of minutes that’s equal to your session timeout. That’s it, problem solved. Security component will not blackhole my innocent users. Now, let the stories roll…
Popularity: 16% [?]
Thanks for this post; you probably just saved me a considerable amount of debugging! It’s odd to me that SecurityComponent doesn’t respect app configured session timeouts by default. Have you brought this up with the core team?
Thanks! I was having this problem in two projects and this probably saved me a lot of debugging. This problem exists in both Cake 1.2 and 1.3. I hope the Cake devs correct this so that it doesn’t have to be fixed with a core hack.