PHP Advent Calendar Day 24
24 Dec 2007Today's entry is provided by Nate Abele.
- Name
- Nate Abele
- Blog
- cake.insertdesignhere.com
- Biography
- Nate Abele of OmniTI has been a core developer of the CakePHP web framework for over two years. He is known in some circles as the Johnny Cash (or "Man in Black") of the PHP community.
- Location
- New York, New York
Today, dear readers, I offer you no lofty words of wisdom, no dishwasher analogies, and no deep thoughts to ponder. What I can offer is a piece of simple, practical advice that you can use today, and some bits of code that I humbly submit to you below. Without further ado, let's begin.
Late last year, Chris and I (mostly Chris), came up with a clever defense-in-depth strategy for session security over a couple of beers. (We were sober most of the time; why do you ask?) The basic idea is that when dealing with session security, you want to do the best you can to ensure that the authenticated user you started talking to is the same user you're talking to now. You're probably familiar with at least some of the various forms of session attacks, so I won't bore you with the details.
There are all sorts of details in each request that you can potentially use to help be sure that you're still talking to the same user. Examples include the user's IP address and the User-Agent
header. Hopefully you know that most of these details aren't reliable, because proxies and various other factors can change these things over the course of a legitimate user's session, causing you to potentially mistake a good guy for a bad guy. Common wisdom is that you can therefore never use such information.
The solution might not be black and white. If the user's IP address has been consistent over the last 50 requests, is it reasonable to assume that it's going to be the same for the next request? If it's suddenly not the same, then it seems reasonable to treat the request with a modicum of suspicion. To mitigate the risk, all you need to do is ask the user to re-authenticate. This can be as simple as prompting for the password again, a minor inconvenience for legitimate users, but a big hinderance to attackers.
In order to implement this simple system, you need two things:
A piece of information (key) you believe can be consistent, at least for a reasonably large portion of your users. (For example, an IP address.)
A threshold, after which you believe you can rely on this piece of information to be consistent in future requests.
The threshold is the important part; it's what makes this idea work. The threshold can be either a number of requests or an interval of time, but after this threshold has been met, the key is considered trustworthy, and any changes to its value will raise a red flag. Note that it's important to properly tune your threshold rules so that they're appropriate for your application, your users, and the piece of information you're tracking. Analyze your log files, and find and a sensible balance.
While the concept itself is simple, and can be implemented in any number of ways, I've created it as a component for CakePHP that you can use as follows:
<?php
class AccountsController extends AppController {
var $components = array('Trending');
function beforeFilter () {
$this->Trending->track(array('User-Agent' => 20,
'Net:!' => '+30 minutes'));
}
function index() {
/* ... */
}
function edit($id = null) {
/* ... */
}
/* ... */
}
?>
This example sets up two rules: one for the User-Agent
header, and one for the IP address. In the context of domain-specific language I've constructed for these rules, :
denotes a type of matching other than HTTP headers (e.g., Net
), and !
means an exact match. So, this example only establishes a trend if the IP address is an exact match during a 30-minute period. For specifying thresholds, use strings to represent time (relative to now), and integers to represent a number of requests. So, this example establishes another trend if the User-Agent
header remains consistent for 20 requests.
If either of these values change after the threshold is met, the trending component is going to to simply blackhole the request by sending back an error page and preventing any further execution. However, it has a callback property which can be used for more graceful handling, such as redirecting to a login page.
For reference, the current implementation is provided in Listing 1.
Listing 1:
<?php
/* SVN FILE: $Id: session_history.php 5112 2007-05-15 20:01:44Z nate $ */
/**
* Define session history rules to mitigate session attacks
*
* Concept by Chris Shiflett
*
* PHP versions 4 and 5
*
* CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
* Copyright 2005-2007, Cake Software Foundation, Inc.
* 1785 E. Sahara Avenue, Suite 490-204
* Las Vegas, Nevada 89104
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @filesource
* @copyright Copyright 2005-2007, Cake Software Foundation, Inc.
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
* @package cake
* @subpackage cake.cake.libs.controller.components
* @since CakePHP(tm) v 0.10.0.1076
* @version $Revision: 5112 $
* @modifiedby $LastChangedBy: nate $
* @lastmodified $Date: 2007-05-15 16:01:44 -0400 (Tue, 15 May 2007) $
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
*/
uses('security');
/**
* Allows the user to define historical tracking thresholds for trending commonly-repeated HTTP
* request headers.
*
* Also allows for defining handling rules for when trends are violated.
*
* @package cake
* @subpackage cake.cake.libs.controller.components
*/
class TrendingComponent extends Object {
/**
* Components used by this class
*
* @var array
*/
var $components = array('Session', 'Security', 'RequestHandler');
/**
* Contains the rules...
*
* @var array
*/
var $rules = array();
/**
* After checks have been run, this holds any rules which have met the threshold but failed validation
*
* @var array
*/
var $failures = array();
/**
* A controller method to call should the validation fail
*
* @var string
*/
var $callback = null;
/**
* Sets trending threshold / violation handling rules for trending
* request data
*
* @param array $rules An array of rules, keyed by HTTP header name (or other criteria)
* @return void
*/
function track($rules) {
$_rules = array();
foreach ($rules as $criteria => $rule) {
if (is_int($criteria)) {
$criteria = $rule;
$rule = 20;
}
$rule = is_array($rule) ? $rule : array('threshold' => $rule);
$key = $criteria;
if (strpos($criteria, ':') === false) {
$criteria = 'HTTP_' . up(r(array('-', ' '), '_', $criteria));
} elseif (strpos($criteria, 'Net:') === 0) {
/*
* Network class rules:
* Net:domain
* Net:subnet
* Net:{3}.{3}.{~20}.* (Where * is a wildcard match, and ~20 represents a range [+/- 20])
*
* The only rule actually defined so far is "!", which means an exact match
* @todo Finish the parsing/matching code for network class rules
*/
} else {
// Other types of rules
}
$rule['key'] = $key;
$_rules[$criteria] = $rule;
}
$this->rules = $_rules;
}
/**
* Checks rule definitions setup in track(), and manages increments/removals for
* rules below the threshold.
*
* @param object $controller
* @return void
*/
function startup(&$controller) {
$this->failures = $this->incrementAndInvalidate();
if (!empty($this->failures)) {
if (!empty($this->callback)) {
$controller->{$this->callback}();
} else {
$this->Security->blackHole($controller, 'login');
}
}
}
/**
* Increment the token for each request rules
*
* @return void
*/
function incrementAndInvalidate() {
$failures = array();
foreach ($this->rules as $header => $rule) {
$cur = $this->_config($header);
if (strpos($header, ':') === false) {
$hashValue = Security::hash(Configure::read('Security.salt') . env($header));
if (isset($cur['value']) && $this->thresholdMet($header)) {
if ($cur['value'] != $hashValue) {
$failures[] = $header;
}
} else {
$cur = $this->increment($rule, $cur, $hashValue);
}
} else {
list($type, $match) = explode(':', $header, 2);
switch ($type) {
case 'Net':
$value = $this->RequestHandler->getClientIP();
if (!isset($cur['value'])) {
$cur['value'] = $this->_getNetworkAddressPattern($match, $value);
}
if (preg_match($cur['value'], $value)) {
if (!$this->thresholdMet($header)) {
$cur = $this->increment($rule, $cur, $value);
}
} else {
if (!$this->thresholdMet($header)) {
// Reset the count and the network pattern
$cur = $this->increment($rule, null, $cur['value']);
} else {
$failures[] = $header;
}
}
break;
}
// Implement other counter checks here
}
$this->_config($header, $cur);
}
return $failures;
}
/**
* Returns true if the threshold for a rule has been met
*
* @param string $rule
* @return boolean
*/
function increment($rule, $state, $value) {
$state = is_array($state) ? $state : array();
$state['value'] = isset($state['value']) ? $state['value'] : $value;
if (is_int($rule['threshold'])) {
$state['count'] = ($state['value'] == $value) ? $state['count'] + 1 : 0;
} else {
if (empty($state['count'])) {
$state['count'] = strtotime($rule['threshold']);
}
}
return $state;
}
/**
* Returns true if the threshold for a rule has been met
*
* @param string $rule
* @return boolean
*/
function thresholdMet($rule) {
if (!isset($this->rules[$rule])) {
foreach ($this->rules as $key => $val) {
if ($val['key'] == $rule) {
$rule = $key;
break;
}
}
}
if (!isset($this->rules[$rule])) {
return false;
}
$config = $this->rules[$rule];
$cur = $this->_config($rule);
if (!$cur['count']) {
return false;
}
$base = strtotime(date('Y') . '-1-1');
if ($cur['count'] < $base && $config['threshold'] < $base) {
// This is a request-count-based threshold
return ($cur['count'] >= $config['threshold']);
} else {
// This is a time-based threshold
return ($cur['count'] < strtotime('now'));
}
return false;
}
/**
* Returns the stored session state for a given threshold rule
*
* @param string $key A rule name array key
* @param array $state An optional state to save to the session
* @return mixed
*/
function _config($key, $state = null) {
$key = r(array('.', '!', ':', '{', '}', '(', ')'), '_', $key);
if ($state === null) {
$state = $this->Session->read("Session.History.{$key}");
return (empty($state) ? array('count' => 0) : $state);
} else {
return $this->Session->write("Session.History.{$key}", $state);
}
}
/**
* Generates a regular expression from a network address and a match pattern
*
* @param string $pattern
* @param string $base
* @return string
*/
function _getNetworkAddressPattern($pattern, $base) {
$result = '.*';
if ($pattern == '!') {
$result = preg_quote($base, '/');
}
return '/' . $result . '/';
}
}
?>
Future plans for the component include scoping by network address class and additional flagging options, but there are plenty of other things you can do such as integrating an IP-to-coordinate system, and making the threshold geography-based. There are numerous of other possibilities as well, but the important thing is to start thinking more broadly about your security strategies. Find patterns in your data and learn to use them to your advantage.
Happy holidays!