From 14a0ac63a9dfb72e4681c37f7727cd48882152bd Mon Sep 17 00:00:00 2001 From: Pascal Kriete Date: Tue, 5 Apr 2011 14:55:56 -0400 Subject: Moving security to core. --- system/core/CodeIgniter.php | 7 + system/core/Input.php | 16 +- system/core/Security.php | 820 ++++++++++++++++++++++++++++++++++++++++++++ system/core/Utf8.php | 2 +- 4 files changed, 835 insertions(+), 10 deletions(-) create mode 100644 system/core/Security.php (limited to 'system/core') diff --git a/system/core/CodeIgniter.php b/system/core/CodeIgniter.php index 39a4d7ffd..7f4595e68 100644 --- a/system/core/CodeIgniter.php +++ b/system/core/CodeIgniter.php @@ -196,6 +196,13 @@ } } +/* + * ----------------------------------------------------- + * Load the security class for xss and csrf support + * ----------------------------------------------------- + */ + $SEC =& load_class('Security', 'core'); + /* * ------------------------------------------------------ * Load the Input class and sanitize globals diff --git a/system/core/Input.php b/system/core/Input.php index 18131350f..dc7612e64 100644 --- a/system/core/Input.php +++ b/system/core/Input.php @@ -53,11 +53,8 @@ class CI_Input { $this->_enable_xss = (config_item('global_xss_filtering') === TRUE); $this->_enable_csrf = (config_item('csrf_protection') === TRUE); - // Do we need to load the security class? - if ($this->_enable_xss == TRUE OR $this->_enable_csrf == TRUE) - { - $this->security =& load_class('Security'); - } + global $SEC; + $this->security =& $SEC; // Do we need the UTF-8 class? if (UTF8_ENABLED === TRUE) @@ -92,8 +89,7 @@ class CI_Input { if ($xss_clean === TRUE) { - $_security =& load_class('Security'); - return $_security->xss_clean($array[$index]); + return $this->security->xss_clean($array[$index]); } return $array[$index]; @@ -527,6 +523,9 @@ class CI_Input { { $str = $this->uni->clean_string($str); } + + // Remove control characters + $str = remove_invisible_characters($str); // Should we filter the input data? if ($this->_enable_xss === TRUE) @@ -642,8 +641,7 @@ class CI_Input { if ($xss_clean === TRUE) { - $_security =& load_class('Security'); - return $_security->xss_clean($this->headers[$index]); + return $this->security->xss_clean($this->headers[$index]); } return $this->headers[$index]; diff --git a/system/core/Security.php b/system/core/Security.php new file mode 100644 index 000000000..ceef9779c --- /dev/null +++ b/system/core/Security.php @@ -0,0 +1,820 @@ + '[removed]', + 'document.write' => '[removed]', + '.parentNode' => '[removed]', + '.innerHTML' => '[removed]', + 'window.location' => '[removed]', + '-moz-binding' => '[removed]', + '' => '-->', + ' '<![CDATA[' + ); + + /* never allowed, regex replacement */ + protected $_never_allowed_regex = array( + "javascript\s*:" => '[removed]', + "expression\s*(\(|&\#40;)" => '[removed]', // CSS and IE + "vbscript\s*:" => '[removed]', // IE, surprise! + "Redirect\s+302" => '[removed]' + ); + + /** + * Constructor + */ + public function __construct() + { + // Append application specific cookie prefix to token name + $this->_csrf_cookie_name = (config_item('cookie_prefix')) ? config_item('cookie_prefix').$this->_csrf_token_name : $this->_csrf_token_name; + + // Set the CSRF hash + $this->_csrf_set_hash(); + + log_message('debug', "Security Class Initialized"); + } + + // -------------------------------------------------------------------- + + /** + * Verify Cross Site Request Forgery Protection + * + * @return object + */ + public function csrf_verify() + { + // If no POST data exists we will set the CSRF cookie + if (count($_POST) == 0) + { + return $this->csrf_set_cookie(); + } + + // Do the tokens exist in both the _POST and _COOKIE arrays? + if ( ! isset($_POST[$this->_csrf_token_name]) OR + ! isset($_COOKIE[$this->_csrf_cookie_name])) + { + $this->csrf_show_error(); + } + + // Do the tokens match? + if ($_POST[$this->_csrf_token_name] != $_COOKIE[$this->_csrf_cookie_name]) + { + $this->csrf_show_error(); + } + + // We kill this since we're done and we don't want to + // polute the _POST array + unset($_POST[$this->_csrf_token_name]); + + // Nothing should last forever + unset($_COOKIE[$this->_csrf_cookie_name]); + $this->_csrf_set_hash(); + $this->csrf_set_cookie(); + + log_message('debug', "CSRF token verified "); + + return $this; + } + + // -------------------------------------------------------------------- + + /** + * Set Cross Site Request Forgery Protection Cookie + * + * @return object + */ + public function csrf_set_cookie() + { + $expire = time() + $this->_csrf_expire; + $secure_cookie = (config_item('cookie_secure') === TRUE) ? 1 : 0; + + if ($secure_cookie) + { + $req = isset($_SERVER['HTTPS']) ? $_SERVER['HTTPS'] : FALSE; + + if ( ! $req OR $req == 'off') + { + return FALSE; + } + } + + setcookie($this->_csrf_cookie_name, $this->_csrf_hash, $expire, config_item('cookie_path'), config_item('cookie_domain'), $secure_cookie); + + log_message('debug', "CRSF cookie Set"); + + return $this; + } + + // -------------------------------------------------------------------- + + /** + * Show CSRF Error + * + * @return void + */ + public function csrf_show_error() + { + show_error('The action you have requested is not allowed.'); + } + + // -------------------------------------------------------------------- + + /** + * Get CSRF Hash + * + * Getter Method + * + * @return string self::_csrf_hash + */ + public function get_csrf_hash() + { + return $this->_csrf_hash; + } + + // -------------------------------------------------------------------- + + /** + * Get CSRF Token Name + * + * Getter Method + * + * @return string self::csrf_token_name + */ + public function get_csrf_token_name() + { + return $this->_csrf_token_name; + } + + // -------------------------------------------------------------------- + + /** + * XSS Clean + * + * Sanitizes data so that Cross Site Scripting Hacks can be + * prevented. This function does a fair amount of work but + * it is extremely thorough, designed to prevent even the + * most obscure XSS attempts. Nothing is ever 100% foolproof, + * of course, but I haven't been able to get anything passed + * the filter. + * + * Note: This function should only be used to deal with data + * upon submission. It's not something that should + * be used for general runtime processing. + * + * This function was based in part on some code and ideas I + * got from Bitflux: http://channel.bitflux.ch/wiki/XSS_Prevention + * + * To help develop this script I used this great list of + * vulnerabilities along with a few other hacks I've + * harvested from examining vulnerabilities in other programs: + * http://ha.ckers.org/xss.html + * + * @param mixed string or array + * @return string + */ + public function xss_clean($str, $is_image = FALSE) + { + /* + * Is the string an array? + * + */ + if (is_array($str)) + { + while (list($key) = each($str)) + { + $str[$key] = $this->xss_clean($str[$key]); + } + + return $str; + } + + /* + * Remove Invisible Characters + */ + $str = remove_invisible_characters($str); + + // Validate Entities in URLs + $str = $this->_validate_entities($str); + + /* + * URL Decode + * + * Just in case stuff like this is submitted: + * + * Google + * + * Note: Use rawurldecode() so it does not remove plus signs + * + */ + $str = rawurldecode($str); + + /* + * Convert character entities to ASCII + * + * This permits our tests below to work reliably. + * We only convert entities that are within tags since + * these are the ones that will pose security problems. + * + */ + + $str = preg_replace_callback("/[a-z]+=([\'\"]).*?\\1/si", array($this, '_convert_attribute'), $str); + + $str = preg_replace_callback("/<\w+.*?(?=>|<|$)/si", array($this, '_decode_entity'), $str); + + /* + * Remove Invisible Characters Again! + */ + $str = remove_invisible_characters($str); + + /* + * Convert all tabs to spaces + * + * This prevents strings like this: ja vascript + * NOTE: we deal with spaces between characters later. + * NOTE: preg_replace was found to be amazingly slow here on + * large blocks of data, so we use str_replace. + */ + + if (strpos($str, "\t") !== FALSE) + { + $str = str_replace("\t", ' ', $str); + } + + /* + * Capture converted string for later comparison + */ + $converted_string = $str; + + // Remove Strings that are never allowed + $str = $this->_do_never_allowed($str); + + /* + * Makes PHP tags safe + * + * Note: XML tags are inadvertently replaced too: + * + * '), array('<?', '?>'), $str); + } + + /* + * Compact any exploded words + * + * This corrects words like: j a v a s c r i p t + * These words are compacted back to their correct state. + */ + $words = array( + 'javascript', 'expression', 'vbscript', 'script', + 'applet', 'alert', 'document', 'write', 'cookie', 'window' + ); + + foreach ($words as $word) + { + $temp = ''; + + for ($i = 0, $wordlen = strlen($word); $i < $wordlen; $i++) + { + $temp .= substr($word, $i, 1)."\s*"; + } + + // We only want to do this when it is followed by a non-word character + // That way valid stuff like "dealer to" does not become "dealerto" + $str = preg_replace_callback('#('.substr($temp, 0, -3).')(\W)#is', array($this, '_compact_exploded_words'), $str); + } + + /* + * Remove disallowed Javascript in links or img tags + * We used to do some version comparisons and use of stripos for PHP5, + * but it is dog slow compared to these simplified non-capturing + * preg_match(), especially if the pattern exists in the string + */ + do + { + $original = $str; + + if (preg_match("/]*?)(>|$)#si", array($this, '_js_link_removal'), $str); + } + + if (preg_match("/]*?)(\s?/?>|$)#si", array($this, '_js_img_removal'), $str); + } + + if (preg_match("/script/i", $str) OR preg_match("/xss/i", $str)) + { + $str = preg_replace("#<(/*)(script|xss)(.*?)\>#si", '[removed]', $str); + } + } + while($original != $str); + + unset($original); + + // Remove evil attributes such as style, onclick and xmlns + $str = $this->_remove_evil_attributes($str, $is_image); + + /* + * Sanitize naughty HTML elements + * + * If a tag containing any of the words in the list + * below is found, the tag gets converted to entities. + * + * So this: + * Becomes: <blink> + */ + $naughty = 'alert|applet|audio|basefont|base|behavior|bgsound|blink|body|embed|expression|form|frameset|frame|head|html|ilayer|iframe|input|isindex|layer|link|meta|object|plaintext|style|script|textarea|title|video|xml|xss'; + $str = preg_replace_callback('#<(/*\s*)('.$naughty.')([^><]*)([><]*)#is', array($this, '_sanitize_naughty_html'), $str); + + /* + * Sanitize naughty scripting elements + * + * Similar to above, only instead of looking for + * tags it looks for PHP and JavaScript commands + * that are disallowed. Rather than removing the + * code, it simply converts the parenthesis to entities + * rendering the code un-executable. + * + * For example: eval('some code') + * Becomes: eval('some code') + */ + $str = preg_replace('#(alert|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si', "\\1\\2(\\3)", $str); + + + // Final clean up + // This adds a bit of extra precaution in case + // something got through the above filters + $str = $this->_do_never_allowed($str); + + /* + * Images are Handled in a Special Way + * - Essentially, we want to know that after all of the character + * conversion is done whether any unwanted, likely XSS, code was found. + * If not, we return TRUE, as the image is clean. + * However, if the string post-conversion does not matched the + * string post-removal of XSS, then it fails, as there was unwanted XSS + * code found and removed/changed during processing. + */ + + if ($is_image === TRUE) + { + return ($str == $converted_string) ? TRUE: FALSE; + } + + log_message('debug', "XSS Filtering completed"); + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Random Hash for protecting URLs + * + * @return string + */ + public function xss_hash() + { + if ($this->_xss_hash == '') + { + if (phpversion() >= 4.2) + { + mt_srand(); + } + else + { + mt_srand(hexdec(substr(md5(microtime()), -8)) & 0x7fffffff); + } + + $this->_xss_hash = md5(time() + mt_rand(0, 1999999999)); + } + + return $this->_xss_hash; + } + + // -------------------------------------------------------------------- + + /** + * HTML Entities Decode + * + * This function is a replacement for html_entity_decode() + * + * In some versions of PHP the native function does not work + * when UTF-8 is the specified character set, so this gives us + * a work-around. More info here: + * http://bugs.php.net/bug.php?id=25670 + * + * NOTE: html_entity_decode() has a bug in some PHP versions when UTF-8 is the + * character set, and the PHP developers said they were not back porting the + * fix to versions other than PHP 5.x. + * + * @param string + * @param string + * @return string + */ + public function entity_decode($str, $charset='UTF-8') + { + if (stristr($str, '&') === FALSE) return $str; + + // The reason we are not using html_entity_decode() by itself is because + // while it is not technically correct to leave out the semicolon + // at the end of an entity most browsers will still interpret the entity + // correctly. html_entity_decode() does not convert entities without + // semicolons, so we are left with our own little solution here. Bummer. + + if (function_exists('html_entity_decode') && + (strtolower($charset) != 'utf-8')) + { + $str = html_entity_decode($str, ENT_COMPAT, $charset); + $str = preg_replace('~&#x(0*[0-9a-f]{2,5})~ei', 'chr(hexdec("\\1"))', $str); + return preg_replace('~&#([0-9]{2,4})~e', 'chr(\\1)', $str); + } + + // Numeric Entities + $str = preg_replace('~&#x(0*[0-9a-f]{2,5});{0,1}~ei', 'chr(hexdec("\\1"))', $str); + $str = preg_replace('~&#([0-9]{2,4});{0,1}~e', 'chr(\\1)', $str); + + // Literal Entities - Slightly slow so we do another check + if (stristr($str, '&') === FALSE) + { + $str = strtr($str, array_flip(get_html_translation_table(HTML_ENTITIES))); + } + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Filename Security + * + * @param string + * @return string + */ + public function sanitize_filename($str, $relative_path = FALSE) + { + $bad = array( + "../", + "", + "<", + ">", + "'", + '"', + '&', + '$', + '#', + '{', + '}', + '[', + ']', + '=', + ';', + '?', + "%20", + "%22", + "%3c", // < + "%253c", // < + "%3e", // > + "%0e", // > + "%28", // ( + "%29", // ) + "%2528", // ( + "%26", // & + "%24", // $ + "%3f", // ? + "%3b", // ; + "%3d" // = + ); + + if ( ! $relative_path) + { + $bad[] = './'; + $bad[] = '/'; + } + + $str = remove_invisible_characters($str, FALSE); + return stripslashes(str_replace($bad, '', $str)); + } + + // ---------------------------------------------------------------- + + /** + * Compact Exploded Words + * + * Callback function for xss_clean() to remove whitespace from + * things like j a v a s c r i p t + * + * @param type + * @return type + */ + protected function _compact_exploded_words($matches) + { + return preg_replace('/\s+/s', '', $matches[1]).$matches[2]; + } + + // -------------------------------------------------------------------- + + /* + * Remove Evil HTML Attributes (like evenhandlers and style) + * + * It removes the evil attribute and either: + * - Everything up until a space + * For example, everything between the pipes: + * + * - Everything inside the quotes + * For example, everything between the pipes: + * + * + * @param string $str The string to check + * @param boolean $is_image TRUE if this is an image + * @return string The string with the evil attributes removed + */ + protected function _remove_evil_attributes($str, $is_image) + { + // All javascript event handlers (e.g. onload, onclick, onmouseover), style, and xmlns + $evil_attributes = array('on\w*', 'style', 'xmlns'); + + if ($is_image === TRUE) + { + /* + * Adobe Photoshop puts XML metadata into JFIF images, + * including namespacing, so we have to allow this for images. + */ + unset($evil_attributes[array_search('xmlns', $evil_attributes)]); + } + + do { + $str = preg_replace( + "#<(/?[^><]+?)([^A-Za-z\-])(".implode('|', $evil_attributes).")(\s*=\s*)([\"][^>]*?[\"]|[\'][^>]*?[\']|[^>]*?)([\s><])([><]*)#i", + "<$1$6", + $str, -1, $count + ); + } while ($count); + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Sanitize Naughty HTML + * + * Callback function for xss_clean() to remove naughty HTML elements + * + * @param array + * @return string + */ + protected function _sanitize_naughty_html($matches) + { + // encode opening brace + $str = '<'.$matches[1].$matches[2].$matches[3]; + + // encode captured opening or closing brace to prevent recursive vectors + $str .= str_replace(array('>', '<'), array('>', '<'), + $matches[4]); + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * JS Link Removal + * + * Callback function for xss_clean() to sanitize links + * This limits the PCRE backtracks, making it more performance friendly + * and prevents PREG_BACKTRACK_LIMIT_ERROR from being triggered in + * PHP 5.2+ on link-heavy strings + * + * @param array + * @return string + */ + protected function _js_link_removal($match) + { + $attributes = $this->_filter_attributes(str_replace(array('<', '>'), '', $match[1])); + + return str_replace($match[1], preg_replace("#href=.*?(alert\(|alert&\#40;|javascript\:|livescript\:|mocha\:|charset\=|window\.|document\.|\.cookie|_filter_attributes(str_replace(array('<', '>'), '', $match[1])); + + return str_replace($match[1], preg_replace("#src=.*?(alert\(|alert&\#40;|javascript\:|livescript\:|mocha\:|charset\=|window\.|document\.|\.cookie|', '<', '\\'), array('>', '<', '\\\\'), $match[0]); + } + + // -------------------------------------------------------------------- + + /** + * Filter Attributes + * + * Filters tag attributes for consistency and safety + * + * @param string + * @return string + */ + protected function _filter_attributes($str) + { + $out = ''; + + if (preg_match_all('#\s*[a-z\-]+\s*=\s*(\042|\047)([^\\1]*?)\\1#is', $str, $matches)) + { + foreach ($matches[0] as $match) + { + $out .= preg_replace("#/\*.*?\*/#s", '', $match); + } + } + + return $out; + } + + // -------------------------------------------------------------------- + + /** + * HTML Entity Decode Callback + * + * Used as a callback for XSS Clean + * + * @param array + * @return string + */ + protected function _decode_entity($match) + { + return $this->entity_decode($match[0], strtoupper(config_item('charset'))); + } + + // -------------------------------------------------------------------- + + /** + * Validate URL entities + * + * Called by xss_clean() + * + * @param string + * @return string + */ + protected function _validate_entities($str) + { + /* + * Protect GET variables in URLs + */ + + // 901119URL5918AMP18930PROTECT8198 + + $str = preg_replace('|\&([a-z\_0-9\-]+)\=([a-z\_0-9\-]+)|i', $this->xss_hash()."\\1=\\2", $str); + + /* + * Validate standard character entities + * + * Add a semicolon if missing. We do this to enable + * the conversion of entities to ASCII later. + * + */ + $str = preg_replace('#(&\#?[0-9a-z]{2,})([\x00-\x20])*;?#i', "\\1;\\2", $str); + + /* + * Validate UTF16 two byte encoding (x00) + * + * Just as above, adds a semicolon if missing. + * + */ + $str = preg_replace('#(&\#x?)([0-9A-F]+);?#i',"\\1\\2;",$str); + + /* + * Un-Protect GET variables in URLs + */ + $str = str_replace($this->xss_hash(), '&', $str); + + return $str; + } + + // ---------------------------------------------------------------------- + + /** + * Do Never Allowed + * + * A utility function for xss_clean() + * + * @param string + * @return string + */ + protected function _do_never_allowed($str) + { + foreach ($this->_never_allowed_str as $key => $val) + { + $str = str_replace($key, $val, $str); + } + + foreach ($this->_never_allowed_regex as $key => $val) + { + $str = preg_replace("#".$key."#i", $val, $str); + } + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Set Cross Site Request Forgery Protection Cookie + * + * @return string + */ + protected function _csrf_set_hash() + { + if ($this->_csrf_hash == '') + { + // If the cookie exists we will use it's value. + // We don't necessarily want to regenerate it with + // each page load since a page could contain embedded + // sub-pages causing this feature to fail + if (isset($_COOKIE[$this->_csrf_cookie_name]) && + $_COOKIE[$this->_csrf_cookie_name] != '') + { + return $this->_csrf_hash = $_COOKIE[$this->_csrf_cookie_name]; + } + + return $this->_csrf_hash = md5(uniqid(rand(), TRUE)); + } + + return $this->_csrf_hash; + } + +} +// END Security Class + +/* End of file Security.php */ +/* Location: ./system/libraries/Security.php */ \ No newline at end of file diff --git a/system/core/Utf8.php b/system/core/Utf8.php index 5d5a7ef72..2a27d1f35 100644 --- a/system/core/Utf8.php +++ b/system/core/Utf8.php @@ -107,7 +107,7 @@ class CI_Utf8 { */ function safe_ascii_for_xml($str) { - return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S', '', $str); + return remove_invisible_characters($str, FALSE); } // -------------------------------------------------------------------- -- cgit v1.2.3-24-g4f1b