PHP Advent Calendar Day 24

24 Dec 2007

Today's entry is provided by Nate Abele.

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:

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:

  1. <?php
  2.  
  3. class AccountsController extends AppController {
  4.     var $components = array('Trending');
  5.  
  6.     function beforeFilter () {
  7.         $this->Trending->track(array('User-Agent' => 20,
  8.                                      'Net:!' => '+30 minutes'));
  9.     }
  10.  
  11.     function index() {
  12.         /* ... */
  13.     }
  14.  
  15.     function edit($id = null) {
  16.         /* ... */
  17.     }
  18.  
  19.     /* ... */
  20. }
  21.  
  22. ?>

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:

  1. <?php
  2. /* SVN FILE: $Id: session_history.php 5112 2007-05-15 20:01:44Z nate $ */
  3. /**
  4.  * Define session history rules to mitigate session attacks
  5.  *
  6.  * Concept by Chris Shiflett
  7.  *
  8.  * PHP versions 4 and 5
  9.  *
  10.  * CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
  11.  * Copyright 2005-2007, Cake Software Foundation, Inc.
  12.  * 1785 E. Sahara Avenue, Suite 490-204
  13.  * Las Vegas, Nevada 89104
  14.  *
  15.  * Licensed under The MIT License
  16.  * Redistributions of files must retain the above copyright notice.
  17.  *
  18.  * @filesource
  19.  * @copyright Copyright 2005-2007, Cake Software Foundation, Inc.
  20.  * @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
  21.  * @package cake
  22.  * @subpackage cake.cake.libs.controller.components
  23.  * @since CakePHP(tm) v 0.10.0.1076
  24.  * @version $Revision: 5112 $
  25.  * @modifiedby $LastChangedBy: nate $
  26.  * @lastmodified $Date: 2007-05-15 16:01:44 -0400 (Tue, 15 May 2007) $
  27.  * @license http://www.opensource.org/licenses/mit-license.php The MIT License
  28.  */
  29. uses('security');
  30.  
  31. /**
  32.  * Allows the user to define historical tracking thresholds for trending commonly-repeated HTTP
  33.  * request headers.
  34.  *
  35.  * Also allows for defining handling rules for when trends are violated.
  36.  *
  37.  * @package cake
  38.  * @subpackage cake.cake.libs.controller.components
  39.  */
  40. class TrendingComponent extends Object {
  41. /**
  42.  * Components used by this class
  43.  *
  44.  * @var array
  45.  */
  46.     var $components = array('Session', 'Security', 'RequestHandler');
  47. /**
  48.  * Contains the rules...
  49.  *
  50.  * @var array
  51.  */
  52.     var $rules = array();
  53. /**
  54.  * After checks have been run, this holds any rules which have met the threshold but failed validation
  55.  *
  56.  * @var array
  57.  */
  58.     var $failures = array();
  59. /**
  60.  * A controller method to call should the validation fail
  61.  *
  62.  * @var string
  63.  */
  64.     var $callback = null;
  65. /**
  66.  * Sets trending threshold / violation handling rules for trending
  67.  * request data
  68.  *
  69.  * @param array $rules An array of rules, keyed by HTTP header name (or other criteria)
  70.  * @return void
  71.  */
  72.     function track($rules) {
  73.         $_rules = array();
  74.  
  75.         foreach ($rules as $criteria => $rule) {
  76.             if (is_int($criteria)) {
  77.                 $criteria = $rule;
  78.                 $rule = 20;
  79.             }
  80.             $rule = is_array($rule) ? $rule : array('threshold' => $rule);
  81.             $key = $criteria;
  82.  
  83.             if (strpos($criteria, ':') === false) {
  84.                 $criteria = 'HTTP_' . up(r(array('-', ' '), '_', $criteria));
  85.             } elseif (strpos($criteria, 'Net:') === 0) {
  86.                 /*
  87.                  * Network class rules:
  88.                  * Net:domain
  89.                  * Net:subnet
  90.                  * Net:{3}.{3}.{~20}.* (Where * is a wildcard match, and ~20 represents a range [+/- 20])
  91.                  *
  92.                  * The only rule actually defined so far is "!", which means an exact match
  93.                  * @todo Finish the parsing/matching code for network class rules
  94.                  */
  95.             } else {
  96.                 // Other types of rules
  97.             }
  98.             $rule['key'] = $key;
  99.             $_rules[$criteria] = $rule;
  100.         }
  101.         $this->rules = $_rules;
  102.     }
  103. /**
  104.  * Checks rule definitions setup in track(), and manages increments/removals for
  105.  * rules below the threshold.
  106.  *
  107.  * @param object $controller
  108.  * @return void
  109.  */
  110.     function startup(&$controller) {
  111.         $this->failures = $this->incrementAndInvalidate();
  112.  
  113.         if (!empty($this->failures)) {
  114.             if (!empty($this->callback)) {
  115.                 $controller->{$this->callback}();
  116.             } else {
  117.                 $this->Security->blackHole($controller, 'login');
  118.             }
  119.         }
  120.     }
  121. /**
  122.  * Increment the token for each request rules
  123.  *
  124.  * @return void
  125.  */
  126.     function incrementAndInvalidate() {
  127.         $failures = array();
  128.  
  129.         foreach ($this->rules as $header => $rule) {
  130.             $cur = $this->_config($header);
  131.  
  132.             if (strpos($header, ':') === false) {
  133.                 $hashValue = Security::hash(Configure::read('Security.salt') . env($header));
  134.  
  135.                 if (isset($cur['value']) && $this->thresholdMet($header)) {
  136.                     if ($cur['value'] != $hashValue) {
  137.                         $failures[] = $header;
  138.                     }
  139.                 } else {
  140.                     $cur = $this->increment($rule, $cur, $hashValue);
  141.                 }
  142.             } else {
  143.                 list($type, $match) = explode(':', $header, 2);
  144.  
  145.                 switch ($type) {
  146.                     case 'Net':
  147.                         $value = $this->RequestHandler->getClientIP();
  148.  
  149.                         if (!isset($cur['value'])) {
  150.                             $cur['value'] = $this->_getNetworkAddressPattern($match, $value);
  151.                         }
  152.  
  153.                         if (preg_match($cur['value'], $value)) {
  154.                             if (!$this->thresholdMet($header)) {
  155.                                 $cur = $this->increment($rule, $cur, $value);
  156.                             }
  157.                         } else {
  158.                             if (!$this->thresholdMet($header)) {
  159.                                 // Reset the count and the network pattern
  160.                                 $cur = $this->increment($rule, null, $cur['value']);
  161.                             } else {
  162.                                 $failures[] = $header;
  163.                             }
  164.                         }
  165.                     break;
  166.                 }
  167.                 // Implement other counter checks here
  168.             }
  169.             $this->_config($header, $cur);
  170.         }
  171.         return $failures;
  172.     }
  173. /**
  174.  * Returns true if the threshold for a rule has been met
  175.  *
  176.  * @param string $rule
  177.  * @return boolean
  178.  */
  179.     function increment($rule, $state, $value) {
  180.         $state = is_array($state) ? $state : array();
  181.         $state['value'] = isset($state['value']) ? $state['value'] : $value;
  182.  
  183.         if (is_int($rule['threshold'])) {
  184.             $state['count'] = ($state['value'] == $value) ? $state['count'] + 1 : 0;
  185.         } else {
  186.             if (empty($state['count'])) {
  187.                 $state['count'] = strtotime($rule['threshold']);
  188.             }
  189.         }
  190.         return $state;
  191.     }
  192. /**
  193.  * Returns true if the threshold for a rule has been met
  194.  *
  195.  * @param string $rule
  196.  * @return boolean
  197.  */
  198.     function thresholdMet($rule) {
  199.         if (!isset($this->rules[$rule])) {
  200.             foreach ($this->rules as $key => $val) {
  201.                 if ($val['key'] == $rule) {
  202.                     $rule = $key;
  203.                     break;
  204.                 }
  205.             }
  206.         }
  207.  
  208.         if (!isset($this->rules[$rule])) {
  209.             return false;
  210.         }
  211.         $config = $this->rules[$rule];
  212.         $cur = $this->_config($rule);
  213.  
  214.         if (!$cur['count']) {
  215.             return false;
  216.         }
  217.         $base = strtotime(date('Y') . '-1-1');
  218.  
  219.         if ($cur['count'] < $base && $config['threshold'] < $base) {
  220.             // This is a request-count-based threshold
  221.             return ($cur['count'] >= $config['threshold']);
  222.         } else {
  223.             // This is a time-based threshold
  224.             return ($cur['count'] < strtotime('now'));
  225.         }
  226.         return false;
  227.     }
  228. /**
  229.  * Returns the stored session state for a given threshold rule
  230.  *
  231.  * @param string $key A rule name array key
  232.  * @param array $state An optional state to save to the session
  233.  * @return mixed
  234.  */
  235.     function _config($key, $state = null) {
  236.         $key = r(array('.', '!', ':', '{', '}', '(', ')'), '_', $key);
  237.  
  238.         if ($state === null) {
  239.             $state = $this->Session->read("Session.History.{$key}");
  240.             return (empty($state) ? array('count' => 0) : $state);
  241.         } else {
  242.             return $this->Session->write("Session.History.{$key}", $state);
  243.         }
  244.     }
  245. /**
  246.  * Generates a regular expression from a network address and a match pattern
  247.  *
  248.  * @param string $pattern
  249.  * @param string $base
  250.  * @return string
  251.  */
  252.     function _getNetworkAddressPattern($pattern, $base) {
  253.         $result = '.*';
  254.  
  255.         if ($pattern == '!') {
  256.             $result = preg_quote($base, '/');
  257.         }
  258.         return '/' . $result . '/';
  259.     }
  260. }
  261.  
  262. ?>

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!