From eda14348b0ed03cba1680484dff9068317401677 Mon Sep 17 00:00:00 2001 From: Abel Hoogeveen Date: Sat, 9 Feb 2019 20:22:49 +0100 Subject: [PATCH] Implemented all basic features of WebComponent. Not release ready --- src/Config/config.mimes.php | 197 +++ src/Config/config.security.php | 104 ++ src/Config/config.web.php | 8 +- src/FuzeWorks/Exception/OutputException.php | 42 + src/FuzeWorks/Exception/SecurityException.php | 42 + src/FuzeWorks/Exception/UriException.php | 41 + src/FuzeWorks/Exception/WebException.php | 42 + src/FuzeWorks/Input.php | 160 ++- src/FuzeWorks/Output.php | 238 +++- src/FuzeWorks/Security.php | 1060 +++++++++++++++++ src/FuzeWorks/URI.php | 211 ++++ src/FuzeWorks/UTF8.php | 178 +++ src/FuzeWorks/WebComponent.php | 106 +- src/FuzeWorks/WebController.php | 68 ++ src/FuzeWorks/WebModel.php | 66 + src/FuzeWorks/WebView.php | 66 + 16 files changed, 2604 insertions(+), 25 deletions(-) create mode 100644 src/Config/config.mimes.php create mode 100644 src/Config/config.security.php create mode 100644 src/FuzeWorks/Exception/OutputException.php create mode 100644 src/FuzeWorks/Exception/SecurityException.php create mode 100644 src/FuzeWorks/Exception/UriException.php create mode 100644 src/FuzeWorks/Exception/WebException.php create mode 100644 src/FuzeWorks/Security.php create mode 100644 src/FuzeWorks/UTF8.php create mode 100644 src/FuzeWorks/WebController.php create mode 100644 src/FuzeWorks/WebModel.php create mode 100644 src/FuzeWorks/WebView.php diff --git a/src/Config/config.mimes.php b/src/Config/config.mimes.php new file mode 100644 index 0000000..12bd902 --- /dev/null +++ b/src/Config/config.mimes.php @@ -0,0 +1,197 @@ + array('application/mac-binhex40', 'application/mac-binhex', 'application/x-binhex40', 'application/x-mac-binhex40'), + 'cpt' => 'application/mac-compactpro', + 'csv' => array('text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream', 'application/vnd.ms-excel', 'application/x-csv', 'text/x-csv', 'text/csv', 'application/csv', 'application/excel', 'application/vnd.msexcel', 'text/plain'), + 'bin' => array('application/macbinary', 'application/mac-binary', 'application/octet-stream', 'application/x-binary', 'application/x-macbinary'), + 'dms' => 'application/octet-stream', + 'lha' => 'application/octet-stream', + 'lzh' => 'application/octet-stream', + 'exe' => array('application/octet-stream', 'application/x-msdownload'), + 'class' => 'application/octet-stream', + 'psd' => array('application/x-photoshop', 'image/vnd.adobe.photoshop'), + 'so' => 'application/octet-stream', + 'sea' => 'application/octet-stream', + 'dll' => 'application/octet-stream', + 'oda' => 'application/oda', + 'pdf' => array('application/pdf', 'application/force-download', 'application/x-download', 'binary/octet-stream'), + 'ai' => array('application/pdf', 'application/postscript'), + 'eps' => 'application/postscript', + 'ps' => 'application/postscript', + 'smi' => 'application/smil', + 'smil' => 'application/smil', + 'mif' => 'application/vnd.mif', + 'xls' => array('application/vnd.ms-excel', 'application/msexcel', 'application/x-msexcel', 'application/x-ms-excel', 'application/x-excel', 'application/x-dos_ms_excel', 'application/xls', 'application/x-xls', 'application/excel', 'application/download', 'application/vnd.ms-office', 'application/msword'), + 'ppt' => array('application/powerpoint', 'application/vnd.ms-powerpoint', 'application/vnd.ms-office', 'application/msword'), + 'pptx' => array('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/x-zip', 'application/zip'), + 'wbxml' => 'application/wbxml', + 'wmlc' => 'application/wmlc', + 'dcr' => 'application/x-director', + 'dir' => 'application/x-director', + 'dxr' => 'application/x-director', + 'dvi' => 'application/x-dvi', + 'gtar' => 'application/x-gtar', + 'gz' => 'application/x-gzip', + 'gzip' => 'application/x-gzip', + 'php' => array('application/x-httpd-php', 'application/php', 'application/x-php', 'text/php', 'text/x-php', 'application/x-httpd-php-source'), + 'php4' => 'application/x-httpd-php', + 'php3' => 'application/x-httpd-php', + 'phtml' => 'application/x-httpd-php', + 'phps' => 'application/x-httpd-php-source', + 'js' => array('application/x-javascript', 'text/plain'), + 'swf' => 'application/x-shockwave-flash', + 'sit' => 'application/x-stuffit', + 'tar' => 'application/x-tar', + 'tgz' => array('application/x-tar', 'application/x-gzip-compressed'), + 'z' => 'application/x-compress', + 'xhtml' => 'application/xhtml+xml', + 'xht' => 'application/xhtml+xml', + 'zip' => array('application/x-zip', 'application/zip', 'application/x-zip-compressed', 'application/s-compressed', 'multipart/x-zip'), + 'rar' => array('application/x-rar', 'application/rar', 'application/x-rar-compressed'), + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mpga' => 'audio/mpeg', + 'mp2' => 'audio/mpeg', + 'mp3' => array('audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'), + 'aif' => array('audio/x-aiff', 'audio/aiff'), + 'aiff' => array('audio/x-aiff', 'audio/aiff'), + 'aifc' => 'audio/x-aiff', + 'ram' => 'audio/x-pn-realaudio', + 'rm' => 'audio/x-pn-realaudio', + 'rpm' => 'audio/x-pn-realaudio-plugin', + 'ra' => 'audio/x-realaudio', + 'rv' => 'video/vnd.rn-realvideo', + 'wav' => array('audio/x-wav', 'audio/wave', 'audio/wav'), + 'bmp' => array('image/bmp', 'image/x-bmp', 'image/x-bitmap', 'image/x-xbitmap', 'image/x-win-bitmap', 'image/x-windows-bmp', 'image/ms-bmp', 'image/x-ms-bmp', 'application/bmp', 'application/x-bmp', 'application/x-win-bitmap'), + 'gif' => 'image/gif', + 'jpeg' => array('image/jpeg', 'image/pjpeg'), + 'jpg' => array('image/jpeg', 'image/pjpeg'), + 'jpe' => array('image/jpeg', 'image/pjpeg'), + 'jp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'j2k' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpf' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpg2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpx' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'jpm' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'mj2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'mjp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'), + 'png' => array('image/png', 'image/x-png'), + 'tiff' => 'image/tiff', + 'tif' => 'image/tiff', + 'css' => array('text/css', 'text/plain'), + 'html' => array('text/html', 'text/plain'), + 'htm' => array('text/html', 'text/plain'), + 'shtml' => array('text/html', 'text/plain'), + 'txt' => 'text/plain', + 'text' => 'text/plain', + 'log' => array('text/plain', 'text/x-log'), + 'rtx' => 'text/richtext', + 'rtf' => 'text/rtf', + 'xml' => array('application/xml', 'text/xml', 'text/plain'), + 'xsl' => array('application/xml', 'text/xsl', 'text/xml'), + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpe' => 'video/mpeg', + 'qt' => 'video/quicktime', + 'mov' => 'video/quicktime', + 'avi' => array('video/x-msvideo', 'video/msvideo', 'video/avi', 'application/x-troff-msvideo'), + 'movie' => 'video/x-sgi-movie', + 'doc' => array('application/msword', 'application/vnd.ms-office'), + 'docx' => array('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/msword', 'application/x-zip'), + 'dot' => array('application/msword', 'application/vnd.ms-office'), + 'dotx' => array('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip', 'application/msword'), + 'xlsx' => array('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip', 'application/vnd.ms-excel', 'application/msword', 'application/x-zip'), + 'word' => array('application/msword', 'application/octet-stream'), + 'xl' => 'application/excel', + 'eml' => 'message/rfc822', + 'json' => array('application/json', 'text/json'), + 'pem' => array('application/x-x509-user-cert', 'application/x-pem-file', 'application/octet-stream'), + 'p10' => array('application/x-pkcs10', 'application/pkcs10'), + 'p12' => 'application/x-pkcs12', + 'p7a' => 'application/x-pkcs7-signature', + 'p7c' => array('application/pkcs7-mime', 'application/x-pkcs7-mime'), + 'p7m' => array('application/pkcs7-mime', 'application/x-pkcs7-mime'), + 'p7r' => 'application/x-pkcs7-certreqresp', + 'p7s' => 'application/pkcs7-signature', + 'crt' => array('application/x-x509-ca-cert', 'application/x-x509-user-cert', 'application/pkix-cert'), + 'crl' => array('application/pkix-crl', 'application/pkcs-crl'), + 'der' => 'application/x-x509-ca-cert', + 'kdb' => 'application/octet-stream', + 'pgp' => 'application/pgp', + 'gpg' => 'application/gpg-keys', + 'sst' => 'application/octet-stream', + 'csr' => 'application/octet-stream', + 'rsa' => 'application/x-pkcs7', + 'cer' => array('application/pkix-cert', 'application/x-x509-ca-cert'), + '3g2' => 'video/3gpp2', + '3gp' => array('video/3gp', 'video/3gpp'), + 'mp4' => 'video/mp4', + 'm4a' => 'audio/x-m4a', + 'f4v' => array('video/mp4', 'video/x-f4v'), + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'aac' => 'audio/x-acc', + 'm4u' => 'application/vnd.mpegurl', + 'm3u' => 'text/plain', + 'xspf' => 'application/xspf+xml', + 'vlc' => 'application/videolan', + 'wmv' => array('video/x-ms-wmv', 'video/x-ms-asf'), + 'au' => 'audio/x-au', + 'ac3' => 'audio/ac3', + 'flac' => 'audio/x-flac', + 'ogg' => array('audio/ogg', 'video/ogg', 'application/ogg'), + 'kmz' => array('application/vnd.google-earth.kmz', 'application/zip', 'application/x-zip'), + 'kml' => array('application/vnd.google-earth.kml+xml', 'application/xml', 'text/xml'), + 'ics' => 'text/calendar', + 'ical' => 'text/calendar', + 'zsh' => 'text/x-scriptzsh', + '7zip' => array('application/x-compressed', 'application/x-zip-compressed', 'application/zip', 'multipart/x-zip'), + 'cdr' => array('application/cdr', 'application/coreldraw', 'application/x-cdr', 'application/x-coreldraw', 'image/cdr', 'image/x-cdr', 'zz-application/zz-winassoc-cdr'), + 'wma' => array('audio/x-ms-wma', 'video/x-ms-asf'), + 'jar' => array('application/java-archive', 'application/x-java-application', 'application/x-jar', 'application/x-compressed'), + 'svg' => array('image/svg+xml', 'application/xml', 'text/xml'), + 'vcf' => 'text/x-vcard', + 'srt' => array('text/srt', 'text/plain'), + 'vtt' => array('text/vtt', 'text/plain'), + 'ico' => array('image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon') +]; \ No newline at end of file diff --git a/src/Config/config.security.php b/src/Config/config.security.php new file mode 100644 index 0000000..891cda4 --- /dev/null +++ b/src/Config/config.security.php @@ -0,0 +1,104 @@ + false, + 'csrf_token_name' => 'fw_csrf_token', + 'csrf_cookie_name' => 'fw_csrf_cookie', + 'csrf_expire' => 7200, + 'csrf_regenerate' => TRUE, + 'csrf_exclude_uris' => array(), + + /* + |-------------------------------------------------------------------------- + | Standardize newlines + |-------------------------------------------------------------------------- + | + | Determines whether to standardize newline characters in input data, + | meaning to replace \r\n, \r, \n occurrences with the PHP_EOL value. + | + | This is particularly useful for portability between UNIX-based OSes, + | (usually \n) and Windows (\r\n). + | + */ + 'standardize_newlines' => FALSE, + + /* + |-------------------------------------------------------------------------- + | Global XSS Filtering + |-------------------------------------------------------------------------- + | + | Determines whether the XSS filter is always active when GET, POST or + | COOKIE data is encountered + | + | WARNING: This feature is DEPRECATED and currently available only + | for backwards compatibility purposes! + | + */ + 'global_xss_filtering' => FALSE, + + /* + |-------------------------------------------------------------------------- + | Reverse Proxy IPs + |-------------------------------------------------------------------------- + | + | If your server is behind a reverse proxy, you must whitelist the proxy + | IP addresses from which CodeIgniter should trust headers such as + | HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP in order to properly identify + | the visitor's IP address. + | + | You can use both an array or a comma-separated list of proxy addresses, + | as well as specifying whole subnets. Here are a few examples: + | + | Comma-separated: '10.0.1.200,192.168.5.0/24' + | Array: array('10.0.1.200', '192.168.5.0/24') + */ + 'proxy_ips' => '' +]; \ No newline at end of file diff --git a/src/Config/config.web.php b/src/Config/config.web.php index 2bf554b..dda4861 100644 --- a/src/Config/config.web.php +++ b/src/Config/config.web.php @@ -36,5 +36,11 @@ return [ 'allow_get_input' => true, 'empty_global_arrays' => true, - 'restore_global_arrays' => true + 'restore_global_arrays' => true, + 'base_url' => '', + 'permitted_uri_chars' => 'a-z 0-9~%.:_\-', + 'charset' => 'UTF-8', + 'compress_output' => false, + 'cookie_prefix' => 'FWZ_', + 'xss_clean' => true ]; \ No newline at end of file diff --git a/src/FuzeWorks/Exception/OutputException.php b/src/FuzeWorks/Exception/OutputException.php new file mode 100644 index 0000000..a59de33 --- /dev/null +++ b/src/FuzeWorks/Exception/OutputException.php @@ -0,0 +1,42 @@ +security = Factory::getInstance()->security; + // Set the configuration $this->webConfig = Factory::getInstance()->config->getConfig('web'); @@ -64,23 +73,58 @@ class Input $this->sanitizeGlobals(); if ($this->webConfig->get('empty_global_arrays') && $this->webConfig->get('restore_global_arrays')) + { + if (class_exists('\FuzeWorks\TracyComponent', true) && \FuzeWorks\TracyComponent::isEnabled()) + { + set_exception_handler([$this, 'tracyExceptionHandler']); + set_error_handler([$this, 'tracyErrorHandler']); + } Events::addListener( - array($this, 'restoreGlobalArrays'), - 'coreShutdownEvent', Priority::HIGH + [$this, 'restoreGlobalArrays'], + 'coreShutdownEvent', Priority::HIGHEST ); + } } - public function restoreGlobalArrays(NotifierEvent $event) + /** + * Used to restore global arrays before handling errors by Tracy + * + * @param $exception + * @param bool $exit + * @internal + */ + public function tracyExceptionHandler($exception, $exit = true) { + $this->restoreGlobalArrays(); + Debugger::exceptionHandler($exception, $exit); + } + + /** + * Used to restore global arrays before handling errors by Tracy + * + * @param $severity + * @param $message + * @param $file + * @param $line + * @param array $context + * @throws \ErrorException + * @internal + */ + public function tracyErrorHandler($severity, $message, $file, $line, $context = []) + { + $this->restoreGlobalArrays(); + Debugger::errorHandler($severity, $message, $file, $line, $context); + } + + public function restoreGlobalArrays() + { + Logger::logInfo('Restoring global $_GET, $_POST, $_SERVER, $_COOKIE arrays'); $_GET = $this->inputArray['get']; $_POST = $this->inputArray['post']; $_COOKIE = $this->inputArray['cookie']; $_SERVER = $this->inputArray['server']; } - /** - * @todo Do this later - */ protected function sanitizeGlobals() { // Copy all values from the global arrays into a local inputArray @@ -92,6 +136,102 @@ class Input // If required to, empty the global arrays if ($this->webConfig->get('empty_global_arrays')) unset($_GET, $_POST, $_COOKIE, $_SERVER); + + // Clean GET + foreach ($this->inputArray['get'] as $key => $val) + $this->inputArray[$this->cleanInputKeys($key)] = $this->cleanInputData($val); + + // Clean POST + foreach ($this->inputArray['post'] as $key => $val) + $this->inputArray[$this->cleanInputKeys($key)] = $this->cleanInputData($val); + + // Clean COOKIE + if (!empty($this->inputArray['cookie'])) + { + // Get rid of conflicting cookies + unset( + $this->inputArray['cookie']['$Version'], + $this->inputArray['cookie']['$Path'], + $this->inputArray['cookie']['$Domain'] + ); + + foreach ($this->inputArray['cookie'] as $key => $val) + { + if (($cookie_key = $this->cleanInputKeys($key)) !== false) + $this->inputArray['cookie'][$cookie_key] = $this->cleanInputData($val); + else + unset($this->inputArray['cookie'][$key]); + } + } + + // Sanitize PHP_SELF + $this->inputArray['server']['PHP_SELF'] = strip_tags($this->inputArray['server']['PHP_SELF']); + Logger::logInfo("Global variables sanitized"); + } + + /** + * Clean Keys + * + * Internal method that helps to prevent malicious users + * from trying to exploit keys we make sure that keys are + * only named with alpha-numeric text and a few other items. + * + * @param string $str Input string + * @param bool $fatal Whether to terminate script exection + * or to return FALSE if an invalid + * key is encountered + * @return string|bool + */ + protected function cleanInputKeys(string $str, bool $fatal = true) + { + if (!preg_match('/^[a-z0-9:_\/|-]+$/i', $str)) + { + if ($fatal) + return false; + else + { + // @todo Implement status header 503 + exit(7); + } + } + + // Clean with UTF8, if supported + if (UTF8::$isEnabled) + return UTF8::cleanString($str); + + return $str; + } + + /** + * Clean Input Data + * + * Internal method that aids in escaping data and + * standardizing newline characters to PHP_EOL. + * + * @param string|string[] $str Input string(s) + * @return string|array + */ + protected function cleanInputData($str) + { + if (is_array($str)) + { + $new = []; + foreach (array_keys($str) as $key) + $new[$this->cleanInputKeys($key)] = $this->cleanInputData($str[$key]); + + return $new; + } + + // Clean with UTF8 if supported + if (UTF8::$isEnabled) + $str = UTF8::cleanString($str); + + // Remove invisible characters + $str = UTF8::removeInvisibleCharacters($str, false); + + // Standardize newlines (@todo) + + return $str; } /** @@ -123,9 +263,7 @@ class Input else return null; - // @todo Implement XSS Clean here - - return $value; + return ($xssClean === true ? $this->security->xss_clean($value) : $value); } public function get($index = null, bool $xssClean = true) diff --git a/src/FuzeWorks/Output.php b/src/FuzeWorks/Output.php index 125b6b2..16797c9 100644 --- a/src/FuzeWorks/Output.php +++ b/src/FuzeWorks/Output.php @@ -37,9 +37,27 @@ namespace FuzeWorks; +use FuzeWorks\ConfigORM\ConfigORM; +use FuzeWorks\Event\HelperLoadEvent; +use FuzeWorks\Exception\OutputException; + class Output { + /** + * The internal Input class + * + * @var Input + */ + private $input; + + /** + * WebCfg + * + * @var ConfigORM + */ + private $config; + /** * Output to be sent to the client * @@ -54,49 +72,251 @@ class Output */ protected $headers = []; + protected $compressOutput = false; + + /** + * List of mime types + * + * @var array + */ + public $mimes = []; + protected $mimeType = 'text/html'; + + protected $statusCode = 200; + protected $statusText = 'OK'; + + public function init() + { + $this->input = Factory::getInstance()->input; + $this->mimes = Factory::getInstance()->config->getConfig('mimes')->toArray(); + $this->config = Factory::getInstance()->config->getConfig('web'); + + $zlib = (bool) ini_get('zlib.output_compression'); + $this->compressOutput = (!$zlib && $this->config->get('compress_output') && extension_loaded('zlib')); + } + + /** + * Get Output + * + * Returns the current output string. + * + * @return string + */ + public function getOutput(): string { return $this->output; } + /** + * Set Output + * + * Sets the output string. + * + * @param string $output Output data + */ public function setOutput(string $output) { $this->output = $output; } + /** + * Append Output + * + * Appends data onto the output string. + * + * @param string $output Data to append + */ public function appendOutput(string $output) { $this->output .= $output; } - public function setHeader(string $header) + /** + * Display Output + * + * Processes and sends finalized output data to the browser along + * with any server headers. + * + * @param string $output Output data override + * @return void + */ + public function display(string $output = null) { - $this->headers[] = $header; + // Set the output data + $output = is_null($output) ? $this->output : $output; + + // First send status code + http_response_code($this->statusCode); + @header('Status: ' . $this->statusCode . ' ' . $this->statusText, true); + + // If compression is requested, start buffering + if ( + $this->compressOutput && + !is_null($this->input->server('HTTP_ACCEPT_ENCODING')) && + strpos($this->input->server('HTTP_ACCEPT_ENCODING'), 'gzip') !== false + ) + { + Logger::log("Compressing output..."); + ob_start('ob_gzhandler'); + } + + // Send all available headers + if (!empty($this->headers)) + foreach ($this->headers as $header) + @header($header[0], $header[1]); + + echo $output; + + Logger::log('Output sent to browser'); } - public function getHeader() + /** + * Set Header + * + * Lets you set a server header which will be sent with the final output. + * + * @param string $header Header + * @param bool $replace Whether to replace the old header value, if already set + */ + public function setHeader(string $header, bool $replace = true) { - + $this->headers[] = [$header, $replace]; } - public function setContentType() + /** + * Get Header + * + * @param string $headerName + * @return string|null + */ + public function getHeader(string $headerName) { + // Combine sent headers with queued headers + $headers = array_merge( + array_map('array_shift', $this->headers), + headers_list() + ); + if (empty($headers) || empty($headerName)) + return null; + + foreach ($headers as $header) + if (strncasecmp($headerName, $header, $l = strlen($headerName)) === 0) + return trim(substr($header, $l+1)); + + return null; } - public function getContentType() - { + /** + * Set Content-Type Header + * + * @param string $mimeType Extension of the file we're outputting + * @param string $charset Character set (default: NULL) + */ + public function setContentType(string $mimeType, $charset = null) + { + if (strpos($mimeType, '/') === false) + { + $extension = ltrim($mimeType, '.'); + if (isset($this->mimes[$extension])) + { + $mimeType = &$this->mimes[$extension]; + if (is_array($mimeType)) + $mimeType = current($mimeType); + } + } + + $this->mimeType = $mimeType; + if (empty($charset)) + $charset = $this->config->get('charset'); + + $header = 'Content-Type: ' . $mimeType . (empty($charset) ? '' : '; charset='.$charset); + $this->headers[] = [$header, true]; } - public function setStatusHeader() + /** + * Get Current Content-Type Header + * + * @return string 'text/html', if not already set + */ + public function getContentType(): string { + foreach ($this->headers as $header) + if (sscanf($header[0], 'Content-Type: %[^;]', $contentType) === 1) + return $contentType; + return 'text/html'; } - public function cache() + /** + * Set HTTP Status Header + * + * @param int $code + * @param string $text + * @throws OutputException + */ + public function setStatusHeader(int $code = 200, string $text = '') { + $this->statusCode = $code; + if (!empty($text)) + $this->statusText = $text; + else + { + $statusCodes = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Requested Range Not Satisfiable', + 417 => 'Expectation Failed', + 422 => 'Unprocessable Entity', + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported' + ]; + + if (isset($statusCodes[$code])) + $this->statusText = $statusCodes[$code]; + else + throw new OutputException("Could not set status header. Code '" . $code . "' not recognized"); + } } } \ No newline at end of file diff --git a/src/FuzeWorks/Security.php b/src/FuzeWorks/Security.php new file mode 100644 index 0000000..d42036c --- /dev/null +++ b/src/FuzeWorks/Security.php @@ -0,0 +1,1060 @@ +', '<', '>', + "'", '"', '&', '$', '#', + '{', '}', '[', ']', '=', + ';', '?', '%20', '%22', + '%3c', // < + '%253c', // < + '%3e', // > + '%0e', // > + '%28', // ( + '%29', // ) + '%2528', // ( + '%26', // & + '%24', // $ + '%3f', // ? + '%3b', // ; + '%3d' // = + ); + + /** + * Character set + * + * Will be overridden by the constructor. + * + * @var string + */ + public $charset = 'UTF-8'; + + /** + * XSS Hash + * + * Random Hash for protecting URLs. + * + * @var string + */ + protected $_xss_hash; + + /** + * CSRF Hash + * + * Random hash for Cross Site Request Forgery protection cookie + * + * @var string + */ + protected $_csrf_hash; + + /** + * CSRF Expire time + * + * Expiration time for Cross Site Request Forgery protection cookie. + * Defaults to two hours (in seconds). + * + * @var int + */ + protected $_csrf_expire = 7200; + + /** + * CSRF Token name + * + * Token name for Cross Site Request Forgery protection cookie. + * + * @var string + */ + protected $_csrf_token_name = 'fw_csrf_token'; + + /** + * CSRF Cookie name + * + * Cookie name for Cross Site Request Forgery protection cookie. + * + * @var string + */ + protected $_csrf_cookie_name = 'fw_csrf_cookie'; + + /** + * List of never allowed strings + * + * @var array + */ + protected $_never_allowed_str = array( + 'document.cookie' => '[removed]', + 'document.write' => '[removed]', + '.parentNode' => '[removed]', + '.innerHTML' => '[removed]', + '-moz-binding' => '[removed]', + '' => '-->', + ' '<![CDATA[', + '' => '<comment>' + ); + + /** + * List of never allowed regex replacements + * + * @var array + */ + protected $_never_allowed_regex = array( + 'javascript\s*:', + '(document|(document\.)?window)\.(location|on\w*)', + 'expression\s*(\(|&\#40;)', // CSS and IE + 'vbscript\s*:', // IE, surprise! + 'wscript\s*:', // IE + 'jscript\s*:', // IE + 'vbs\s*:', // IE + 'Redirect\s+30\d', + "([\"'])?data\s*:[^\\1]*?base64[^\\1]*?,[^\\1]*?\\1?" + ); + + /** + * Config of this class + * + * @var ConfigORM + */ + private $config; + + /** + * Class constructor + * + * @throws ConfigException + * @return void + */ + public function init() + { + $this->config = Factory::getInstance()->config->get('security'); + + // Is CSRF protection enabled? + if ($this->config->csrf_protection) + { + // CSRF config + foreach (array('csrf_expire', 'csrf_token_name', 'csrf_cookie_name') as $key) + { + if (NULL !== ($val = $this->config->$key)) + { + $this->{'_'.$key} = $val; + } + } + + // Append application specific cookie prefix + if ($cookie_prefix = Factory::getInstance()->config->get('web')->cookie_prefix) + { + $this->_csrf_cookie_name = $cookie_prefix.$this->_csrf_cookie_name; + } + + // Set the CSRF hash + $this->_csrf_set_hash(); + } + + $this->charset = strtoupper(Factory::getInstance()->config->get('web')->charset); + } + + // -------------------------------------------------------------------- + + /** + * CSRF Verify + * + * @return self + */ + public function csrf_verify(): self + { + // If it's not a POST request we will set the CSRF cookie + if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST') + { + return $this->csrf_set_cookie(); + } + + // Check if URI has been whitelisted from CSRF checks + if ($exclude_uris = $this->config->csrf_exclude_uris) + { + foreach ($exclude_uris as $excluded) + { + if (preg_match('#^'.$excluded.'$#i'.(UTF8::$isEnabled ? 'u' : ''), URI::uri_string())) + { + return $this; + } + } + } + + // Do the tokens exist in both the _POST and _COOKIE arrays? + if ( ! isset($_POST[$this->_csrf_token_name], $_COOKIE[$this->_csrf_cookie_name]) + OR $_POST[$this->_csrf_token_name] !== $_COOKIE[$this->_csrf_cookie_name]) // Do the tokens match? + { + $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]); + + // Regenerate on every submission? + if ($this->config->csrf_regenerate) + { + // Nothing should last forever + unset($_COOKIE[$this->_csrf_cookie_name]); + $this->_csrf_hash = NULL; + } + + $this->_csrf_set_hash(); + $this->csrf_set_cookie(); + + Logger::log('CSRF token verified'); + return $this; + } + + // -------------------------------------------------------------------- + + /** + * CSRF Set Cookie + * + * @codeCoverageIgnore + * @throws ConfigException + */ + public function csrf_set_cookie() + { + $expire = time() + $this->_csrf_expire; + $cfg = Factory::getInstance()->config->get('main'); + $secure_cookie = (bool) $cfg->cookie_secure; + + if ($secure_cookie && ! Core::isHttps()) + { + return $this; + } + + setcookie( + $this->_csrf_cookie_name, + $this->_csrf_hash, + $expire, + $cfg->cookie_path, + $cfg->cookie_domain, + $secure_cookie, + $cfg->cookie_httponly + ); + Logger::log('CSRF cookie sent'); + } + + // -------------------------------------------------------------------- + + /** + * Show CSRF Error + * + * @return void + * @throws SecurityException + */ + public function csrf_show_error() + { + throw new SecurityException('The action you have requested is not allowed.', 1); + } + + // -------------------------------------------------------------------- + + /** + * Get CSRF Hash + * + * @see Security::$_csrf_hash + * @return string CSRF hash + */ + public function get_csrf_hash(): string + { + return $this->_csrf_hash; + } + + // -------------------------------------------------------------------- + + /** + * Get CSRF Token Name + * + * @see Security::$_csrf_token_name + * @return string CSRF token name + */ + public function get_csrf_token_name(): string + { + return $this->_csrf_token_name; + } + + // -------------------------------------------------------------------- + + /** + * XSS Clean + * + * Sanitizes data so that Cross Site Scripting Hacks can be + * prevented. This method 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: Should only be used to deal with data upon submission. + * It's not something that should be used for general + * runtime processing. + * + * @link http://channel.bitflux.ch/wiki/XSS_Prevention + * Based in part on some code and ideas from Bitflux. + * + * @link http://ha.ckers.org/xss.html + * 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. + * + * @param string|string[] $str Input data + * @param bool $is_image Whether the input is an image + * @return string|array + */ + 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 = UTF8::removeInvisibleCharacters($str); + + /* + * URL Decode + * + * Just in case stuff like this is submitted: + * + * Google + * + * Note: Use rawurldecode() so it does not remove plus signs + */ + do + { + $str = rawurldecode($str); + } + while (preg_match('/%[0-9a-f]{2,}/i', $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-z0-9>]+[a-z0-9]+=([\'\"]).*?\\1/si", array('FuzeWorks\Security', '_convert_attribute'), $str); + $str = preg_replace_callback('/<\w+.*/si', array('FuzeWorks\Security', '_decode_entity'), $str); + + // Remove Invisible Characters Again! + $str = UTF8::removeInvisibleCharacters($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. + */ + $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', 'jscript', 'wscript', + 'vbs', 'script', 'base64', 'applet', 'alert', 'document', + 'write', 'cookie', 'window', 'confirm', 'prompt', 'eval' + ); + + foreach ($words as $word) + { + $word = implode('\s*', str_split($word)).'\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($word, 0, -3).')(\W)#is', array('FuzeWorks\Security', '_compact_exploded_words'), $str); + } + + /* + * Remove disallowed Javascript in links or img tags + * We used to do some version comparisons and use of stripos(), + * but it is dog slow compared to these simplified non-capturing + * preg_match(), especially if the pattern exists in the string + * + * Note: It was reported that not only space characters, but all in + * the following pattern can be parsed as separators between a tag name + * and its attributes: [\d\s"\'`;,\/\=\(\x00\x0B\x09\x0C] + * ... however, remove_invisible_characters() above already strips the + * hex-encoded ones, so we'll skip them below. + */ + do + { + $original = $str; + + if (preg_match('/]+([^>]*?)(?:>|$)#si', array('FuzeWorks\Security', '_js_link_removal'), $str); + } + + if (preg_match('/]*?)(?:\s?/?>|$)#si', array('FuzeWorks\Security', '_js_img_removal'), $str); + } + + if (preg_match('/script|xss/i', $str)) + { + $str = preg_replace('##si', '[removed]', $str); + } + } + while ($original !== $str); + unset($original); + + /* + * 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> + */ + $pattern = '#' + .'<((?/*\s*)(?[a-z0-9]+)(?=[^a-z0-9]|$)' // tag start and name, followed by a non-tag character + .'[^\s\042\047a-z0-9>/=]*' // a valid attribute character immediately after the tag would count as a separator + // optional attributes + .'(?(?:[\s\042\047/=]*' // non-attribute characters, excluding > (tag close) for obvious reasons + .'[^\s\042\047>/=]+' // attribute characters + // optional attribute-value + .'(?:\s*=' // attribute-value separator + .'(?:[^\s\042\047=><`]+|\s*\042[^\042]*\042|\s*\047[^\047]*\047|\s*(?U:[^\s\042\047=><`]*))' // single, double or non-quoted value + .')?' // end optional attribute-value group + .')*)' // end optional attributes group + .'[^>]*)(?\>)?#isS'; + + // Note: It would be nice to optimize this for speed, BUT + // only matching the naughty elements here results in + // false positives and in turn - vulnerabilities! + do + { + $old_str = $str; + $str = preg_replace_callback($pattern, array('FuzeWorks\Security', '_sanitize_naughty_html'), $str); + } + while ($old_str !== $str); + unset($old_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|prompt|confirm|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); + } + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * XSS Hash + * + * Generates the XSS hash if needed and returns it. + * + * @see Security::$_xss_hash + * @return string XSS hash + */ + public function xss_hash(): string + { + if ($this->_xss_hash === NULL) + { + $rand = $this->get_random_bytes(16); + $this->_xss_hash = ($rand === FALSE) + ? md5(uniqid(mt_rand(), TRUE)) + : bin2hex($rand); + } + + return $this->_xss_hash; + } + + // -------------------------------------------------------------------- + + /** + * Get random bytes + * + * @param int $length Output length + * @return string + */ + public function get_random_bytes($length): string + { + if (empty($length) OR ! ctype_digit((string) $length)) + { + return FALSE; + } + + if (function_exists('random_bytes')) + { + try + { + // The cast is required to avoid TypeError + return random_bytes((int) $length); + } + catch (\Exception $e) + { + // If random_bytes() can't do the job, we can't either ... + // There's no point in using fallbacks. + Logger::logError($e->getMessage()); + return FALSE; + } + } + + // Unfortunately, none of the following PRNGs is guaranteed to exist ... + if (defined('MCRYPT_DEV_URANDOM') && ($output = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM)) !== FALSE) + { + return $output; + } + + if (is_readable('/dev/urandom') && ($fp = fopen('/dev/urandom', 'rb')) !== FALSE) + { + // Try not to waste entropy ... + Core::isPHP('5.4') && stream_set_chunk_size($fp, $length); + $output = fread($fp, $length); + fclose($fp); + if ($output !== FALSE) + { + return $output; + } + } + + if (function_exists('openssl_random_pseudo_bytes')) + { + return openssl_random_pseudo_bytes($length); + } + + return FALSE; + } + + // -------------------------------------------------------------------- + + /** + * HTML Entities Decode + * + * A replacement for html_entity_decode() + * + * 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. + * + * @link http://php.net/html-entity-decode + * + * @param string $str Input + * @param string $charset Character set + * @return string + */ + public function entity_decode($str, $charset = NULL): string + { + if (strpos($str, '&') === FALSE) + { + return $str; + } + + static $_entities; + + isset($charset) OR $charset = $this->charset; + $flag = Core::isPHP('5.4') + ? ENT_COMPAT | ENT_HTML5 + : ENT_COMPAT; + + do + { + $str_compare = $str; + + // Decode standard entities, avoiding false positives + if (preg_match_all('/&[a-z]{2,}(?![a-z;])/i', $str, $matches)) + { + if ( ! isset($_entities)) + { + $_entities = array_map( + 'strtolower', + Core::isPHP('5.3.4') + ? get_html_translation_table(HTML_ENTITIES, $flag, $charset) + : get_html_translation_table(HTML_ENTITIES, $flag) + ); + + // If we're not on PHP 5.4+, add the possibly dangerous HTML 5 + // entities to the array manually + if ($flag === ENT_COMPAT) + { + $_entities[':'] = ':'; + $_entities['('] = '('; + $_entities[')'] = ')'; + $_entities["\n"] = '&newline;'; + $_entities["\t"] = '&tab;'; + } + } + + $replace = array(); + $matches = array_unique(array_map('strtolower', $matches[0])); + foreach ($matches as &$match) + { + if (($char = array_search($match.';', $_entities, TRUE)) !== FALSE) + { + $replace[$match] = $char; + } + } + + $str = str_ireplace(array_keys($replace), array_values($replace), $str); + } + + // Decode numeric & UTF16 two byte entities + $str = html_entity_decode( + preg_replace('/(&#(?:x0*[0-9a-f]{2,5}(?![0-9a-f;])|(?:0*\d{2,4}(?![0-9;]))))/iS', '$1;', $str), + $flag, + $charset + ); + } + while ($str_compare !== $str); + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Sanitize Filename + * + * @param string $str Input file name + * @param bool $relative_path Whether to preserve paths + * @return string + */ + public function sanitize_filename($str, $relative_path = FALSE): string + { + $bad = $this->filename_bad_chars; + + if ( ! $relative_path) + { + $bad[] = './'; + $bad[] = '/'; + } + + $str = UTF8::removeInvisibleCharacters($str, FALSE); + + do + { + $old = $str; + $str = str_replace($bad, '', $str); + } + while ($old !== $str); + + return stripslashes($str); + } + + // ---------------------------------------------------------------- + + /** + * Strip Image Tags + * + * @param string $str + * @return string + */ + public function strip_image_tags($str): string + { + return preg_replace( + array( + '##i', + '#`]+)).*?\>#i' + ), + '\\2', + $str + ); + } + + // ---------------------------------------------------------------- + + /** + * Compact Exploded Words + * + * Callback method for xss_clean() to remove whitespace from + * things like 'j a v a s c r i p t'. + * + * @used-by Security::xss_clean() + * @param array $matches + * @return string + */ + protected function _compact_exploded_words($matches): string + { + return preg_replace('/\s+/s', '', $matches[1]).$matches[2]; + } + + // -------------------------------------------------------------------- + + /** + * Sanitize Naughty HTML + * + * Callback method for xss_clean() to remove naughty HTML elements. + * + * @used-by Security::xss_clean() + * @param array $matches + * @return string + */ + protected function _sanitize_naughty_html($matches): string + { + static $naughty_tags = array( + 'alert', 'prompt', 'confirm', 'applet', 'audio', 'basefont', 'base', 'behavior', 'bgsound', + 'blink', 'body', 'embed', 'expression', 'form', 'frameset', 'frame', 'head', 'html', 'ilayer', + 'iframe', 'input', 'button', 'select', 'isindex', 'layer', 'link', 'meta', 'keygen', 'object', + 'plaintext', 'style', 'script', 'textarea', 'title', 'math', 'video', 'svg', 'xml', 'xss' + ); + + static $evil_attributes = array( + 'on\w+', 'style', 'xmlns', 'formaction', 'form', 'xlink:href', 'FSCommand', 'seekSegmentTime' + ); + + // First, escape unclosed tags + if (empty($matches['closeTag'])) + { + return '<'.$matches[1]; + } + // Is the element that we caught naughty? If so, escape it + elseif (in_array(strtolower($matches['tagName']), $naughty_tags, TRUE)) + { + return '<'.$matches[1].'>'; + } + // For other tags, see if their attributes are "evil" and strip those + elseif (isset($matches['attributes'])) + { + // We'll store the already fitlered attributes here + $attributes = array(); + + // Attribute-catching pattern + $attributes_pattern = '#' + .'(?[^\s\042\047>/=]+)' // attribute characters + // optional attribute-value + .'(?:\s*=(?[^\s\042\047=><`]+|\s*\042[^\042]*\042|\s*\047[^\047]*\047|\s*(?U:[^\s\042\047=><`]*)))' // attribute-value separator + .'#i'; + + // Blacklist pattern for evil attribute names + $is_evil_pattern = '#^('.implode('|', $evil_attributes).')$#i'; + + // Each iteration filters a single attribute + do + { + // Strip any non-alpha characters that may preceed an attribute. + // Browsers often parse these incorrectly and that has been a + // of numerous XSS issues we've had. + $matches['attributes'] = preg_replace('#^[^a-z]+#i', '', $matches['attributes']); + + if ( ! preg_match($attributes_pattern, $matches['attributes'], $attribute, PREG_OFFSET_CAPTURE)) + { + // No (valid) attribute found? Discard everything else inside the tag + break; + } + + if ( + // Is it indeed an "evil" attribute? + preg_match($is_evil_pattern, $attribute['name'][0]) + // Or does it have an equals sign, but no value and not quoted? Strip that too! + OR (trim($attribute['value'][0]) === '') + ) + { + $attributes[] = 'xss=removed'; + } + else + { + $attributes[] = $attribute[0][0]; + } + + $matches['attributes'] = substr($matches['attributes'], $attribute[0][1] + strlen($attribute[0][0])); + } + while ($matches['attributes'] !== ''); + + $attributes = empty($attributes) + ? '' + : ' '.implode(' ', $attributes); + return '<'.$matches['slash'].$matches['tagName'].$attributes.'>'; + } + + return $matches[0]; + } + + // -------------------------------------------------------------------- + + /** + * JS Link Removal + * + * Callback method 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. + * + * @used-by Security::xss_clean() + * @param array $match + * @return string + */ + protected function _js_link_removal($match): string + { + return str_replace( + $match[1], + preg_replace( + '#href=.*?(?:(?:alert|prompt|confirm)(?:\(|&\#40;)|javascript:|livescript:|mocha:|charset=|window\.|document\.|\.cookie|_filter_attributes($match[1]) + ), + $match[0] + ); + } + + // -------------------------------------------------------------------- + + /** + * JS Image Removal + * + * Callback method for xss_clean() to sanitize image tags. + * + * This limits the PCRE backtracks, making it more performance friendly + * and prevents PREG_BACKTRACK_LIMIT_ERROR from being triggered in + * PHP 5.2+ on image tag heavy strings. + * + * @used-by Security::xss_clean() + * @param array $match + * @return string + */ + protected function _js_img_removal($match): string + { + return str_replace( + $match[1], + preg_replace( + '#src=.*?(?:(?:alert|prompt|confirm|eval)(?:\(|&\#40;)|javascript:|livescript:|mocha:|charset=|window\.|document\.|\.cookie|_filter_attributes($match[1]) + ), + $match[0] + ); + } + + // -------------------------------------------------------------------- + + /** + * Attribute Conversion + * + * @used-by Security::xss_clean() + * @param array $match + * @return string + */ + protected function _convert_attribute($match): string + { + return str_replace(array('>', '<', '\\'), array('>', '<', '\\\\'), $match[0]); + } + + // -------------------------------------------------------------------- + + /** + * Filter Attributes + * + * Filters tag attributes for consistency and safety. + * + * @used-by Security::_js_img_removal() + * @used-by Security::_js_link_removal() + * @param string $str + * @return string + */ + protected function _filter_attributes($str): string + { + $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-by Security::xss_clean() + * @param array $match + * @return string + */ + protected function _decode_entity($match): string + { + // Protect GET variables in URLs + // 901119URL5918AMP18930PROTECT8198 + $match = preg_replace('|\&([a-z\_0-9\-]+)\=([a-z\_0-9\-/]+)|i', $this->xss_hash().'\\1=\\2', $match[0]); + + // Decode, then un-protect URL GET vars + return str_replace( + $this->xss_hash(), + '&', + $this->entity_decode($match, $this->charset) + ); + } + + // -------------------------------------------------------------------- + + /** + * Do Never Allowed + * + * @used-by Security::xss_clean() + * @param string + * @return string + */ + protected function _do_never_allowed($str): string + { + $str = str_replace(array_keys($this->_never_allowed_str), $this->_never_allowed_str, $str); + + foreach ($this->_never_allowed_regex as $regex) + { + $str = preg_replace('#'.$regex.'#is', '[removed]', $str); + } + + return $str; + } + + // -------------------------------------------------------------------- + + /** + * Set CSRF Hash and Cookie + * + * @return string + */ + protected function _csrf_set_hash(): string + { + if ($this->_csrf_hash === NULL) + { + // If the cookie exists we will use its 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]) && is_string($_COOKIE[$this->_csrf_cookie_name]) + && preg_match('#^[0-9a-f]{32}$#iS', $_COOKIE[$this->_csrf_cookie_name]) === 1) + { + return $this->_csrf_hash = $_COOKIE[$this->_csrf_cookie_name]; + } + + $rand = $this->get_random_bytes(16); + $this->_csrf_hash = ($rand === FALSE) + ? md5(uniqid(mt_rand(), TRUE)) + : bin2hex($rand); + } + + Logger::logInfo("CSRF-Protection has been enabled"); + return $this->_csrf_hash; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/URI.php b/src/FuzeWorks/URI.php index 08e3996..1763575 100644 --- a/src/FuzeWorks/URI.php +++ b/src/FuzeWorks/URI.php @@ -36,8 +36,219 @@ namespace FuzeWorks; +use FuzeWorks\ConfigORM\ConfigORM; +use FuzeWorks\Exception\UriException; +/** + * Class URI + * + * @todo Add to assoc methods + */ class URI { + /** + * ConfigORM of the Web config file + * + * @var ConfigORM + */ + private $config; + + /** + * @var Input + */ + private $input; + + protected $baseUri; + protected $uriString; + protected $segments; + + public function init() + { + $this->input = Factory::getInstance()->input; + $this->config = Factory::getInstance()->config->getConfig('web'); + if (WebComponent::$willHandleRequest) + { + $this->determineUri(); + } + } + + public function determineUri() + { + // If no base_url is provided, attempt to determine URI with SERVER variables + if (empty($this->config->get('base_url'))) + { + $serverAddr = $this->input->server('SERVER_ADDR'); + if (!is_null($serverAddr)) + { + if (strpos($serverAddr, ':') !== false) + $serverAddr = '['.$serverAddr.']'; + + $scriptName = $this->input->server('SCRIPT_NAME'); + $scriptFilename = $this->input->server('SCRIPT_FILENAME'); + $baseUrl = ($this->isHttps() ? 'https' : 'http') . + "://" . $serverAddr . + substr($scriptName, 0, strpos($scriptName, basename($scriptFilename))); + } + else + $baseUrl = 'http://localhost/'; + + $this->config->set('base_url', $baseUrl); + } + + // Set the baseUri + $this->baseUri = $this->config->get('base_url'); + $subUri = $this->parseUri(); + + // Log the incoming request + Logger::newLevel("Request received with the following URL: "); + Logger::logInfo("Base URL: " . $this->baseUri); + Logger::logInfo("Request URL: ". $subUri); + Logger::stopLevel(); + + $this->setUri($this->parseUri()); + return true; + } + + public function uriString(): string + { + return $this->uriString; + } + + /** + * Fetch URI Segment + * + * @param int $n index + * @return string|null + */ + public function segment(int $n) + { + return isset($this->segments[$n]) ? $this->segments[$n] : null; + } + + /** + * Segment Array + * + * @return array + */ + public function segmentArray(): array + { + return $this->segments; + } + + protected function parseUri(): string + { + // If no vars are provided, return an empty string + $vars = $this->input->server(['REQUEST_URI', 'SCRIPT_NAME', 'QUERY_STRING']); + if (is_null($vars['REQUEST_URI']) || is_null($vars['SCRIPT_NAME'])) + return ''; + + // Get a basic URL from parse_url + $uri = parse_url('http://dummy'.$vars['REQUEST_URI']); + $uri = isset($uri['path']) ? $uri['path'] : ''; + + // Determine the script + if (isset($vars['SCRIPT_NAME'][0])) + { + if (strpos($uri, $vars['SCRIPT_NAME']) === 0) + $uri = (string) substr($uri, strlen($vars['SCRIPT_NAME'])); + elseif (strpos($uri, dirname($vars['SCRIPT_NAME'])) === 0) + $uri = (string) substr($uri, strlen(dirname($vars['SCRIPT_NAME']))); + + } + + // If empty, return empty + if ($uri === '/' || $uri === '') + return '/'; + + // Remove the relative directory + $uris = []; + $tok = strtok($uri, '/'); + while ($tok !== false) + { + if ( (!empty($tok) || $tok === '0') && $tok !== '..') + $uris[] = $tok; + + $tok = strtok('/'); + } + + return implode('/', $uris); + } + + /** + * @param $str + * @throws UriException + */ + protected function setUri($str) + { + // First clean the string + $uri = $this->uriString = trim(UTF8::removeInvisibleCharacters($str, false), '/'); + if ($this->uriString === '') + return; + + // Determine the segments + $this->segments[0] = null; + foreach (explode('/', trim($uri, '/')) as $segment) + { + // Filter segments for security + $segment = trim($segment); + $this->filterUri($segment); + + if ($segment !== '') + $this->segments[] = $segment; + } + + unset($this->segments[0]); + } + + /** + * Filter URI + * + * Filters segments for malicious characters. + * + * @param string $str + * @return bool + * @throws UriException + */ + protected function filterUri(string &$str): bool + { + $permitted = $this->config->get('permitted_uri_chars'); + if ( + !empty($str) && + !empty($permitted) && + !preg_match('/^['.$permitted.']+$/i'.(UTF8::$isEnabled ? 'u' : ''), $str)) + { + throw new UriException("The submitted URI has illegal characters."); + } + + return true; + } + + /** + * Is HTTPS? + * + * Determines if the application is accessed via an encrypted + * (HTTPS) connection. + * + * @return bool + */ + protected function isHttps(): bool + { + $https = $this->input->server('HTTPS'); + if (!is_null($https) && strtolower($https) !== 'off') + { + return true; + } + elseif (!is_null($this->input->server('HTTP_X_FORWARDED_PROTO')) && $this->input->server('HTTP_X_FORWARDED_PROTO') === 'https') + { + return true; + } + elseif ( ! is_null($this->input->server('HTTP_FRONT_END_HTTPS')) && strtolower($this->input->server('HTTP_FRONT_END_HTTPS')) !== 'off') + { + return true; + } + + return false; + } + } \ No newline at end of file diff --git a/src/FuzeWorks/UTF8.php b/src/FuzeWorks/UTF8.php new file mode 100644 index 0000000..1392e72 --- /dev/null +++ b/src/FuzeWorks/UTF8.php @@ -0,0 +1,178 @@ +config->getConfig('web')->get('charset')); + } catch (Exception\ConfigException $e) { + $charset = 'UTF-8'; + } + ini_set('default_charset', $charset); + + // Enable mbstring if it is provided + if (extension_loaded('mbstring')) + { + define('MBEnabled', true); + mb_internal_encoding($charset); + mb_substitute_character('none'); + } + else + define('MBEnabled', false); + + // Enable iconv if it is provided + if (extension_loaded('iconv')) + { + define('ICONVEnabled', true); + ini_set('default_encoding', $charset); + } + else + define('ICONVEnabled', false); + + // Set some global values + ini_set('php.internal_encoding', $charset); + if (defined('PREG_BAD_UTF8_ERROR') + && (ICONVEnabled || MBEnabled) + && $charset === 'UTF-8') + { + self::$isEnabled = true; + Logger::logInfo('UTF-8 support has been enabled'); + } + else + { + self::$isEnabled = false; + Logger::logInfo('UTF-8 support has not been enabled'); + } + } + + /** + * Clean UTF-8 strings + * + * Ensures strings contain only valid UTF-8 characters. + * + * @param string $str String to clean + * @return string + */ + public static function cleanString(string $str): string + { + if (self::isAscii($str) === false) + { + if (MBEnabled) + $str = mb_convert_encoding($str, 'UTF-8', 'UTF-8'); + elseif (ICONVEnabled) + $str = @iconv('UTF-8', 'UTF-8//IGNORE', $str); + } + + return $str; + } + + /** + * Convert to UTF-8 + * + * Attempts to convert a string to UTF-8. + * + * @param string $str + * @param string $encoding + * @return bool|string + */ + public static function convertToUtf8(string $str, string $encoding) + { + if (MBEnabled) + return mb_convert_encoding($str, 'UTF-8', $encoding); + elseif (ICONVEnabled) + return @iconv($encoding, 'UTF-8', $str); + + return false; + } + + /** + * Is ASCII? + * + * Tests if a string is standard 7-bit ASCII or not. + * + * @param string $str String to check + * @return bool + */ + public static function isAscii(string $str): bool + { + return (preg_match('/[^\x00-\x7F]/S', $str) === 0); + } + + /** + * Remove Invisible Characters + * + * This prevents sandwiching null characters + * between ascii characters, like Java\0script. + * + * @param string + * @param bool + * @return string + */ + + public static function removeInvisibleCharacters($str, $urlEncoded = true): string + { + // First determine which characters are invisible + if ($urlEncoded) + $nonDisplayable = ['/%0[0-8bcef]/', '/%1[0-9a-f]/']; + else + $nonDisplayable = []; + + $nonDisplayable[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S'; + + do + { + $str = preg_replace($nonDisplayable, '', $str, -1, $count); + } + while($count); + + return $str; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/WebComponent.php b/src/FuzeWorks/WebComponent.php index 31a9649..451ec8e 100644 --- a/src/FuzeWorks/WebComponent.php +++ b/src/FuzeWorks/WebComponent.php @@ -36,10 +36,21 @@ namespace FuzeWorks; +use FuzeWorks\Exception\EventException; +use FuzeWorks\Exception\Exception; +use FuzeWorks\Exception\NotFoundException; +use FuzeWorks\Exception\WebException; class WebComponent implements iComponent { + /** + * Whether WebComponent is configured to handle a web request + * + * @var bool + */ + public static $willHandleRequest = false; + public function getName(): string { return "WebComponent"; @@ -49,8 +60,10 @@ class WebComponent implements iComponent { return [ 'web' => $this, + 'security' => '\FuzeWorks\Security', 'input' => '\FuzeWorks\Input', - 'output' => '\FuzeWorks\Output' + 'output' => '\FuzeWorks\Output', + 'uri' => '\FuzeWorks\URI', ]; } @@ -63,18 +76,103 @@ class WebComponent implements iComponent // Add dependencies $configurator->addComponent(new MVCRComponent()); - // Invoke methods to prepare system for HTTP calls - $configurator->call('logger', 'setLoggerTemplate', null, 'logger_http'); - // Add fallback config directory $configurator->addDirectory( dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Config', 'config', Priority::LOWEST ); + + // If WebComponent will handle a request, add some calls to the configurator + if (self::$willHandleRequest) + { + // Invoke methods to prepare system for HTTP calls + $configurator->call('logger', 'setLoggerTemplate', null, 'logger_http'); + } } public function onCreateContainer(Factory $container) { } + + public function init() + { + // First init UTF8 + UTF8::init(); + } + + public function enableComponent() + { + self::$willHandleRequest = true; + } + + public function disableComponent() + { + self::$willHandleRequest = false; + } + + /** + * @throws WebException + */ + public function routeWebRequest(): bool + { + if (!self::$willHandleRequest) + throw new WebException("Could not route web request. WebComponent is not configured to handle requests"); + + // Set the output to display when shutting down + try { + Events::addListener(function () { + /** @var Output $output */ + Logger::logInfo("Parsing output..."); + $output = Factory::getInstance()->output; + $output->display(); + }, 'coreShutdownEvent', Priority::NORMAL); + } catch (EventException $e) { + throw new WebException("Could not route web request. coreShutdownEvent threw EventException: '".$e->getMessage()."'"); + } + + /** @var Router $router */ + $router = Factory::getInstance()->router; + + /** @var URI $uriObject */ + $uriObject = Factory::getInstance()->uri; + $uri = $uriObject->uriString(); + + /** @var Output $output */ + $output = Factory::getInstance()->output; + + // Attempt to load the requested page + try { + $viewOutput = $router->route($uri); + } catch (NotFoundException $e) { + Logger::logWarning("Requested page not found. Requesting Error/error404 View"); + $output->setStatusHeader(404); + + // Request 404 page= + try { + $viewOutput = $router->route('Error/error404'); + } catch (NotFoundException $e) { + // If still resulting in an error, do something else + $viewOutput = 'ERROR 404. Page was not found.'; + } catch (Exception $e) { + Logger::exceptionHandler($e, false); + $viewOutput = 'ERROR 404. Page was not found.'; + } + } catch (Exception $e) { + Logger::exceptionHandler($e, false); + $output->setStatusHeader(500); + try { + $viewOutput = $router->route('Error/error500'); + } catch (Exception $error500Exception) { + Logger::exceptionHandler($error500Exception, false); + $viewOutput = 'ERROR 500. Page could not be loaded.'; + } + } + + // Append the output + if (!empty($viewOutput)) + $output->appendOutput($viewOutput); + + return true; + } } \ No newline at end of file diff --git a/src/FuzeWorks/WebController.php b/src/FuzeWorks/WebController.php new file mode 100644 index 0000000..bc0dbec --- /dev/null +++ b/src/FuzeWorks/WebController.php @@ -0,0 +1,68 @@ +input = Factory::getInstance()->input; + $this->output = Factory::getInstance()->output; + $this->uri = Factory::getInstance()->uri; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/WebModel.php b/src/FuzeWorks/WebModel.php new file mode 100644 index 0000000..92207e5 --- /dev/null +++ b/src/FuzeWorks/WebModel.php @@ -0,0 +1,66 @@ +input = Factory::getInstance()->input; + $this->output = Factory::getInstance()->output; + $this->uri = Factory::getInstance()->uri; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/WebView.php b/src/FuzeWorks/WebView.php new file mode 100644 index 0000000..02fc2fd --- /dev/null +++ b/src/FuzeWorks/WebView.php @@ -0,0 +1,66 @@ +input = Factory::getInstance()->input; + $this->output = Factory::getInstance()->output; + $this->uri = Factory::getInstance()->uri; + } +} \ No newline at end of file