Whitelist HTML sanitizing with PHP

The following is a single class written to perform comprehensive HTML input filtering with minimal dependencies (basically only Tidy) and should work in PHP 5.3+. This will be included in my forum script as the default filter.

This version captures URL encoded XSS attempts with deep attribute inspection (to a decoding depth of 6 by default) as well as scrubbing all non-whitelisted attributes, tags and conversion of surviving attribute data into HTML entities.

In addition, it will attempt to capture directory traversal attempts ( ../ or \\ or /~/ etc… ) which may give access to restricted areas of a site. Your web server should deny access to these URLs by default, however that won’t stop someone from posting links pointing elsewhere. This will reduce your liability should such a link be included in your site content by a user.

You can post sourcecode within <code> tags and it will be encoded by default.

<?php

/**
 * HTML parsing, filtering and sanitization
 * This class depends on Tidy which is included in the core since PHP 5.3
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 * @version 0.2
 */

class Html {
	
	/**
	 * @var array HTML filtering options
	 */
	public static $options = array( 
		'rx_url'	=> // URLs over 255 chars can cause problems
			'~^(http|ftp)(s)?\:\/\/((([a-z|0-9|\-]{1,25})(\.)?){2,7})($|/.*$){4,255}$~i',
		
		'rx_js'		=> // Questionable attributes
			'/((java)?script|eval|document)/ism',
		
		'rx_xss'	=> // XSS (<style> can also be a vector. Stupid IE 6!)
			'/(<(s(?:cript|tyle)).*?)/ism',
		
		'rx_xss2'	=> // More potential XSS
			'/(document\.|window\.|eval\(|\(\))/ism',
		
		'rx_esc'	=> // Directory traversal/escaping/injection
			'/(\\~\/|\.\.|\\\\|\-\-)/sm'	,
		
		'scrub_depth'	=> 6, // URL Decoding depth (fails on exceeding this)
		
		'nofollow'	=> true // Set rel='nofollow' on all links

	);
	
	/**
	 * @var array List of HTML Tidy output settings
	 * @link http://tidy.sourceforge.net/docs/quickref.html
	 */
	private static $tidy = array(
		// Preserve whitespace inside tags
		'add-xml-space'			=> true,
		
		// Remove proprietary markup (E.G. og:tags)
		'bare'				=> true,
		
		// More proprietary markup
		'drop-proprietary-attributes'	=> true,
		
		// Remove blank (E.G. <p></p>) paragraphs
		'drop-empty-paras'		=> true,
		
		// Wraps bare text in <p> tags
		'enclose-text'			=> true,
		
		// Removes illegal/invalid characters in URIs
		'fix-uri'			=> true,
		
		// Removes <!-- Comments -->
		'hide-comments'			=> true,
		
		// Removing indentation saves storage space
		'indent'			=> false,
		
		// Combine individual formatting styles
		'join-styles'			=> true,
		
		// Converts <i> to <em> & <b> to <strong>
		'logical-emphasis'		=> true,
		
		// Byte Order Mark isn't really needed
		'output-bom'			=> false,
		
		// Ensure UTF-8 characters are preserved
		'output-encoding'		=> 'utf8',
		
		// W3C standards compliant markup
		'output-xhtml'			=> true,
		
		// Had some unexpected behavior with this
		//'markup'			=> true,

		// Merge multiple <span> tags into one		
		'merge-spans'			=> true,
		
		// Only outputs <body> (<head> etc... not needed)
		'show-body-only'		=> true,
		
		// Removing empty lines saves storage
		'vertical-space'		=> false,
		
		// Wrapping tags not needed (saves bandwidth)
		'wrap'				=> 0
	);
	
	
	/**
	 * @var array Whitelist of tags. Trim or expand these as necessary
	 * @example 'tag' => array( of, allowed, attributes )
	 */
	private static $whitelist = array(
		'p'		=> array( 'style', 'class', 'align' ),
		'div'		=> array( 'style', 'class', 'align' ),
		'span'		=> array( 'style', 'class' ),
		'br'		=> array( 'style', 'class' ),
		'hr'		=> array( 'style', 'class' ),
		
		'h1'		=> array( 'style', 'class' ),
		'h2'		=> array( 'style', 'class' ),
		'h3'		=> array( 'style', 'class' ),
		'h4'		=> array( 'style', 'class' ),
		'h5'		=> array( 'style', 'class' ),
		'h6'		=> array( 'style', 'class' ),
		
		'strong'	=> array( 'style', 'class' ),
		'em'		=> array( 'style', 'class' ),
		'u'		=> array( 'style', 'class' ),
		'strike'	=> array( 'style', 'class' ),
		'del'		=> array( 'style', 'class' ),
		'ol'		=> array( 'style', 'class' ),
		'ul'		=> array( 'style', 'class' ),
		'li'		=> array( 'style', 'class' ),
		'code'		=> array( 'style', 'class' ),
		'pre'		=> array( 'style', 'class' ),
		
		'sup'		=> array( 'style', 'class' ),
		'sub'		=> array( 'style', 'class' ),
		
		// Took out 'rel' and 'title', because we're using those below
		'a'		=> array( 'style', 'class', 'href' ),
		
		'img'		=> array( 'style', 'class', 'src', 'height', 
					  'width', 'alt', 'longdesc', 'title', 
					  'hspace', 'vspace' ),
		
		'table'		=> array( 'style', 'class', 'border-collapse', 
					  'cellspacing', 'cellpadding' ),
					
		'thead'		=> array( 'style', 'class' ),
		'tbody'		=> array( 'style', 'class' ),
		'tfoot'		=> array( 'style', 'class' ),
		'tr'		=> array( 'style', 'class' ),
		'td'		=> array( 'style', 'class', 
					'colspan', 'rowspan' ),
		'th'		=> array( 'style', 'class', 'scope', 'colspan', 
					  'rowspan' ),
		
		'q'		=> array( 'style', 'class', 'cite' ),
		'cite'		=> array( 'style', 'class' ),
		'abbr'		=> array( 'style', 'class' ),
		'blockquote'	=> array( 'style', 'class' ),
		
		// Stripped out
		'body'		=> array()
	);
	
	
	
	/**#@+
	 * HTML Filtering
	 */
	
	
	/**
	 * Convert content between code blocks into code tags
	 * 
	 * @param $val string Value to encode to entities
	 */
	protected function escapeCode( $val ) {
		
		if ( is_array( $val ) ) {
			$out = self::entities( $val[1] );
			return '<code>' . $out . '</code>';
		}
		
	}
	
	
	/**
	 * Convert an unformatted text block to paragraphs
	 * 
	 * @link http://stackoverflow.com/a/2959926
	 * @param $val string Filter variable
	 */
	protected function makeParagraphs( $val ) {
		
		/**
		 * Convert newlines to linebreaks first
		 * This is why PHP both sucks and is awesome at the same time
		 */
		$out = nl2br( $val );
		
		/**
		 * Turn consecutive <br>s to paragraph breaks and wrap the 
		 * whole thing in a paragraph
		 */
		$out = '<p>' . preg_replace('#(?:<br\s*/?>\s*?){2,}#', 
			'<p></p><p>', $out ) . '</p>';
		
		/**
		 * Remove <br> abnormalities
		 */
		$out = preg_replace( '#<p>(\s*<br\s*/?>)+#', '</p><p>', $out );
		$out = preg_replace( '#<br\s*/?>(\s*</p>)+#', '<p></p>', $out );
		
		return $out;
	}
	
	
	/**
	 * Filters HTML content through whitelist of tags and attributes
	 * 
	 * @param $val string Value filter
	 */
	public function filter( $val ) {
		
		if ( !isset( $val ) || empty( $val ) ) {
			return '';
		}
		
		/**
		 * Escape the content of any code blocks before we parse HTML or 
		 * they will get stripped
		 */
		$out	= preg_replace_callback( "/\<code\>(.*)\<\/code\>/imu", 
				array( $this, 'escapeCode' ) , $val
			);
		
		/**
		 * Convert to paragraphs and begin
		 */
		$out	= $this->makeParagraphs( $out );
		$dom	= new DOMDocument();
		
		/**
		 * Hide parse warnings since we'll be cleaning the output anyway
		 */
		$err	= libxml_use_internal_errors( true );
		
		$dom->loadHTML( $out );
		$dom->encoding = 'utf-8';
		
		$body	= $dom->getElementsByTagName( 'body' )->item( 0 );
		$this->cleanNodes( $body, $badTags );
		
		/**
		 * Iterate through bad tags found above and convert them to 
		 * harmless text
		 */
		foreach ( $badTags as $node ) {
			if( $node->nodeName != "#text" ) {
				$ctext = $dom->createTextNode( 
						$dom->saveHTML( $node )
					);
				$node->parentNode->replaceChild( 
					$ctext, $node 
				);
			}
		}
		
		
		/**
		 * Filter the junk and return only the contents of the body tag
		 */
		$out = tidy_repair_string( 
				$dom->saveHTML( $body ), 
				self::$tidy
			);
		
		
		/**
		 * Reset errors
		 */
		libxml_clear_errors();
		libxml_use_internal_errors( $err );
		
		return $out;
	}
	
	
	protected function cleanAttributeNode( 
		&$node, 
		&$attr, 
		&$goodAttributes, 
		&$href 
	) {
		/**
		 * Why the devil is an attribute name called "nodeName"?!
		 */
		$name = $attr->nodeName;
		
		/**
		 * And an attribute value is still "nodeValue"?? Damn you PHP!
		 */
		$val = $attr->nodeValue;
		
		/**
		 * Default action is to remove the attribute completely
		 * It's reinstated only if it's allowed and only after 
		 * it's filtered
		 */
		$node->removeAttributeNode( $attr );
		
		if ( in_array( $name, $goodAttributes ) ) {
			
			switch ( $name ) {
				
				/**
				 * Validate URL attribute types
				 */
				case 'url':
				case 'src':
				case 'href':
				case 'longdesc':
					if ( self::urlFilter( $val ) ) {
						$href = $val;
					} else {
						$val = '';
					}
					break;
				
				/**
				 * Everything else gets default scrubbing
				 */
				default:
					if ( self::decodeScrub( $val ) ) {
						$val = self::entities( $val );
					} else {
						$val = '';
					}
			}
			
			if ( '' !== $val ) {
				$node->setAttribute( $name, $val );
			}
		}
	}
	
	
	/**
	 * Modify links to display their domains and add 'nofollow'.
	 * Also puts the linked domain in the title as well as the file name
	 */
	protected static function linkAttributes( &$node, $href ) {
		try {
			if ( !self::$options['nofollow'] ) {
				return;
			}
			
			$parsed	= parse_url( $href );
			$title	= $parsed['host'] . ' ';
			
			$f	= pathinfo( $parsed['path'] );
			$title	.= ' ( /' . $f['basename'] . ' ) ';
				
			$node->setAttribute( 
				'title', $title
			);
			
			if ( self::$options['nofollow'] ) {
				$node->setAttribute(
					'rel', 'nofollow'
				);
			}
			
		} catch ( Exception $e ) { }
	}
	
	
	/**
	 * Iterate through each tag and add non-whitelisted tags to the 
	 * bad list. Also filter the attributes and remove non-whitelisted ones.
	 * 
	 * @param htmlNode $node Current HTML node
	 * @param array $badTags Cumulative list of tags for deletion
	 */
	protected function cleanNodes( $node, &$badTags = array() ) {
		
		if ( array_key_exists( $node->nodeName, self::$whitelist ) ) {
			
			if ( $node->hasAttributes() ) {
				
				/**
				 * Prepare for href attribute which gets special 
				 * treatment
				 */
				$href = '';
				
				/**
				 * Filter through attribute whitelist for this 
				 * tag
				 */
				$goodAttributes = 
					self::$whitelist[$node->nodeName];
				
				
				/**
				 * Check out each attribute in this tag
				 */
				foreach ( 
					iterator_to_array( $node->attributes ) 
					as $attr ) {
					$this->cleanAttributeNode( 
						$node, $attr, $goodAttributes, 
						$href
					);
				}
				
				/**
				 * This is a link. Treat it accordingly
				 */
				if ( 'a' === $node->nodeName && '' !== $href ) {
					self::linkAttributes( $node, $href );
				}
				
			} // End if( $node->hasAttributes() )
			
			/**
			 * If we have childnodes, recursively call cleanNodes 
			 * on those as well
			 */
			if ( $node->childNodes ) {
				foreach ( $node->childNodes as $child ) {
					$this->cleanNodes( $child, $badTags );
				}
			}
			
		} else {
			
			/**
			 * Not in whitelist so no need to check its child nodes. 
			 * Simply add to array of nodes pending deletion.
			 */
			$badTags[] = $node;
			
		} // End if array_key_exists( $node->nodeName, self::$whitelist )
		
	}
	
	/**#@-*/
	
	
	/**
	 * Returns true if the URL passed value is harmless.
	 * This regex takes into account Unicode domain names however, it 
	 * doesn't check for TLD (.com, .net, .mobi, .museum etc...) as that 
	 * list is too long.
	 * The purpose is to ensure your visitors are not harmed by invalid 
	 * markup, not that they get a functional domain name.
	 * 
	 * @param string $v Raw URL to validate
	 * @returns boolean
	 */
	public static function urlFilter( $v ) {
		
		$v = strtolower( $v );
		$out = false;
		
		if ( filter_var( $v, 
			FILTER_VALIDATE_URL, FILTER_FLAG_SCHEME_REQUIRED ) ) {
			
			/**
			 * PHP's native filter isn't restrictive enough.
			 */
			if ( preg_match( self::$options['rx_url'], $v ) ) {
				$out = true;
			} else {
				$out = false;
			}
			
			if ( $out ) {
				$out = self::decodeScrub( $v );
			}
		} else {
			$out = false;
		}
		
		return $out;
	}
	
	
	/**
	 * Regular expressions don't work well when used for validating HTML.
	 * It really shines when evaluating text so that's what we're doing here
	 * 
	 * @param string $v string Attribute name
	 * @param int $depth Number of times to URL decode
	 * @returns boolean True if nothing unsavory was found.
	 */
	public static function decodeScrub( $v ) {
		if ( empty( $v ) ) {
			return true;
		}
		
		$depth		= self::$options['scrub_depth'];
		$i		= 1;
		$success	= false;
		$old		= '';
		
		
		while( $i <= $depth && !empty( $v ) ) {
			// Check for any JS and other shenanigans
			if (
				preg_match( self::$options['rx_xss'], $v ) || 
				preg_match( self::$options['rx_xss2'], $v ) || 
				preg_match( self::$options['rx_esc'], $v )
			) {
				$success = false;
				break;
			} else {
				$old	= $v;
				$v	= self::utfdecode( $v );
				
				/**
				 * We found the the lowest decode level.
				 * No need to continue decoding.
				 */
				if ( $old === $v ) {
					$success = true;
					break;
				}
			}
			
			$i++;
		}
		
		
		/**
		 * If after decoding a number times, we still couldn't get to 
		 * the original string, then there's something still wrong
		 */
		if ( $old !== $v && $i === $depth ) {
			return false;
		}
		
		return $success;
	}
	
	
	/**
	 * UTF-8 compatible URL decoding
	 * 
	 * @link http://www.php.net/manual/en/function.urldecode.php#79595
	 * @returns string
	 */
	public static function utfdecode( $v ) {
		$v = urldecode( $v );
		$v = preg_replace( '/%u([0-9a-f]{3,4})/i', '&#x\\1;', $v );
		return html_entity_decode( $v, null, 'UTF-8' );
	}
	
	
	/**
	 * HTML safe character entitites in UTF-8
	 * 
	 * @returns string
	 */
	public static function entities( $v ) {
		return htmlentities( 
			iconv( 'UTF-8', 'UTF-8', $v ), 
			ENT_NOQUOTES | ENT_SUBSTITUTE, 
			'UTF-8'
		);
	}	
}

Usage is pretty simple:

$data = $_POST['body'];
$html = new Html();
$data = $html->filter( $data );

PDO for Fun and Profit (mostly fun)

Connecting and retrieving information from the database is usually the least sexy part of writing an app and so gets left behind a lot. The following classes, which are going to be part of the forum script, (more or less) should take some of the dreariness out of CRUD; that’s Create, Read, Update and Delete. The cornerstones of our universe. Shout out to Kevin Worthington. (Do people still say “shout out”? Man, I feel old.)

Let’s begin with the (semi) universal PDO connector. If you use Postgres, you can include your entire connection string in one line including username and password :

//PostgreSQL :
define( 'DBH', 'pgsql:host=localhost;dbname=hitlist;user=MichaelCorleone;password=IKnowItWasYouFredo' );

However the MySQL PDO driver will complain if you just include a single string like that as it demands the username and password separately. So, you’ll have to do the following :

//MySQL PDO connection (usually)
define( 'DBH', 'mysql:host=localhost;dbname=hitlist' );
define( 'DB_USER', 'MichaelCorleone' );
define( 'DB_PASS', 'IKnowItWasYouFredo' );

$dbh = new PDO( DBH, DB_USER, DB_PASS );

This is annoying.

There’s also the problem of security. If you just leave the connection strings in your script and for some reason the script gets served in plain-text (this happens far more often than you might think) there go your keys to the castle. The safest place to store connection strings is in your php.ini.

You can put the DSN name in a php.ini config as follows :

php.dsn.mydsn ='mysql:host=localhost;dbname=hitlist;username=MichaelCorleone;password=IKnowItWasYouFredo'

Now it’s the same as the (sane) Postgresql driver. But now you need a way to grab this complete DSN and dissect the username and password as necessary.

I wrote a handy class for this.

<?php
/**
 * PDO Connector class
 * Modifies the DSN to parse username and password individually.
 * Optionally, gets the DSN directly from php.ini.
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 * @version 0.2
 */

namespace Models;
 
class Cxn {
	
	/**
	 * @var object PDO connection.
	 */
	protected $db;
	
	
	public function __construct( $dbh ) {
		$this->connect( $dbh );
	}
	
	
	public function __destruct() {
		$this->db = null;
	}
	
	
	public function getDb() {
		
		if ( is_object( $this->db ) ) {
			return $this->db;
		} else {
			die('There was a database problem');
		}
	}

	private function connect( $dbh ) {
		if ( !empty( $this->db ) && is_object( $this->db ) ) {
			return;
		}
		
		try {
			$settings = array(
				\PDO::ATTR_TIMEOUT		=> "5",
				\PDO::ATTR_ERRMODE		=> 
						\PDO::ERRMODE_EXCEPTION,
				\PDO::ATTR_DEFAULT_FETCH_MODE	=> 
						\PDO::FETCH_ASSOC,
				\PDO::ATTR_PERSISTENT		=> true
			);
			
			$this->_dsn( $dbh, $username, $password );
			if ( !defined( 'DBTYPE' ) ) {
				$this->setDbType( $dbh );
			}
			
			if ( 'mysql' === DBTYPE || 'postgres' === DBTYPE) {
				/**
				 * Might be slightly slower, but more secure to 
				 * disable emulation
				 */
				$settings[\PDO::ATTR_EMULATE_PERPARES] = false;
			}
			
			$this->db = new \PDO( $dbh, $username, $password, 
						$settings );
			
			
		} catch ( \PDOException $e ) {
			exit( $e->getMessage() );
		}
	}
	
	/**
	 * Extract the username and password from the DSN and rebuild the 
	 * connection string
	 * 
	 * @param string $dsn Full connection string or DSN identifyer in php.ini
	 * @param string $username Optional username. If empty, it will extract from DSN
	 * @param string $password Also optional and will extract from DSN as above
	 */
	private function _dsn( &$dsn, &$username = '', &$password = '' ) {
		
		/**
		 * No host name with ':' would mean this is a DSN name in php.ini
		 */
		if ( false === strrpos( $dsn, ':' ) ) {
			
			/**
			 * We need get_cfg_var() here because ini_get doesn't work
			 * https://bugs.php.net/bug.php?id=54276
			 */
			$dsn = get_cfg_var( "php.dsn.$dsn" );
		}
		
		/**
		 * Some people use spaces to separate parameters in DSN strings 
		 * and this is NOT standard
		 */
		$d = explode( ';', $dsn );
		$m = count( $d );
		$s = '';
		
		for( $i = 0; $i < $m; $i++ ) {
			$n = explode( '=', $d[$i] );
			
			/**
			 * Empty parameter? Continue
			 */
			if ( count( $n ) <= 1 ) {
				$s .= implode( '', $n ) . ';';
				continue;
			}
			
			/**
			 * Username or password?
			 */
			switch( trim( $n[0] ) ) {
				case 'uid':
				case 'user':
				case 'username':
					$username = trim( $n[1] );
					break;
				
				case 'pwd':
				case 'pass':
				case 'password':
					$password = trim( $n[1] );
					break;
				
				/**
				 * Some other parameter? Leave as-is
				 */
				default:
					$s .= implode( '=', $n ) . ';';
			}
		}
		
		$dsn = rtrim( $s, ';' );
	}
	
	/**
	 * Sets the DBTYPE defined variable.
	 * Useful for database specific SQL.
	 * Expand as necessary.
	 */
	private function setDbType( $dsn ) {
		
		if ( 0 === strpos( $dsn, 'mysql' ) ) {
			
			define( 'DBTYPE', 'mysql' );
			
		} elseif ( 0 === strpos( $dsn, 'postgres' ) ) {
			
			define( 'DBTYPE', 'postgres' );
			
		} elseif ( 0 === strpos( $dsn, 'sqlite' ) ) {
			
			define( 'DBTYPE', 'sqlite' );
			
		} else {
			define( 'DBTYPE', 'other' );
		}
	}
}

Using this is pretty simple :

$cxn = new Cxn( 'mydsn' ); // Where php.dsn.mydsn is in php.ini
$db = $cxn->getDb();
$st = $db->prepare( 'SELECT * FROM whacks' );

$st->execute();
$whacks = $st->fetchAll();

Of course you want your family to be completely clean in a few years or Kay will try to leave you and take the kids with her. To prevent this and hide any whacks you do commit from Kay, we need some OOP and I’ve used this class on a previous project (not specifically to whack anyone) where I needed object/model separation. Hence the namespace will again be “Models”, same as above.

<?php
/* 
Example MySQL schema: 
CREATE TABLE whacks (
	id int(10) unsigned NOT NULL auto_increment,
	name varchar(100) NOT NULL,
	method varchar(100) NOT NULL,
	reason text NOT NULL,
	created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
	updated_at datetime NOT NULL,
	whacked_at timestamp NOT NULL,
	PRIMARY KEY (id)
);
*/

namespace Models;

class Whack extends base {
	
	/**
	 * @var string Keep your friends close, but your enemies closer 
	 */
	public $name	= '';
	
	
	/**
	 * @var string Stupid thugs. People behaving like that with guns.
	 */
	public $method	= '';
	
	
	
	/**
	 * @var string I don't feel I have to wipe everybody out, Tom. 
	 *		Just my enemies.
	 */
	public $reason = '';
	
	
	/**
	 * @var string He's has been dying from the same heart attack 
	 *		for the last twenty years.
	 */
	public $whacked_at= '';
	
	
	public function __construct( array $data = null ) {
		
		if ( empty( $data ) ) {
			return;
		}
		
		foreach ( $data as $field => $value ) {
			$this->$field = $value;
		}
	}
	
	
	public function save() {
		$row		= 0;
		$params = array(
			'name'		=> $this->name,
			'method'	=> $this->method
		);
		
		
		/**
		 * I don't want anything to happen to him while my mother's alive
		 */
		if ( !empty( $this->whacked_at ) ) {
			$this->whacked_at = parent::_myTime( $this->whacked_at );
			$params['whacked_at'] = $this->whacked_at;
		}
		
		/**
		 * Sal, Tom, the boss says he'll come in a separate car.
		 */
		if ( isset( $this->id ) ) {
			$params['id'] = $this->id;
			parent::edit( 'whacks', $params );
		} else {
			parent::put( 'whacks', $params );
		}
	}
	
	
	/**
	 * I want you to find out what he's got under his fingernails.
	 */
	public static function find( $filter = array() ) {
		$sql = "SELECT id, name, method, reason, created_at, whacked_at 
				FROM whacks";
		
		// Filtering cleanup
		$filter = parent::filterConfig( $filter );
		$params = array();
		
		// Get by id
		if ( $filter['id'] > 0 ) {
			$sql .= " WHERE id = :id";
			$param['id'] = $filter['id'];
		}
		
		// Get by name
		if ( isset( $filter['name'] ) ) {
			if ( false !== strrpos( $sql, 'WHERE' ) ) {
				$sql .= " OR";
			} else {
				$sql .= " WHERE";
			}
			$sql .= " name = :name";
			$param['name'] = $filter['name'];
		}
		
		// Get 10, 20 results etc...
		if ( isset( $filter['limit'] ) ) {
			$sql .= " LIMIT :limit";
			$param['limit'] = $filter['limit'];
		}
		
		// Pagination
		if ( isset( $filter['offset'] ) ) {
			$sql .= " OFFSET :offset";
			$param['offset'] = $filter['offset'];
		}
		
		// Many whacks. Family's been busy.
		if ( isset( $filter['limit'] ) && $filter['limit'] > 1 )
			return parent::find( $sql, $params );
		}
		
		// One whack (note the 'true' for a single object)
		return parent::find( $sql, $params, true );
	}
	
	
	/**
	 * Tessio? I always thought it would be Clemenza.
	 */
	public static function delete( $params = array() ) {
		parent::delete( 'whacks', $params );
	}
}

You’ll notice there’s a base class this inherits from. The base is what’s referred to in all those “parent::” methods for creating (put), editing and, of course, deleting because no self-respecting member of the family will be too stubborn, lest he wishes to get gunned down on the causeway.

Here’s the parent class (You don’t need to manually assign the DSN to this; just have it as a defined variable) :

<?php
/**
 * Base object modal for finding, editing, deleting objects by table
 * This class handles connecting to a database if necessary and so it 
 * depends on the 'Cxn' class and requires DBH to be defined.
 * 
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 * @uses Cxn
 */

namespace Models;

abstract class base {
	
	/**
	 * @var int Class object unique identifier (every class should have one)
	 */
	public $id;
	
	
	/**
	 * @var int Class object creation date. Should not modified.
	 */
	public $created_at;
	
	
	/**
	 * @var int Class object edited/saved date. Must be modified.
	 */
	public $updated_at;
	
	
	/**
	 * @var int Special status. Relevance will differ per object.
	 * @example A Post with status = -1 may be 'hidden' from view
	 */
	public $status;
	
	
	/**
	 * @var object PDO connection.
	 */
	protected static $db = null;
	
	
	/**
	 * @var int Total number of queries executed.
	 */
	public static $qcount = 0;
	
	
	/**
	 * Checks PDO connection or assigns to self::$db if it hasn't been set 
	 * and a new one has been passed.
	 * 
	 * @param object $db PDO connection
	 * @return bool True of the variable was set. False on failure.
	 */
	protected static function _db( $db = null ) {
		
		if ( is_object( self::$db ) ) {
			return true;
		} elseif ( is_object( $db ) ) {
			self::$db = $db;
			return true;
		}
		
		return false;
	}
	
	
	/**
	 * Clean up
	 */
	public function __destruct() {
		
		if ( is_object( self::$db ) ) {
			self::$db = null;
		}
	}
	
	
	/**
	 * Setup local Cxn instance with PDO 
	 * Note: This depends on the Cxn class and also 
	 * DBH (your connection string) being set
	 */
	public static function init() {
		
		if ( !self::_db() ) {
			$db = new Cxn( DBH );
			self::_db( $db->getDb() );
		}
	}
	
	
	/**
	 * Find objects using the given sql and parameters
	 * 
	 * @param string $sql Database query
	 * @param array $params Selector parameters in 'column' => 'value' format
	 * @return object Of the same type as the class calling this or null on failure
	 * @return mixed Single object or array of the same type as the class calling 
	 * 			this or null on failure
	 */
	protected static function find( $sql, $params, $single = false ) {
		
		self::init();
		
		$class	= get_called_class();
		$stmt	= self::$db->prepare( $sql );
		
		if ( $stmt->execute( $params ) ) {
			self::$qcount++;
			if ( $single ) {
				return $stmt->fetchObject( $class );
			} else {
				return $stmt->fetchAll( 
					\PDO::FETCH_CLASS, $class );
			}
		}
		return null;
	}
	
	
	/**
	 * Insert a single row into a table
	 * 
	 * @param string $table Database table name
	 * @param array $params Insert values in 'column' => 'value' format
	 * @return int The ID of the newly inserted row or 0 on failure
	 */
	protected static function put( $table, $params ) {
		
		self::init();
		
		$sql	= self::_insertStatement( $table, $params );
		$stmt	= self::$db->prepare( $sql );
		
		if ( $stmt->execute( $params ) ) {
			self::$qcount++;
			if ( 'postgres' === DBTYPE ) {
				return self::$db->lastInsertId( 'id' );
			}
			return self::$db->lastInsertId();
		}
		
		return 0;
	}
	
	
	/**
	 * Update records in a single table
	 * 
	 * @param string $table Database table name
	 * @param array $params Column parameters (id required)
	 * @return int Number of rows affected
	 */
	protected static function edit( $table, $params ) {
		
		if ( !isset( $params['id'] ) ) {
			return 0;
		}
		
		$id	= $params['id'];
		unset( $params['id'] );
				
		$sql = self::_updateStatement(
			$table, $params, "$table.id = :id"
		);
		
		self::init();
		$params['id']	= $id;
		$stmt		= self::$db->prepare( $sql );
	
		if ( $stmt->execute( $params ) ) {
			self::$qcount++;
			return $stmt->rowCount();
		}
		return 0;
	}
	
	
	/**
	 * Delete from a single table based on parameters
	 * 
	 * @param string $table Table name (only one) to delete from
	 * @param array $params Delete selectors
	 * 
	 * @example Deleting a post with ID = 223
	 * 		base::delete( 'posts', array( 'id' = 223 ) );
	 * 
	 * @return int Number of rows affected/deleted
	 */
	protected static function delete( $table, $params ) {
		
		self::init();
		
		$sql	= self::_deleteStatement( $table, $params );
		$stmt	= self::$db->prepare( $sql );
		
		if ( $stmt->execute( $params ) ) {
			self::$qcount++;
			return $stmt->rowCount();
		}
		return 0;
	}
	
	
	/**
	 * Add parameters to conditional IN/NOT IN ( x,y,z ) query
	 */
	protected function _addParams( $t, &$values, &$params = array(), &$in = '' ) {
		
		$vc = count( $values );
		for ( $i = 0; $i < $vc; $i++ ) {
			$in			= $in . ":val_{$i},";
			$params["val_{$i}"]	= array( $values[$i], $t );
		}
		$in = rtrim( $in, ',' );
	}
	
	
	/**
	 * Prepares parameters for SELECT, UPDATE or INSERT SQL statements.
	 * 
	 * E.G. For INSERT
	 * :name, :email, :password etc...
	 * 
	 * For UPDATE or DELETE
	 * name = :name, email = :email, password = :password etc...
	 */
	protected static function _setParams( 
		$fields = array(), 
		$mode = 'select', 
		$table = '' 
	) {
		$columns = is_array( $fields ) ? 
				array_keys( $fields ) : 
				array_map( 'trim', explode( ',', $fields ) );
		
		switch( $mode ) {
			case 'select':
				return implode( ', ', $columns );
				
			case 'insert':
				return ':' . implode( ', :', $columns );
			
			case 'update':
			case 'delete':
				$v = array_map( 
					function( $field ) use ( $table ) {
						
						if ( empty( $field ) ) { 
							return '';
						}
						return "$field = :$field";
					}, $columns );
				return implode( ', ', $v );
		}
	}
	
	
	/**
	 * Prepares SQL INSERT query with parameters matching field names
	 * 
	 * @param string $table Table name
	 * @param string|array $fields A comma delimited string of fields or 
	 * 		an array
	 * 
	 * @example field => value pairs (the 'field' key will be extracted as 
	 * 		the parameter)
	 * 		_insertStatement(
	 * 			'posts', 
	 * 			array( 
	 * 				'title' => 'Test title', 
	 * 				'author' => 'Guest'
	 * 			) 
	 * 		);
	 * 
	 * @return string;
	 */
	protected static function _insertStatement( $table, $fields ) {
		
		$cols = self::_setParams( $fields, 'select', $table );
		$vals = self::_setParams( $fields, 'insert', $table );
		return	"INSERT INTO $table ( $cols ) VALUES ( $vals );";
	}
	
	
	/**
	 * Prepares sql UPDATE query with parameters matching field names
	 * 
	 * @param string $table Table name
	 * @param string|array $fields A single field or comma delimited string 
	 * 		of fields or an array
	 * 
	 * @example field => value pairs (the 'field' key will be extracted as 
	 * 		the parameter)
	 * 		_updateStatement(
	 * 			'posts', 
	 * 			array( 
	 * 				'title' => 'Changed title', 
	 * 				'author' => 'Edited Guest' 
	 * 			),
	 * 			array( 'id' => 223 )
	 * 		);
	 * @return string;
	 */
	protected static function _updateStatement( 
		$table, 
		$fields = null, 
		$cond = ''
	) {
		$params = self::_setParams( $fields, 'update', $table );
		$sql	= "UPDATE $table SET $params";
		
		if ( !empty( $cond ) ) {
			$sql .= " WHERE $cond";
		}
		
		return $sql . ';';
	}
	
	
	/**
	 * Prepares sql DELETE query with parameters matching field names
	 * 
	 * @param string $table Table name
	 * @param string|array $fields A comma delimited string of fields or an 
	 * 		array of field => value pairs (the 'field' key will be 
	 * 		extracted as the parameter)
	 * @return string;
	 */
	protected static function _deleteStatement( 
		$table, 
		$fields = null, 
		$limit = null
	) {
		
		$params	= self::_setParams( $fields, 'delete', $table );
		$sql	= "DELETE FROM $table WHERE ( $params )";
		
		
		/**
	 	 * Limit must consist of an integer and not start with a '0'
	 	 */
		if ( null !== $limit && preg_match('/^([1-9][0-9]?+){1,2}$/', 
			$limit ) ) {
			$sql .= " LIMIT $limit";
		}
		
		return $sql . ';';
	}
	
	
	/**
	 * Pagination offset calculator
	 * Hard limit of 300 set for page since we rarely browse that many 
	 * casually. That's what searching is for ( also reduces abuse )
	 * 
	 * @param int $page Currently requested index (starting from 1)
	 * @param int $limit Maximum number of records per page
	 * @return int Offset
	 */
	protected static function _offset( $page, $limit ) {
		
		$page	= ( isset( $page ) )? ( int ) $page - 1 : 0;
		$limit	= ( isset( $limit ) )? ( int ) $limit : 0;
		
		if ( empty( $page ) || $page < 0 || 
			empty( $limit ) || $limit < 0
		) {
			return 0; 
		}
		return ( $page > 300 ) ? 0 : $page * $limit;
	}
	
	
	/**
	 * Convert a unix timestamp a datetime-friendly timestamp
	 * 
	 * @param int $time Unix timestamp
	 * @return string 'Year-month-date Hour:minute:second' format
	 */
	protected static function _myTime( $time ) {
		return gmdate( 'Y-m-d H:i:s', $time );
	}
	
	
	/**
	 * Sets filter configuration ( pagination, limit, id etc... )
	 */
	public static function filterConfig( &$filter = array() ) {
		
		$filter['id']		= isset( $filter['id'] ) ? 
						$filter['id'] : 0;
						
		$filter['limit']	= isset( $filter['limit'] ) ? 
						$filter['limit'] : 1;
						
		$filter['page']		= isset( $filter['page'] ) ? 
						$filter['page'] : 1;
						
		$filter['search']	= isset( $filter['search'] ) ? 
						$filter['search'] : '';
		
		$offset			= self::_offset( 
						$filter['page'] , 
						$filter['limit']
					);
		if ( $offset > 0 ) {
			$filter['offset'] = $offset;
		}
	}
}

This class was built to help me with a bunch of other classes as well, so I added a quick filterConfig there for a lot of the default parameters I’d use, including for offset calculation for pagination.

Putting it all together, you might use something like this to add a new entry :

 // Make sure, your full DSN is set in php.ini
// under pdo.dsn.mywhacks = 'sqlite:data/hitlist.sqlite' or something
define( 'DBH', 'mywhacks' );

$whack = new \Models\Whack();
$whack->name = "Carlo";
$whack->method = "garrote in the car";
$whack->reason = "Fingered Sonny for the Barzini people.";

// Baptism is already over so why wait? 
$whack->save();

To change something :

$fredo = \Models\Whack::find( array( 'name' => 'Fredo' ) );
$fredo->method = "shot while fishing";
$fredo->reason = "Almost got Michael killed. Took sides against the family.";

$fredo->whacked_at = time(); // Mamma's dead now
$fredo->save();

To delete :

\Models\Whack::delete( array( 'name' => 'Clemenza' ) );

And that’s about it.

Very simple encryption class for PHP

I’ve been getting emails asking about the encryption class I put up a couple of months ago. There were many requests asking for a more simplified version just pertaining to encryption. That’s a good idea actually, and here are three simple functions to take care of that.

Update Nov.17

Based on some suggestions, I’ve made an improved version (especially getting rid of the @ hack for error suppression). Original class is below this.

<?php

/**
 * Encryption/Decryption related functions.
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 */
class uCrypt {
	
	public static function IV( $size, $ssl = false ) {
		
		if ( $ssl && 
			function_exists( 'openssl_random_pseudo_bytes' ) ) {
			
			return openssl_random_pseudo_bytes( $size, $ssl );
		}
		
		return mcrypt_create_iv( $size, MCRYPT_DEV_RANDOM );
	}
	
	
	public static function encrypt( $data, $key ) {
		if ( null === $data ) { return null ; }
		return self::encryption( $data, $key, 'encrypt' );
	}
	
	public static function decrypt( $data, $key ) {
		if ( null === $data ) { return null ; }
		return self::encryption( $data, $key, 'decrypt' );
	}
	
	private static function encryption( $str, $key, $mode = 'encrypt' ) {
		$td	= mcrypt_module_open( MCRYPT_RIJNDAEL_256, '', 
				MCRYPT_MODE_CBC, '');
		
		$ivs	= mcrypt_enc_get_iv_size( $td );
		$bsize	= mcrypt_enc_get_block_size( $td );
		$ksize	= mcrypt_enc_get_key_size( $td );
		$key	= substr( hash( 'sha256', $key ), 0, $ksize );
		
		if ( 'encrypt' === $mode ) {
			$iv	= self::IV( $ivs );
		} else {
			$str	= base64_decode( $str );
			$iv	= mb_substr( $str, 0, $ivs );
			$str	= mb_substr( $str, mb_strlen( $iv ) );
		}
		
		mcrypt_generic_init( $td, $key, $iv );
		
		if ( 'encrypt' === $mode ) {
			self::_pad( $str, $bsize );
			$str = mcrypt_generic( $td, $str );
			$out = base64_encode( $iv . $str );
		} else {
			$str = mdecrypt_generic( $td, $str );
			self::_unpad( $str, $bsize );
			$out = $str;
		}
		
		mcrypt_generic_deinit( $td );
		mcrypt_module_close( $td );
		
		return $out;
	}
	
	private static function _pad( &$str, $bsize ) {
		$pad = $bsize - ( mb_strlen( $str ) % $bsize );
		$str .= str_repeat( chr( $pad ), $pad );
	}
	
	private static function _unpad( &$str, $bsize ) {
		$len = mb_strlen( $str );
		$pad = ord( $str[$len - 1] );

		if ($pad && $pad < $bsize) {
			$pm = preg_match( '/' . chr( $pad ) . 
				'{' . $pad . '}$/', $str );
 
			if ( $pm ) {
				$str = mb_substr( $str, 0, $len - $pad );
			}
		}
	}
}

Original

<?php

/**
 * Encryption/Decryption related functions.
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 */
class uCrypt {
	
	public static function IV( $size, $ssl = false ) {
		
		if ( $ssl && 
			function_exists( 'openssl_random_pseudo_bytes' ) ) {
			
			return openssl_random_pseudo_bytes( $size, $ssl );
		}
		
		return mcrypt_create_iv( $size, MCRYPT_DEV_URANDOM );
	}
	
	public static function encrypt( $data, $key ) {
		$key	= hash( 'sha256', $key, true );
		$ivs	= mcrypt_get_iv_size( MCRYPT_RIJNDAEL_256, 
						MCRYPT_MODE_CBC );
		$iv	= self::IV( $ivs );
		
		return @base64_encode( $iv .  mcrypt_encrypt( 
					MCRYPT_RIJNDAEL_256, $key, $data, 
					MCRYPT_MODE_CBC, $iv 
			) );
	}
	
	public static function decrypt( $data, $key ) {
		$key	= hash( 'sha256', $key, true );
		$ivs	= mcrypt_get_iv_size( MCRYPT_RIJNDAEL_256, 
						MCRYPT_MODE_CBC );
		
		$data	= base64_decode( $data );
		
		$iv	= mb_substr( $data, 0, $ivs );
		$data	= mb_substr( $data, $ivs );
		
		return @mcrypt_decrypt( MCRYPT_RIJNDAEL_256, $key, $data, 
				MCRYPT_MODE_CBC, $iv );
	}
}

Usage :

 $rawstring = 'This is a test string';
$password = 'password';
$encrypted = uCrypt::encrypt( $rawstring, $password );
$decrypted = uCrypt::decrypt( $encrypted, $password );

Firewall.php

Since yesterday, I’ve been working on my forum script again (oh, you mean the one you’ve been working on since 2009?! Er… yes). The good news is that I’m finally getting somewhere. Bad news, I had to scrap everything I wrote so far since that turned out not to be the direction I wanted to go. The one sticking point was protecting the forum from all sorts of unsavory things the internet has an abundance of.

There all sorts of plugins and apps available to protect your software from spammers and things, but most of them are hardly drop-in caliber. I’ve looked at Akismet (which isn’t as transparent as I had hoped), Fail2ban (which was too involved) and Bad behavior. All in all, BB turned out to be the thing closest to what I was looking for, but it didn’t quite… match.

The premise behind Bad Behavior is that it’s a module/plugin or what-have-you, that sits listening to any requests to your site and piles through a blacklist of bad bots in the form of User Agent fragments and rubbish IP addresses. It optionally downloads blacklists and does host matching, but this aspect seems to be broken due to a PHP bug (surprise!). There’s also the problem of layout. BB seems a bit all-over-the-place as a piece of software. After scanning the code for a while, I realized it wasn’t really what I wanted or how I’d like to layout my forum.

I needed something that can be deeply integrated into the forum so that I’ll have the option of pushing requests to a log of some sort, like BB does, but I also wanted to block users based on user name in other portions of the site. This required that I hack into BB to work and, considering the differing approaches, that wasn’t going to work. There should be two sections to this: A main firewall script and a model. The model is a “firewall entry object” that I can save to a database. Optionally, I also wanted it to have username and other information in the future so I haven’t finished it yet.

So last night, I sat down and sketched out a few things into a class. This is a non functional draft for what might be a firewall script I can reuse elsewhere. You can think of this script as me thinking out loud.

There are many different ways to do this so I’ll be scrubbing this in the future. But for now, here’s the overview

Update: Well that was quick. This went from non-functional draft to semi-functional draft. I’ve also added a sketch of a FireEntry model which can show what would be saved if this was connected to a database. Also moved all the ‘lists’ to separate config files (‘Config/’ folder).

I haven’t had a chance to do a proper update yet since I’ve been extremely busy over the past month. As soon as few days are done, I’ll get back to more important things. I.E. Cabins!

<?php
/**
 * Bot and bad client blocking script (NON FUNCTIONAL DRAFT) 
 * This should NOT be considered foolproof as it uses a blacklist approach.
 * Parts of this code was inspired by the Bad Behavior plugin. No code was shared.
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 * @version 0.1
 */

class Firewall extends \Singleton {
	
	/**
	 * Message to return if a user is blocked
	 * Right now, it's identical to the router 'not found' message to avoid
	 * returning too much information.
	 */
	const DIE_MESSAGE = 'Couldn\'t find that';
	
	private static $botsIni = 'Config/verifiedbots.ini';
	
	private static $uasIni = 'Config/baduas.ini';
	
	private static $urisIni = 'Config/baduris.ini';
	
	
	/**
	 * @var object Firewall model object
	 */
	private $fire	= null;
	
	public $userhash = '';
	
	
	/**
	 * Forbidden request methods
	 */
	public static $rms = array(
		'trace', 'track', 'delete'
	);
	
	
	public static $searchEngines = array(
		'Google',
		'Bing',
		'Live',
		'MS Search',
		'MSN',
		'Inktomi',
		'Slurp',
		'SearchMonkey',
		'Yahoo',
		'Baidu',
		'Yandex'
	);
	
	/**
	 * Begin working as soon as the module is loaded.
	 * Starts from least expensive checks (IP) to most expensive (Headers)
	 */
	public function __construct() {
		$this->init();
		
		if ( empty( $this->fire->ip ) ) {
			$this->fire->ip		= $_SERVER['REMOTE_ADDR'];
			$this->fire->response	= 'Failed: Martian IP';
			$this->killReq( self::DIE_MESSAGE );
		}
		
		$this->checkRequest();
		$this->checkURI();
		$this->checkHeaders();
		$this->verifiedBotScan();
		
	}
	
	private function init() {
		$this->fire		= new \Models\FireEntry();
		$this->fire->method	= 
			strtolower( $_SERVER['REQUEST_METHOD'] );
		
		$this->fire->uri	= $this->getURI();
		$this->fire->headers	= $this->headers();
		
		$this->fire->ua		= $_SERVER['HTTP_USER_AGENT'];
		$this->fire->protocol	= $_SERVER['SERVER_PROTOCOL'];
		$this->fire->reqtime	= isset( $_SERVER['REQUEST_TIME'] ) ?
						$_SERVER['REQUEST_TIME'] : 
						time();

		$this->fire->ip		= $this->getIP();
	}
	
	private function checkRequest() {
		if ( in_array( $this->fire->method, self::$rms ) ) {
			$this->fire->response = 'Failed: Request check';
			$this->killReq( self::DIE_MESSAGE );
		}
	}
	
	private function checkURI() {
		$uris =  parse_ini_file( self::$urisIni );
		
		foreach( $uris['u'] as $uri ) {
			if ( false === stripos( 
				$this->fire->uri, $uri ) ) {
				continue;
			} else {
				$this->fire->response = 'Failed: URI check';
				$this->killReq( self::DIE_MESSAGE );
				break;
			}
		}
	}
	
	private function checkHeaders() {
		$headers = $this->fire->headers;
		
		/**
		 * Accept missing. Not acceptable.
		 */
		if ( $this->missing( $headers, 'Accept' ) ) {
			$this->fire->response = 'Failed: Accept header missing';
			$this->killReq( self::DIE_MESSAGE );
		}
		
		/**
		 * No UA or it's too short
		 */
		if ( $this->missing( $headers, 'User-Agent', 10 ) ) {
			$this->fire->response = 'Failed: User agent too small';
			$this->killReq( self::DIE_MESSAGE );
		}
		
		/**
		 * Shouldn't see MSIE *and* Windows ME/XP/2000 in the same 
		 * UA string
		 */
		if ( 
			$this->has( $headers, 'User-Agent', '; MSIE' ) && (
			$this->has( $headers, 'User-Agent', 'Windows 2000' ) || 
			$this->has( $headers, 'User-Agent', 'Windows ME' ) || 
			$this->has( 
				$headers, 'User-Agent', 'Windows XP' ) 
			)
		) {
			$this->fire->response = 'Failed: Fake MSIE bot';
			$this->killReq( self::DIE_MESSAGE );
		}
		
		/**
		 * Check against blacklist of User agents.
		 * This is the most expensive operation and should be 
		 * reserved for last.
		 */
		$uas =  parse_ini_file( self::$uasIni );
		if ( $this->has( $headers, 'User-Agent', $uas['u'] ) ) {
			$this->fire->response = 'Failed: Bad User Agent';
			$this->killReq( self::DIE_MESSAGE );
		}
	}
	
	/**
	 * It's opposites day! This function returns *true* if a particular 
	 * header value is completely missing, contains an empty string or
	 * is below the minimum length
	 */
	private function missing( &$h, $k, $min = 0 ) {
		if ( array_key_exists( $k, $h ) ) {
			if ( empty( $h[$k] ) ) {
				return true;
			}
			if ( $min > 0 && mb_strlen( $h[$k] ) < $min ) {
				return true;
			}
			return false;
		}
		
		return true;
	}
	
	/**
	 * Helper to see if a key exists in an array, has a component
	 * to search in the value or matches to an optional regular expression
	 */
	private function has( &$h, $k, $v = null, $regex = false ) {
		$has = array_key_exists( $k, $h );
		
		/**
		 * Only checking for key existence
		 */
		if ( null === $v || !$has ) {
			return $has;
		}
		
		if ( is_array( $v ) ) {
			foreach( $v as $name ) {
				if ( false === stripos( $name, $h[$k] ) ) {
					continue;
				} else {
					return true;
				}
			}
			
			/**
			 * Made it this far. The key wasn't in the array
			 */
			 return false;
		}
		
		/**
		 * The key value should be a regular expression match
		 */
		if ( $regex ) {
			return preg_match('/\b'. $v .'\b/i', $h[$k] );
		}
		
		if ( false === stripos( $h[$k], $v ) ) {
			return false;
		}
		
		return $has;
	}
	
	private function uaInSearchBot() {
		foreach( self::$searchEngines as $bot ) {
			if ( false === strpos( $this->fire->ua, $bot ) ) {
				continue;
			} else {
				return $bot;
			}
		}
		return null;
	}
	
	/**
	 * Check bot UA against IPs that are known for it
	 */
	private function verifiedBotScan() {
		if ( !$this->uaInSearchBot() ) {
			return;
		}
		$out	= null;
		$ua	= $this->fire->ua;
		
		$var =  parse_ini_file( self::$botsIni, true );
		$bots	= array_keys( $var );
		
		foreach( $bots as $b ) {
			$bua = explode( '_', $b );
			foreach( $bua as $a ) {
				
				/**
				 * User agent didn't match any bot aliases
				 */
				if ( false === strpos( $ua, $a ) ) {
					continue;
				} else {
					
					/**
					 * User agent claims to be a known bot
					 */
					$out = $this->rangeScan( 
						$var[$b]['i']
					);
					break; // Bot checking done
				}
			}
			
			/**
			 * We have a result (anything other than null)
			 */
			if ( null !== $out ) { break; }
		}
		
		if ( null === $out ) {
			$this->fire->response = 'Passed';
			return;
		}
		
		/**
		 * Didn't pass bot scan
		 */
		$this->fire->response = 'Failed: Spoofed popular bot';
		$this->killReq( self::DIE_MESSAGE );
	}
	
	/**
	 * Checks a given IP range in CIDR format
	 */
	private function rangeScan( $ips = array() ) {
		$out = false;
		foreach( $ips as $ip ) {
			if ( $out = $this->cidr( $ip, $this->fire->ip ) ) {
				/**
				 * IP in the given list  Exit loop
				 */
				break;
			}
		}
		return $out;
	}
	
	
	/**
	 * This may fail... hard!
	 * 
	 * @returns Gets (or rather extrapolates) IPv4/6 address from 
	 * 		relevant headers
	 */
	private function getIP() {
		
		$vars = array(
			'HTTP_CLIENT_IP', 
			'HTTP_X_FORWARDED_FOR', 
			'HTTP_X_FORWARDED', 
			'HTTP_X_CLUSTER_CLIENT_IP', 
			'HTTP_FORWARDED_FOR', 
			'HTTP_FORWARDED', 
			'REMOTE_ADDR'
		);
		
		foreach( $vars as $v ) {
			
			if ( true === array_key_exists( $v, $_SERVER ) )  {
				
				$ip = explode( ',', $_SERVER[$v] );
				
				foreach( $ip as $test ) {
					$test = trim( $test );
					if ( $this->checkIP( $test ) ) {
						return $test;
					}
				}
			}
		}
		
		/**
		 * If we made it this far, the IP was invalid
		 */
		return '';
	}
	
	private function formatIP4( $ip, $pad = '0' ) {
		$ip	= str_replace( '*', $pad, $ip );
		$bits	= null;
		$p	= strpos( $ip, '/' );
		if ( false !== $p ) { 
			$bits	= substr( $ip, $p, strlen( $ip ) - 1 );
			$ip	= substr( $ip, 0, $p );
		}
		
		$sr	= explode( '.', $ip );
		while( count( $sr ) < 4) {
			$sr[] = $pad;
		}
		$ip	= implode('.', $sr );
		
		return $ip . $bits;
	}
	
	private function matchIP4StartToEnd( $start, &$end ) {
		if ( empty( $end ) ) {
			$end	= array();
			$d	= explode( '.', $start );
			$c	= count( $d );
			
			for( $i = 0; $i < $c; $i++ ) {
				if ( empty( $d[$i] ) ) {
					$end[$i] = '255';
				} else {
					$end[$i] = $d[i];
				}
			}
		} else {
			$end = str_replace( '*', '255', $end );
		}
		
		$end = $this->formatIP4( $end, '255' );
	}
	
	/**
	 * Checks if an IP is between an IPv4 range
	 */
	public function ip4Range( $start, $end, $ip ) {
		
		$start	= $this->formatIP4( $start, '0' );
		
		/**
		 * Bits E.G.'/16' was present. Send to CIDR validation
		 */
		if ( false !== strpos( $start, '/' ) ) {
			return $this->cidr( $start, $ip );
		}
		
		$this->matchIP4StartToEnd( $start, $end );
		
		$start	= ip2long( $start );
		$ip	= ip2long( $ip );
		$end	= ip2long( $end );
		
		if ( $start <= $ip && $end >= $ip ) {
			return true;
		}
		
		return false;
	}
	
	
	/**
	 * TODO: Create IPv6 matching
	 */
	private function ip6Range( $start, $end, $ip ) {
		return false;
	}
	
	
	/**
	 * CIDR format IP matching
	 */
	private function cidr( $r, $ip ) {
		list( $sub, $bits ) = explode( '/', $r );
		
		$ip	= ip2long( $ip );
		$sub	= ip2long( $sub );
		$mask	= ( -1 << ( 32 - $bits ) );
		
		$sub	&= $mask; // Fix inconsistencies
		
		return ( $ip & $mask ) == $sub;
	}
	 
	 /**
	  * Converts an IP4 address to IP6.
	  * Convenient to store as a single format
	  */
	private function ip4Toip6( $ip ) {
		if ( filter_var( $ip, 
			FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			return cleanIPv6( $ip ); // Already IPv6
		}
		
		$ia = array_pad( explode( '.', $ip ), 4, 0 );
		$b1 = base_convert( ($ia[0] * 256 ) + $ia[1], 10, 16 );
		$b2 = base_convert( ($ia[2] * 256 ) + $ia[3], 10, 16 );
		
		return "0000:0000:ffff:$b1:$b2";
	}
	 
	 /**
	  * Expand IPv6 to proper storage
	  * 
	  * @link http://php.net/manual/en/function.inet-pton.php
	  */
	private function cleanIPv6( $ip ) {
		$h	= unpack( "H*hex", inet_pton( $ip ) );
		$ip	= preg_replace( '/([A-f0-9]{4})/', "$1:", $hex['hex'] );
		
		return substr( $ip , 0, -1 );
	}
	
	
	/**
	 * Checks for martians E.G. 10.0.0.0/8
	 * These should really be blocked at the router/switch
	 */
	private function checkIP( $ip ) {
		return filter_var( $ip, FILTER_VALIDATE_IP, 
			FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_NO_PRIV_RANGE );
	}
	
	private function killReq( $msg ) {
		$this->logReq();
		//echo $this->fire->response;
	}
	
	private function logReq() {
		$this->fire->save();
	}
	
	private function headers() {
		if ( function_exists( 'getallheaders' ) ) {
			return getallheaders();
		}
		
		$headers = array();
		
		foreach( $_SERVER as $k => $v ) {
			
			if ( 0 === strpos( $k, 'HTTP_' ) ) {
				
				/**
				 * Remove HTTP_ and turn turn '_' to spaces
				 */
				$hd	= substr( $k, 5 );
				$hd	= str_replace( '_', ' ', $hd );
				
				/**
				 * E.G. ACCEPT LANGUAGE to Accept-Language
				 */
				$uw	= ucwords( strtolower( $hd ) );
				$uw	= str_replace( ' ', '-', $uw );
				
				$headers[ $uw ] = $value; 
			}
		}
		
		return $headers;
	}
	
	private function getURI() {
		if ( isset( $_SERVER['REQUEST_URI'] ) ) {
			return $_SERVER['REQUEST_URI'];
		}
		
		$_SERVER['REQUEST_URI'] = substr( $_SERVER['PHP_SELF'], 1 );
		
		if ( isset($_SERVER['QUERY_STRING'] ) ) {
			$_SERVER['REQUEST_URI'] .= '?' . 
				$_SERVER['QUERY_STRING'];
		}
	}
}

The bad user agents ini file

; Partial (I.E. never ending) list of User Agents and partial matches
; Courtesy of the following:
; 
; http://bad-behavior.ioerror.us/
; https://github.com/bluedragonz/bad-bot-blocker/blob/master/.htaccess
; http://forum.joomla.org/viewtopic.php?t=494485
;
;Last count at 278 fragments checked


u[] = '**'
u[] = '\\\\'
u[] = '.NET CLR 1)'
u[] = '.NET CLR1'
u[] = '\r'
u[] = '<sc'
u[] = '; Widows'
u[] = '360Spider'
u[] = '8484 Boston Project'
u[] = 'a href='
u[] = 'Aboundex'
u[] = 'Acunetix'
u[] = 'adwords'
u[] = 'Alexibot'
u[] = 'AIBOT'
u[] = 'asterias'
u[] = 'attach'
u[] = 'autoemailspider'
u[] = 'BackDoorBot'
u[] = 'BackWeb'
u[] = 'Bad Behavior Test'
u[] = 'Bandit'
u[] = 'BatchFTP'
u[] = 'Bigfoot'
u[] = 'Black.Hole'
u[] = 'BlackHole'
u[] = 'BlackWidow'
u[] = 'blogsearchbot-martin'
u[] = 'BlowFish'
u[] = 'Bot mailto:craftbot@yahoo.com'
u[] = 'BotALot'
u[] = 'BrowserEmulator'
u[] = 'Buddy'
u[] = 'BuiltBotTough'
u[] = 'Bullseye'
u[] = 'BunnySlippers'
u[] = 'Cegbfeieh'
u[] = 'CheeseBot'
u[] = 'CherryPicker'
u[] = 'ChinaClaw'
u[] = 'Clearswift'
u[] = 'clipping'
u[] = 'Cogentbot'
u[] = 'Collector'
u[] = 'compatible ; MSIE'
u[] = 'compatible-'
u[] = 'CoralWebPrx'
u[] = 'core-project'
u[] = 'Copier'
u[] = 'CopyRightCheck'
u[] = 'cosmos'
u[] = 'Crescent'
u[] = 'Custo'
u[] = 'Diamond'
u[] = 'Digger'
u[] = 'DIIbot'
u[] = 'DISCo'
u[] = 'DittoSpyder'
u[] = 'discovery'
u[] = 'dragonfly'
u[] = 'Drip'
u[] = 'Download'
u[] = 'eCatch'
u[] = 'Easy'
u[] = 'Email'
u[] = 'Emulator'
u[] = 'Enchanc'
u[] = 'EroCrawler'
u[] = 'Exabot'
u[] = 'Express WebPictures'
u[] = 'Extrac'			; Extractors
u[] = 'EyeNetIE'
u[] = 'Fail'
u[] = 'Fatal'
u[] = 'FlashGet'
u[] = 'FHscan'
u[] = 'Firebird'		; Too old to be viable
u[] = 'flunky'
u[] = 'Foobot'
u[] = 'Forum Poster'
u[] = 'FrontPage'
u[] = 'Gecko/2525'
u[] = 'GetRight'
u[] = 'GetWeb!'
u[] = 'Go!Zilla'
u[] = 'Go-Ahead-Got-It'
u[] = 'gotit'
u[] = 'Grab'
u[] = 'Grafula'
u[] = 'grub'
u[] = 'hanzoweb'
u[] = 'Harvest'
u[] = 'Havij'
u[] = 'hloader'
u[] = 'HMView'
u[] = 'HttpProxy'
u[] = 'HTTrack'
u[] = 'humanlinks'
u[] = 'IlseBot'
u[] = 'Indy Library'
u[] = 'InfoNaviRobot'
u[] = 'InfoTekies'
u[] = 'Intelliseek'
u[] = 'InterGET'
u[] = 'Internet Explorer'	; *Not* IE. UA is likely a bot
u[] = 'Intraformant'
u[] = 'ISC Systems iRc'
u[] = 'Iria'
u[] = 'Java'
u[] = 'Jakarta'
u[] = 'Jenny'
u[] = 'JetCar'
u[] = 'JOC'
u[] = 'JustView'
u[] = 'Jyxobot'
u[] = 'Kenjin'
u[] = 'Keyword'
u[] = 'larbin'
u[] = 'Leacher'
u[] = 'LexiBot'
u[] = 'LeechFTP'
u[] = 'libwww-perl'
u[] = 'lftp'
u[] = 'libWeb/clsHTTP'
u[] = 'likse'
u[] = 'LinkScan'
u[] = 'LNSpiderguy'
u[] = 'LinkWalker'
u[] = 'Lobster'
u[] = 'Locator'
u[] = 'LWP'
u[] = 'Magnet'
u[] = 'Mag-Net'
u[] = 'MarkWatch'
u[] = 'Mata.Hari'		; Well, now I've seen everything
u[] = 'Memo'
u[] = 'Microsoft URL'
u[] = 'Microsoft.URL'
u[] = 'MIDown'
u[] = 'Ming Mong'
u[] = 'Missigua'
u[] = 'Mister'
u[] = 'MJ12bot/v1.0.8'
u[] = 'moget'
u[] = 'Morfeus'
u[] = 'Movable Type'		; Not the blog engine
u[] = 'Mozilla.*NEWT'
u[] = 'Mozilla/0'
u[] = 'Mozilla/1'
u[] = 'Mozilla/2'
u[] = 'Mozilla/3'
u[] = 'Mozilla/4.0('
u[] = 'Mozilla/4.0+(compatible;+'
u[] = 'Mozilla/4.0 (Hydra)'
u[] = 'MSIE 7.0;  Windows NT 5.2'
u[] = 'Murzillo'
u[] = 'MVAClient'
u[] = 'Navroad'
u[] = 'NearSite'
u[] = 'NetAnts'
u[] = 'NetMechanic'
u[] = 'NetSpider'
u[] = 'Net Vampire'
u[] = 'NetZIP'
u[] = 'Nessus'
u[] = 'NG'
u[] = 'NICErsPRO'
u[] = 'Nikto'
u[] = 'Ninja'
u[] = 'Nimble'
u[] = 'NPbot'
u[] = 'Nomad'
u[] = 'NutchCVS'
u[] = 'Nutscrape'
u[] = 'NextGen'
u[] = 'Octopus'
u[] = 'OmniExplorer'
u[] = 'Opera/9.64('
u[] = 'Offline'		 ; 'Offline' anything is a scraper
u[] = 'Openfind'
u[] = 'OutfoxBot'
u[] = 'Papa Foto'
u[] = 'pavuk'
u[] = 'pcBrowser'
u[] = 'Perman Surfer'
u[] = 'PHP'
u[] = 'Pockey'
u[] = 'PMAFind'
u[] = 'POE'
u[] = 'ProPowerBot'
u[] = 'psbot'
u[] = 'psycheclone'
u[] = 'Pump'
u[] = 'PussyCat'
u[] = 'PycURL'
u[] = 'Python-urllib'
u[] = 'QueryN'
u[] = 'RealDownload'
u[] = 'Reaper'
u[] = 'Recorder'
u[] = 'ReGet'
u[] = 'RepoMonkey'
u[] = 'RMA'
u[] = 'revolt'
u[] = 'Siphon'
u[] = 'SiteSnagger'
u[] = 'SlySearch'
u[] = 'SmartDownload'
u[] = 'Snake'
u[] = 'Snapbot'
u[] = 'sogou'
u[] = 'SpaceBison'
u[] = 'Spank'
u[] = 'spanner'
u[] = 'sqlmap'
u[] = 'Sqworm'
u[] = 'Stripper'
u[] = 'Sucker'
u[] = 'SuperBot'
u[] = 'Super Happy Fun'
u[] = 'SuperHTTP'
u[] = 'Surfbot'
u[] = 'suzuran'
u[] = 'Szukacz'
u[] = 'tAkeOut'
u[] = 'TightTwatBot'		; WTF?!
u[] = 'Titan'
u[] = 'Teleport'
u[] = 'Telesoft'
u[] = 'TrackBack'
u[] = 'True_Robot'
u[] = 'Turing Machine'
u[] = 'turingos'
u[] = 'TurnitinBot'
u[] = 'Ubuntu/9.25'
u[] = 'unspecified'
u[] = 'user'
u[] = 'User Agent:'
u[] = 'User-Agent:'
u[] = 'VoidEYE'
u[] = 'w3af'
u[] = 'Warning'
u[] = 'Web Image Collector'
u[] = 'WebaltBot'
u[] = 'WebAuto'
u[] = 'WebFetch'
u[] = 'WebGo'
u[] = 'WebmasterWorldForumBot'
u[] = 'WebSauger'
u[] = 'WebSite-X Suite'
u[] = 'Website eXtractor'
u[] = 'Website Quester'
u[] = 'Webster'
u[] = 'WebWhacker'
u[] = 'WebZIP'
u[] = 'Whacker'
u[] = 'Widow'
u[] = 'Winnie Poh'
u[] = 'Win95'			; These are too old. Likely bots
u[] = 'Win98'
u[] = 'WinME'
u[] = 'Win 9x 4.90'
u[] = 'Windows 3'
u[] = 'Windows 95'
u[] = 'Windows 98'
u[] = 'Windows NT 4'
u[] = 'Windows NT;'
u[] = 'Windows NT 5.0;)'
u[] = 'Windows NT 5.1;)'
u[] = 'Windows XP 5'
u[] = 'WISEbot'
u[] = 'WISENutbot'
u[] = 'Wordpress'		; Vulnerability scanner
u[] = 'WWWOFFLE'
u[] = 'Vacuum'
u[] = 'VCI'
u[] = 'Xaldon'
u[] = 'Xenu'
u[] = 'Zeus'
u[] = 'ZmEu'
u[] = 'Zyborg'

The verified search engines

; Whitelisted popular bots and corresponding IP addresses
; Note: This isn't exhaustive and will likely fail on a few 
; legitimate visits from these. This is mostly to prevent spoofers.
; 
; http://chceme.info/ips/
; http://www.webmasterworld.com/search_engine_spiders/4475767.htm
; http://www.internetofficer.com/web-robot/yahoo/

[Google]
i[] = '64.233.160.0/19'
i[] = '66.102.0.0/20' 
i[] = '66.249.64.0/19'
i[] = '72.14.192.0/18' 
i[] = '74.125.0.0/16' 
i[] = '209.85.128.0/17' 
i[] = '216.239.32.0/19'

[Bing_Live_MS Search_MSN]
i[] = '64.4.0.0/18'
i[] = '65.52.0.0/14'
i[] = '131.253.21.0/24'
i[] = '131.253.22.0/23'
i[] = '131.253.24.0/21'
i[] = '131.253.32.0/20'
i[] = '157.54.0.0/15'
i[] = '157.56.0.0/14'
i[] = '157.60.0.0/16'
i[] = '207.46.0.0/16'
i[] = '207.68.128.0/18'
i[] = '207.68.192.0/20'

[Inktomi_Slurp_SearchMonkey_Yahoo] 
i[] = '8.12.144.0/24'
i[] = '66.196.64.0/18'
i[] = '66.228.160.0/19'
i[] = '67.195.0.0/16'
i[] = '68.142.192.0/18'
i[] = '68.180.128.0/17'
i[] = '72.30.0.0/16'
i[] = '74.6.0.0/16'
i[] = '202.160.176.0/20'
i[] = '209.191.64.0/18'

[Baidu]
i[] = '61.135.190.1/32'		; CN...
i[] = '61.135.190.2/31'
i[] = '61.135.190.4/30'
i[] = '61.135.190.8/29'
i[] = '61.135.190.16/28'
i[] = '61.135.190.32/27'
i[] = '61.135.190.64/26'
i[] = '61.135.190.128/26'
i[] = '61.135.190.192/27'
i[] = '61.135.190.224/28'
i[] = '61.135.190.240/29'
i[] = '61.135.190.248/30'
i[] = '61.135.190.252/31'
i[] = '61.135.190.254/32'
i[] = '119.63.192.0/21'		; JP...
i[] = '119.63.192.128/26'
i[] = '119.63.192.192/27'
i[] = '119.63.192.224/28'
i[] = '119.63.192.240/29'
i[] = '119.63.192.248/30'
i[] = '119.63.192.252/31'
i[] = '119.63.192.254/32'
i[] = '119.63.193.0/24'
i[] = '119.63.196.1/32'
i[] = '119.63.196.2/31'
i[] = '119.63.196.4/30'
i[] = '119.63.196.8/29'
i[] = '119.63.196.16/28'
i[] = '119.63.196.32/27'
i[] = '119.63.196.64/26'
i[] = '119.63.198.0/24'
i[] = '119.63.199.103/32'
i[] = '123.125.64.0/18'		; CN...
i[] = '123.125.66.0/24'
i[] = '123.125.71.0/24'
i[] = '180.76.0.0/16'
i[] = '180.76.5.0/24'
i[] = '180.76.6.0/24'
i[] = '220.181.0.0/18'
i[] = '220.181.7.0/24'
i[] = '220.181.108.0/24'

[Yandex]
i[] = '77.88.0.0/18'
i[] = '77.88.22.0/23'
i[] = '77.88.24.0/21'
i[] = '77.88.24.0/22'
i[] = '77.88.28.0/22'
i[] = '77.88.36.0/23'
i[] = '77.88.42.0/23'
i[] = '77.88.44.0/24'
i[] = '77.88.50.0/23'
i[] = '87.250.224.0/19'
i[] = '87.250.230.0/23'
i[] = '87.250.252.0/22'
i[] = '93.158.128.0/18'
i[] = '93.158.137.0/24'
i[] = '93.158.144.0/21'
i[] = '93.158.144.0/23'
i[] = '93.158.146.0/23'
i[] = '93.158.148.0/22'
i[] = '95.108.128.0/17'
i[] = '95.108.128.0/24'
i[] = '95.108.152.0/22'
i[] = '95.108.216.0/23'
i[] = '95.108.240.0/21'
i[] = '95.108.248.0/23'
i[] = '178.154.128.0/17'
i[] = '178.154.160.0/22'
i[] = '178.154.164.0/23'
i[] = '199.36.240.0/22'
i[] = '213.180.192.0/19'
i[] = '213.180.204.0/24'
i[] = '213.180.206.0/23'
i[] = '213.180.209.0/24'
i[] = '213.180.218.0/23'
i[] = '213.180.220.0/23'

The ‘Bad URIs’

; URL fragments indicating possible SQL injection or 
; directory traversal attempts. Part of the matches from Bad Behavior
; 
; http://www.technicalinfo.net/papers/URLEmbeddedAttacks.html


u[] = '0x31303235343830303536'
u[] = '../'
u[] = '..\\'
u[] = '..%2F'
u[] = '..%u2216'
u[] = '?=PHP'				; Attempt to reveal PHP version
u[] = '%60information_schema%60'
u[] = ';DECLARE%20@'
u[] = '%7e'
u[] = '%3cscript%20'
u[] = '%27%3b%20'
u[] = '%22http%3a%2f%2f'
u[] = '%255c'
u[] = '%%35c'
u[] = '%25%35%63'
u[] = '%c0%af'
u[] = '%c1%9c'
u[] = '%c1%pc'
u[] = '%c0%qf'
u[] = '%c1%8s'
u[] = '%c1%1c'
u[] = '%c1%af'
u[] = '%e0%80%af'
u[] = '%u'
u[] = '+%2F*%21'
u[] = '%27--'
u[] = '%27 --'
u[] = '%27%23'
u[] = '%27 %23'
u[] = 'benchmark%28'
u[] = 'insert+into+'
u[] = 'r3dm0v3'
u[] = 'select+1+from'
u[] = 'union+all+select'
u[] = 'union+select'
u[] = 'waitfor+delay+'
u[] = 'w00tw00t'

And, finally, a ‘FireEntry’ example model. This can show what variables would be saved to the db.

<?php


namespace Models;

class FireEntry extends base {
	
	/**
	 * @var string Assigned label (not UA, but what the firewall determined)
	 */
	public $label	= 'unknown';
	
	
	/**
	 * @var string Request method
	 */
	public $method	= '';
	
	
	/**
	 * @var string Accessed URI
	 */
	public $uri	= '';
	
	
	
	/**
	 * @var string Accessing IP
	 */
	public $ip	= '';
	
	
	
	/**
	 * @var string User Agent string
	 */
	public $ua	= '';
	
	
	
	/**
	 * @var string Complete header string
	 */
	public $headers	= '';
	
	
	
	/**
	 * @var string Requested server protocol
	 */
	public $protocol = '';
	
	
	
	/**
	 * @var string Firewall action (blocked, passed etc...)
	 */
	public $response = '';
	
	
	
	/**
	 * @var string Time the request was received
	 */
	public $reqtime = '';
	
	
	public function __construct( array $data = null ) {
		
		if ( empty( $data ) ) {
			return;
		}
		
		foreach ( $data as $field => $value ) {
			$this->$field = $value;
		}
	}
	
	
	public function save() {
		$time	= parent::_myTime( time() );
		$row	= 0;
		
		$headers='';
		if ( !empty( $this->headers ) ) {
			
		}
		if ( empty( $this->reqtime ) ) {
			$this->reqtime = $time;
		} else {
			$this->reqtime = parent::_myTime( $this->reqtime );
		}
		
		$params = array(
			'label'		=> $this->label,
			'method'	=> $this->method,
			'uri'		=> $this->uri,
			'ip'		=> $this->ip,
			'ua'		=> $this->ua,
			'headers'	=> $headers,
			'protocol'	=> $this->protocol,
			'reqtime'	=> $this->reqtime,
			'updated_at'	=> $time
		);
		
		var_dump( $params );
		//parent::put( 'firewall', $params );
	}
	
	
	
	public static function find( $filter = array() ) {
		// TODO: Filter
		
	}
	
	
	public static function gc( $exp ) {
		$sql	= "DELETE FROM firewall WHERE ( created_at < : exp);";
		$param	= array( 'exp' => $exp );
		
		parent::init();
		parent::$db->prepare( $sql );
		parent::$db->execute( $param );	
	}
	
	
	private static function filterConfig( &$filter = array() ) {
		$filter['limit']	= isset( $filter['limit'] ) ? $filter['limit'] : 10;
		$filter['page']		= isset( $filter['page'] ) ? $filter['page'] : 1;
		$filter['search']	= isset( $filter['search'] ) ? 
						$filter['search'] : '';
		
		$filter['offset']	= parent::_offset( 
						$filter['page'] , 
						$filter['limit']
					);
	}
}

Storing Database Credentials (and other stuff) in php.ini

If you’re storing your database password + username and other secure information in just any old .php file in your application, you’re doing it so, very, very, very wrong. If you must physically store these keys to the castle, the old method with Apache used to be SetEnv. Of course, not everyone uses Apache these days (I use Nginx on my *nix boxes).

The best place to store these things is in an .ini file. Specifically, for content that rarely, if ever, changes (I.E. database connection strings) it should be php.ini. Every PHP installation should have one and if you don’t have access to this, it’s time to switch web hosts.

In your php.ini, you can add the following or equivalent settings somewhere in the bottom.

[MyCustomApp]
myapp.cfg.DB_HOST = 'mysql:host=127.0.0.1;dbname=mydatabase'
myapp.cfg.DB_USER = 'dbusername'
myapp.cfg.DB_PASS = 'dbpassword'

Note: MyCustomApp is just the configuration label set to that particular group of settings. It’s good practice to give labels to your configuration settings and group them together. Especially if you move on to have a lot more of them later on.

Here is a very simple bit of code to load the above settings into globally defined variables :

// Very simple loader
function loadConfig( $vars = array() ) {
	foreach( $vars as $v ) {
		define( $v, get_cfg_var( "myapp.cfg.$v" ) );
	}
}

// Then call :
$cfg = array( 'DB_HOST', 'DB_USER', 'DB_PASS' );
loadConfig( $cfg );

Doing this is the far more secure method of setting up most other applications (including *cough* WordPress) as opposed to the old-school way, which I’m sure anyone who’s setup any PHP app in the past has dealt with:

 // Ordinary config.php or some such file 
// (I.E. DON'T DO THIS ANY MORE)
define( 'DB_HOST', 'mysql:host=127.0.0.1;dbname=mydatabase' );
define( 'DB_USER', 'dbusername' );
define( 'DB_PASS', 'dbpassword' );

The best way to prevent information you have on your hands falling into the wrong hands is to not have it in your hands. If some misconfiguration results in raw PHP files being served as text files (this happens far more often than you might think), the only thing you’ve exposed is just the site code, not your DB credentials AWS passwords, secret salts etc…

Caveats

As mentioned above, not everyone will have access to php.ini from their web host (which, as I also said, is a good hint it’s time to switch hosts). You will also need to reload PHP to ensure the new configuration changes will take effect. It’s possible to gracefully shutdown and restart these days, but that will mean a tinsey bit of down time of a few seconds at least so this will need to be done for configuration settings that are critical and yet will change infrequently. Or, if you’re using PHP-FPM with Nginx, you can start another FastCGI instance and have Nginx fail over to that.

Addendum

The PDO driver for MySQL for some reason demands the username and password separately. I thought this is kinda silly since other drivers (E.G. Postgresql) can function just fine with a connection string such as :

pgsql:host=localhost;port=5432;dbname=testdb;user=bruce;password=mypass

Well, to keep the MySQL driver and many others happy, I’ve written a small helper class that intercepts the connection string and breaks it down so the username and password can be kept separate. It also works with the above php.ini trick in that you can now store a complete connection string as php.dsn.mydb or the like as shown in the PDO docs.

/**
 * PDO Connector class
 * Modifies the DSN to parse username and password individually.
 * Optionally, gets the DSN directly from php.ini.
 *
 * @author Eksith Rodrigo <reksith at gmail.com>
 * @license http://opensource.org/licenses/ISC ISC License
 * @version 0.1
 */
 
class Cxn {
	protected $db;
	
	public function __construct( $dbh ) {
		$this->connect( $dbh );
	}
	
	public function getDb() {	
		if ( is_object( $this->db ) ) {
			return $this->db;
		} else {
			die('There was a database problem');
		}
	}
	
	public function __destruct() {
		$this->db = null;
	}

	private function connect( $dbh ) {
		if ( !empty( $this->db ) && is_object( $this->db ) ) {
			return;
		}
		
		try {
			$settings = array(
				PDO::ATTR_TIMEOUT		=> "5",
				//PDO::ATTR_EMULATE_PERPARES	=> false,
				PDO::ATTR_ERRMODE		=> PDO::ERRMODE_EXCEPTION,
				PDO::ATTR_DEFAULT_FETCH_MODE	=> PDO::FETCH_ASSOC,
				PDO::ATTR_PERSISTENT		=> false
			);
			
			$this->_dsn( $dbh, $username, $password );
			$this->db = new PDO( $dbh, $username, $password, $settings );
		} catch ( PDOException $e ) {
			exit( $e->getMessage() );
		}
	}
	
	/**
	 * Extract the username and password from the DSN and rebuild
	 */
	private function _dsn( &$dsn, &$username = '', &$password = '' ) {
		
		/**
		 * No host name with ':' would mean this is a DSN name in php.ini
		 */
		if ( false === strrpos( $dsn, ':' ) ) {
			
			/**
			 * We need get_cfg_var() here because ini_get doesn't work
			 * https://bugs.php.net/bug.php?id=54276
			 */
			$dsn = get_cfg_var( "php.dsn.$dsn" );
		}
		
		/**
		 * Some people use spaces to separate parameters in
		 * DSN strings and this is NOT standard
		 */
		$d = explode( ';', $dsn );
		$m = count( $d );
		$s = '';
		
		for( $i = 0; $i < $m; $i++ ) {
			$n = explode( '=', $d[$i] );

			// Empty parameter? Continue
			if ( count( $n ) <= 1 ) {
				$s .= implode( '', $n ) . ';';
				continue;
			}
			
			switch( trim( $n[0] ) ) {
				case 'uid':
				case 'user':
				case 'username':
					$username = trim( $n[1] );
					break;
				
				case 'pwd':
				case 'pass':
				case 'password':
					$password = trim( $n[1] );
					break;
				
				default: // Some other parameter? Leave as-is
					$s .= implode( '=', $n ) . ';';
			}
		}
		$dsn = $s;
	}
}

You can use this class with :

$cxn = new Cxn( DBH );

Where DBH came from (hopefully) php.ini.