diff --git a/composer.json b/composer.json index d630e82..387cf9d 100644 --- a/composer.json +++ b/composer.json @@ -14,17 +14,15 @@ ], "require": { "php": ">=7.1.0", - "fuzeworks/core": "dev-development", - "fuzeworks/mvcr": "dev-master" + "fuzeworks/mvcr": "~1.2.0", + "fuzeworks/core": "~1.2.0" }, "require-dev": { - "phpunit/phpunit": "^7", - "fuzeworks/tracycomponent": "dev-master" + "phpunit/phpunit": "^7" }, "autoload": { "psr-4": { "FuzeWorks\\": "src/FuzeWorks/" } } - } \ No newline at end of file diff --git a/src/Config/config.security.php b/src/Config/config.security.php index 891cda4..346fcbc 100644 --- a/src/Config/config.security.php +++ b/src/Config/config.security.php @@ -49,56 +49,16 @@ return [ | 'csrf_regenerate' = Regenerate token on every submission | 'csrf_exclude_uris' = Array of URIs which ignore CSRF checks */ - 'csrf_protection' => false, + 'csrf_protection' => true, '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' => '' + // CSRF Cookie information + 'csrf_cookie_name' => 'fw_csrf_cookie', + 'csrf_cookie_prefix' => '', + 'csrf_cookie_domain' => '', + 'csrf_cookie_path' => '/', + 'csrf_cookie_secure' => false, + 'csrf_cookie_httponly' => false ]; \ No newline at end of file diff --git a/src/FuzeWorks/Event/ResourceServeEvent.php b/src/FuzeWorks/Event/ResourceServeEvent.php new file mode 100644 index 0000000..109dc51 --- /dev/null +++ b/src/FuzeWorks/Event/ResourceServeEvent.php @@ -0,0 +1,63 @@ +resourceName = $resourceName; + $this->resourceUrlSegments = $resourceUrlSegments; + $this->resourceFilePath = $resourceFilePath; + } +} \ No newline at end of file diff --git a/src/FuzeWorks/Event/RouteWebRequestEvent.php b/src/FuzeWorks/Event/RouteWebRequestEvent.php new file mode 100644 index 0000000..e99171a --- /dev/null +++ b/src/FuzeWorks/Event/RouteWebRequestEvent.php @@ -0,0 +1,53 @@ +uriString = $uriString; + } + +} \ No newline at end of file diff --git a/src/FuzeWorks/Exception/CSRFException.php b/src/FuzeWorks/Exception/CSRFException.php new file mode 100644 index 0000000..9ffbb13 --- /dev/null +++ b/src/FuzeWorks/Exception/CSRFException.php @@ -0,0 +1,42 @@ +webConfig = Factory::getInstance()->config->getConfig('web'); + // If not handling requests, do not continue + if (!WebComponent::$willHandleRequest) + return; + + // Start session + session_start(); + // Sanitize all global arrays $this->sanitizeGlobals(); @@ -76,8 +85,8 @@ class Input { if (class_exists('\FuzeWorks\TracyComponent', true) && \FuzeWorks\TracyComponent::isEnabled()) { - set_exception_handler([$this, 'tracyExceptionHandler']); - set_error_handler([$this, 'tracyErrorHandler']); + Core::addExceptionHandler([$this, 'restoreGlobalArrays'], Priority::HIGHEST); + Core::addErrorHandler([$this, 'restoreGlobalArrays'], Priority::HIGHEST); } Events::addListener( [$this, 'restoreGlobalArrays'], @@ -87,35 +96,10 @@ class Input } /** - * Used to restore global arrays before handling errors by Tracy + * Restores global arrays before handling by processes outside of FuzeWorks * - * @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'); @@ -235,6 +219,8 @@ class Input } /** + * Used to fetch variables from the global arrays + * * @param string $arrayName * @param null $index * @param bool $xssClean @@ -242,8 +228,8 @@ class Input */ protected function getFromInputArray(string $arrayName, $index = null, bool $xssClean = true) { - // Clean XSS if requested manually or forced through configuration - $xssClean = $xssClean || $this->webConfig->get('xss_clean'); + // Never run XSS clean if disabled by config + $xssClean = ($this->webConfig->get('xss_clean') == true ? $xssClean : false); // If the index is null, the entire array is requested $index = (!is_null($index) ? $index : array_keys($this->inputArray[$arrayName])); @@ -266,59 +252,124 @@ class Input return ($xssClean === true ? $this->security->xss_clean($value) : $value); } + /** + * Fetch variables from the global $_GET array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function get($index = null, bool $xssClean = true) { return $this->getFromInputArray('get', $index, $xssClean); } + /** + * Fetch variables from the global $_POST array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function post($index = null, bool $xssClean = true) { return $this->getFromInputArray('post', $index, $xssClean); } + /** + * Fetch variables from the global $_POST or $_GET array. Tries POST first + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function postGet($index, bool $xssClean = true) { return isset($this->inputArray['post'][$index]) ? $this->post($index, $xssClean) : $this->get($index, $xssClean); } + /** + * Fetch variables from the global $_GET or $_POST array. Tries GET first + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function getPost($index, bool $xssClean = true) { return isset($this->inputArray['get'][$index]) ? $this->get($index, $xssClean) : $this->post($index, $xssClean); } + /** + * Fetch variables from the global $_COOKIE array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function cookie($index = null, bool $xssClean = true) { return $this->getFromInputArray('cookie', $index, $xssClean); } + /** + * Fetch variables from the global $_SERVER array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function server($index = null, bool $xssClean = true) { return $this->getFromInputArray('server', $index, $xssClean); } /** - * @todo Extend with OldInput functionality + * Fetch the HTTP_USER_AGENT variable from the $_SERVER array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed */ - public function ip() - { - $ip = ''; - // Validate IP - - $valid = ( - (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || - (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) - ); - } - public function userAgent(bool $xssClean = true): string { return $this->getFromInputArray('server', 'HTTP_USER_AGENT', $xssClean); } + /** + * Fetch the REQUEST_METHOD variable from the $_SERVER array + * + * @param string|array|null $index + * @param bool $xssClean + * @return mixed + */ public function method(bool $xssClean = true): string { return $this->getFromInputArray('server', 'REQUEST_METHOD', $xssClean); } + /** + * Is HTTPS? + * + * Determines if the application is accessed via an encrypted + * (HTTPS) connection. + * + * @return bool + */ + public function isHttps(): bool + { + if (!empty($this->inputArray['server']['HTTPS']) && strtolower($this->inputArray['server']['HTTPS']) !== 'off') + return true; + + elseif (isset($this->inputArray['server']['HTTP_X_FORWARDED_PROTO']) && $this->inputArray['server']['HTTP_X_FORWARDED_PROTO'] === 'https') + return true; + + elseif ( ! empty($this->inputArray['server']['HTTP_FRONT_END_HTTPS']) && strtolower($this->inputArray['server']['HTTP_FRONT_END_HTTPS']) !== 'off') + return true; + + return false; + } + + } \ No newline at end of file diff --git a/src/FuzeWorks/Output.php b/src/FuzeWorks/Output.php index 16797c9..a623f14 100644 --- a/src/FuzeWorks/Output.php +++ b/src/FuzeWorks/Output.php @@ -38,9 +38,11 @@ namespace FuzeWorks; use FuzeWorks\ConfigORM\ConfigORM; -use FuzeWorks\Event\HelperLoadEvent; use FuzeWorks\Exception\OutputException; +/** + * @todo Implement caching + */ class Output { @@ -51,6 +53,13 @@ class Output */ private $input; + /** + * The internal URI class + * + * @var URI + */ + private $uri; + /** * WebCfg * @@ -82,12 +91,38 @@ class Output public $mimes = []; protected $mimeType = 'text/html'; + /** + * The amount of time the current page is cached + * + * @var int $cacheTime + */ + protected $cacheTime = 0; + + /** + * Whether a cache file is being used now + * + * @var bool + */ + protected $usingCache = false; + + /** + * The status code that will be sent to the client + * + * @var int $statusCode + */ protected $statusCode = 200; + + /** + * The status code text that will be sent along with $statusCode + * + * @var string $statusText + */ protected $statusText = 'OK'; public function init() { $this->input = Factory::getInstance()->input; + $this->uri = Factory::getInstance()->uri; $this->mimes = Factory::getInstance()->config->getConfig('mimes')->toArray(); $this->config = Factory::getInstance()->config->getConfig('web'); @@ -95,6 +130,200 @@ class Output $this->compressOutput = (!$zlib && $this->config->get('compress_output') && extension_loaded('zlib')); } + /** + * 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) + { + // Set the output data + $output = is_null($output) ? $this->output : $output; + + // Write cache if requested to do so + if ($this->cacheTime > 0) + $this->writeCache($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 && !$this->usingCache && + !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 gzip headers when using cache + if ($this->usingCache && $this->compressOutput) + { + if (!is_null($this->input->server('HTTP_ACCEPT_ENCODING')) && + strpos($this->input->server('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) + { + header('Content-Encoding: gzip'); + header('Content-Length: '.strlen($output)); + } + // If the cache is zipped, but the client doesn't support it, decompress the output + else + $output = gzinflate(substr($output, 10, -8)); + } + + // 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'); + } + + /** + * Enable the current page to be cached + * + * Set the amount of time with the $time parameter. + * + * @param int $time In minutes + */ + public function cache(int $time) + { + $this->cacheTime = $time > 0 ? $time : 0; + } + + public function getCache(string $selector): bool + { + // If empty, index page is requested + $selector = empty($selector) ? 'index' : $selector; + + // Generate the full uri + $uri = $this->config->get('base_url') . $selector; + + // Determine the file that holds the cache + if ($this->compressOutput) + $file = Core::$tempDir . DS . 'OutputCache' . DS . md5($uri) . '_gzip.fwcache'; + else + $file = Core::$tempDir . DS . 'OutputCache' . DS . md5($uri) . '.fwcache'; + + + // Determine if file exists + if (!file_exists($file)) + return false; + + // Retrieve cache + $cache = file_get_contents($file); + + // Verify that this is a cache file + if (!preg_match('/^(.*)EndFuzeWorksCache--->/', $cache, $match)) + return false; + + // Retrieve data from cache file + $cacheInfo = unserialize($match[1]); + + // Test if the cache has expired + if (time() > $cacheInfo['expire']) + { + // If not writeable, log warning and do not remove + if (!Core::isReallyWritable($file)) + { + Logger::logWarning("Found expired output cache. Could not remove!"); + return false; + } + + // Delete file if expired + @unlink($file); + Logger::logInfo("Found expired output cache. Removed."); + return false; + } + + // @todo Send cache header + + // Send all the headers cached in the file + foreach ($cacheInfo['headers'] as $header) + $this->setHeader($header[0], $header[1]); + + // And save the output + $this->usingCache = true; + $this->setOutput(substr($cache, strlen($match[0]))); + Logger::logInfo("Found output cache. Set output."); + return true; + } + + public function writeCache(string $output) + { + // First create cache directory + $cachePath = Core::$tempDir . DS . 'OutputCache'; + + // Attempt to create the OutputCache directory in the TempDirectory + if (!is_dir($cachePath) && !mkdir($cachePath, 0777, false)) + { + Logger::logError("Could not write output cache. Cannot create directory. Are permissions set correctly?"); + return false; + } + + // If directory is not writable, return error + if (!Core::isReallyWritable($cachePath)) + { + Logger::logError("Could not write output cache. No file permissions. Are permissions set correctly?"); + return false; + } + + // Generate the full uri + $uri = $this->config->get('base_url') . (empty($this->uri->uriString()) ? 'index' : $this->uri->uriString()); + + // Determine the file that holds the cache + if ($this->compressOutput) + $file = $cachePath . DS . md5($uri) . '_gzip.fwcache'; + else + $file = $cachePath . DS . md5($uri) . '.fwcache'; + + + // If compression is enabled, compress the output + if ($this->compressOutput) + { + $output = gzencode($output); + if ($this->getHeader('content-type') === null) + $this->setContentType($this->mimeType); + } + + // Calculate expiry time + $expire = time() + ($this->cacheTime * 60); + + // Prepare the cache contents + $cache = [ + 'expire' => $expire, + 'headers' => $this->headers + ]; + + // Create cache file contents + $cache = serialize($cache) . 'EndFuzeWorksCache--->' . $output; + + // Write the cache + if (file_put_contents($file, $cache, LOCK_EX) === false) + { + @unlink($file); + Logger::logError("Could not write output cache. File error. Deleting cache file."); + return false; + } + + // Lowering permissions to read only + chmod($cachePath, 0640); + + // And report back + Logger::logInfo("Output cache has been saved."); + + // @todo Set cache header + return true; + } + /** * Get Output * @@ -132,45 +361,6 @@ class Output $this->output .= $output; } - /** - * 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) - { - // 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'); - } - /** * Set Header * @@ -181,6 +371,11 @@ class Output */ public function setHeader(string $header, bool $replace = true) { + // If compression is enabled content-length should be suppressed, since it won't match the length + // of the compressed output. + if ($this->compressOutput && strncasecmp($header, 'content-length', 14) === 0) + return; + $this->headers[] = [$header, $replace]; } @@ -214,7 +409,6 @@ class Output * @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) diff --git a/src/FuzeWorks/Resources.php b/src/FuzeWorks/Resources.php new file mode 100644 index 0000000..4dd39eb --- /dev/null +++ b/src/FuzeWorks/Resources.php @@ -0,0 +1,177 @@ +output = Factory::getInstance()->output; + } + + public function resourceExists(array $resourceUrlSegments): bool + { + // First find the resource + $file = $this->findResource($resourceUrlSegments); + + // If not found, return false; + if (is_null($file)) + return false; + + // If found, simply return true + return true; + } + + /** + * Serves a static file if found. + * + * @param array $resourceUrlSegments + * @return bool + * @throws WebException + * + * @todo Bypass the Output system and use the readFile() method. + * @todo Run as FuzeWorks pre-code, before creating the container + */ + public function serveResource(array $resourceUrlSegments): bool + { + // First find the resource + $file = $this->findResource($resourceUrlSegments); + + // If not found return false + if (is_null($file)) + return false; + + // If a file is found, fire a serveResourceEvent + /** @var ResourceServeEvent $event */ + try { + $event = Events::fireEvent('resourceServeEvent', $file['resourceName'], $file['segments'], $file['file']); + } catch (Exception\EventException $e) { + throw new WebException("Could not serve resource. resourceServeEvent threw exception: '" . $e->getMessage() . "'"); + } + + // If cancelled, don't serve + if ($event->isCancelled()) + return false; + + // Log the resource serving + Logger::log("Serving static resource '/" . $file['resourceName'] . '/' . implode('/', $file['segments']) . "'"); + + // Serve file in accordance with event + $fileExtension = pathinfo($event->resourceFilePath, PATHINFO_EXTENSION); + $this->output->setContentType($fileExtension); + $this->output->setOutput(file_get_contents($event->resourceFilePath)); + #readfile($event->resourceFilePath); + + // And return true at the end + return true; + } + + protected function findResource(array $resourceUrlSegments): ?array + { + // If too few segments provided, don't even bother + if (count($resourceUrlSegments) < 2) + return null; + + // First segment should be the resourceName, check if it exists + $resourceName = urldecode($resourceUrlSegments[1]); + if (!isset($this->resources[$resourceName])) + return null; + + // If resource is found, generate file path + $resourceUrlSegmentsBck = $resourceUrlSegments; + array_shift($resourceUrlSegments); + $file = $this->resources[$resourceName] . DS . implode(DS, $resourceUrlSegments); + + // Test if file exists, if it does, return the string + if (file_exists($file) && is_file($file)) + return ['file' => $file, 'resourceName' => $resourceName, 'segments' => $resourceUrlSegments]; + + return null; + } + + /** + * Register a resource which can be served statically. + * + * The resourceName will be the directory under which the files shall be served on the web server. + * The filePath is where FuzeWorks should look for the files. + * + * @param string $resourceName + * @param string $filePath + * @throws WebException + * @return bool + */ + public function registerResources(string $resourceName, string $filePath): bool + { + // First check if the resource already exists + $resourceName = urldecode($resourceName); + if (isset($this->resources[$resourceName])) + throw new WebException("Could not register resources. Resources with same name already exists."); + + // Also check if the file path exists and is a directory + if (!file_exists($filePath) && !is_dir($filePath)) + throw new WebException("Could not register resources. Provided filePath does not exist."); + + // Add the resource + $this->resources[$resourceName] = $filePath; + + // Log the registration + Logger::log("Adding static resources on: '/" . $resourceName . "'"); + + return true; + } + + + +} \ No newline at end of file diff --git a/src/FuzeWorks/Security.php b/src/FuzeWorks/Security.php index d42036c..99be132 100644 --- a/src/FuzeWorks/Security.php +++ b/src/FuzeWorks/Security.php @@ -37,7 +37,7 @@ namespace FuzeWorks; use FuzeWorks\ConfigORM\ConfigORM; -use FuzeWorks\Exception\{ConfigException, SecurityException, Exception}; +use FuzeWorks\Exception\{ConfigException, CSRFException, Exception}; /** * Security Class @@ -45,6 +45,7 @@ use FuzeWorks\Exception\{ConfigException, SecurityException, Exception}; * @author EllisLab Dev Team * @copyright Copyright (c) 2008 - 2014, EllisLab, Inc. (https://ellislab.com/) * @copyright Copyright (c) 2014 - 2016, British Columbia Institute of Technology (http://bcit.ca/) + * @todo Complete rewrite */ class Security { @@ -168,6 +169,13 @@ class Security { */ private $config; + /** + * Input. A dependency for this class + * + * @var Input + */ + private $input; + /** * Class constructor * @@ -177,6 +185,7 @@ class Security { public function init() { $this->config = Factory::getInstance()->config->get('security'); + $this->input = Factory::getInstance()->input; // Is CSRF protection enabled? if ($this->config->csrf_protection) @@ -200,7 +209,7 @@ class Security { $this->_csrf_set_hash(); } - $this->charset = strtoupper(Factory::getInstance()->config->get('web')->charset); + $this->charset = strtoupper(Factory::getInstance()->config->get('web')->get('charset')); } // -------------------------------------------------------------------- @@ -209,14 +218,13 @@ class Security { * CSRF Verify * * @return self + * @throws CSRFException */ public function csrf_verify(): self { // If it's not a POST request we will set the CSRF cookie - if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST') - { + if (strtoupper($this->input->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) @@ -231,22 +239,10 @@ class Security { } // 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? - { + $token = $this->input->post($this->_csrf_token_name); + $cookie = $this->input->cookie($this->_csrf_cookie_name); + if ($token == null || $cookie == null || $token !== $cookie) $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(); @@ -257,6 +253,22 @@ class Security { // -------------------------------------------------------------------- + /** + * CSRF Regenerate + * + * @throws ConfigException + * @return self + */ + public function csrf_regenerate() + { + Logger::log("CSRF Hash is being regenerated..."); + $this->_csrf_hash = null; + $this->_csrf_set_hash(); + $this->csrf_set_cookie(); + + return $this; + } + /** * CSRF Set Cookie * @@ -266,24 +278,25 @@ class Security { public function csrf_set_cookie() { $expire = time() + $this->_csrf_expire; - $cfg = Factory::getInstance()->config->get('main'); - $secure_cookie = (bool) $cfg->cookie_secure; + $cfg = Factory::getInstance()->config->get('security'); + $secure_cookie = (bool) $cfg->csrf_cookie_secure; - if ($secure_cookie && ! Core::isHttps()) - { + if ($secure_cookie && !$this->input->isHttps()) return $this; - } setcookie( $this->_csrf_cookie_name, $this->_csrf_hash, $expire, - $cfg->cookie_path, - $cfg->cookie_domain, + $cfg->csrf_cookie_path, + $cfg->csrf_cookie_domain, $secure_cookie, - $cfg->cookie_httponly + $cfg->csrf_cookie_httponly ); + Logger::log('CSRF cookie sent'); + + return $this; } // -------------------------------------------------------------------- @@ -292,11 +305,11 @@ class Security { * Show CSRF Error * * @return void - * @throws SecurityException + * @throws CSRFException */ public function csrf_show_error() { - throw new SecurityException('The action you have requested is not allowed.', 1); + throw new CSRFException('This action resulted in a Cross Site Reference Forgery warning. Request will be blocked...', 5); } // -------------------------------------------------------------------- @@ -727,57 +740,6 @@ class Security { 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 - ); - } - // ---------------------------------------------------------------- /** diff --git a/src/FuzeWorks/URI.php b/src/FuzeWorks/URI.php index 1763575..610dc6c 100644 --- a/src/FuzeWorks/URI.php +++ b/src/FuzeWorks/URI.php @@ -60,17 +60,15 @@ class URI private $input; protected $baseUri; - protected $uriString; - protected $segments; + 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() @@ -86,7 +84,7 @@ class URI $scriptName = $this->input->server('SCRIPT_NAME'); $scriptFilename = $this->input->server('SCRIPT_FILENAME'); - $baseUrl = ($this->isHttps() ? 'https' : 'http') . + $baseUrl = ($this->input->isHttps() ? 'https' : 'http') . "://" . $serverAddr . substr($scriptName, 0, strpos($scriptName, basename($scriptFilename))); } @@ -224,31 +222,4 @@ class URI 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/WebComponent.php b/src/FuzeWorks/WebComponent.php index 451ec8e..0db2716 100644 --- a/src/FuzeWorks/WebComponent.php +++ b/src/FuzeWorks/WebComponent.php @@ -36,14 +36,24 @@ namespace FuzeWorks; +use FuzeWorks\Event\HaltExecutionEvent; +use FuzeWorks\Event\LayoutLoadEvent; +use FuzeWorks\Event\RouterCallViewEvent; +use FuzeWorks\Event\RouterLoadViewAndControllerEvent; +use FuzeWorks\Event\RouteWebRequestEvent; +use FuzeWorks\Exception\ConfigException; use FuzeWorks\Exception\EventException; use FuzeWorks\Exception\Exception; +use FuzeWorks\Exception\FactoryException; +use FuzeWorks\Exception\HaltException; use FuzeWorks\Exception\NotFoundException; +use FuzeWorks\Exception\OutputException; +use FuzeWorks\Exception\RouterException; +use FuzeWorks\Exception\SecurityException; use FuzeWorks\Exception\WebException; class WebComponent implements iComponent { - /** * Whether WebComponent is configured to handle a web request * @@ -64,13 +74,10 @@ class WebComponent implements iComponent 'input' => '\FuzeWorks\Input', 'output' => '\FuzeWorks\Output', 'uri' => '\FuzeWorks\URI', + 'resources' => '\FuzeWorks\Resources' ]; } - /** - * @param Configurator $configurator - * @todo WebComponent will not always be running when added to FuzeWorks, move this into a separate method - */ public function onAddComponent(Configurator $configurator) { // Add dependencies @@ -95,60 +102,133 @@ class WebComponent implements iComponent { } + /** + * On initializing, Initialize UTF8 first, since it's a dependency for other componentClasses + */ public function init() { // First init UTF8 UTF8::init(); + + // Register some base events + Events::addListener([$this, 'layoutLoadEventListener'], 'layoutLoadEvent', Priority::NORMAL); } + /** + * Enable the WebComponent to prepare for handling requests + */ public function enableComponent() { self::$willHandleRequest = true; } + /** + * Disable the WebComponent so it won't prepare for handling requests + */ public function disableComponent() { self::$willHandleRequest = false; } /** + * Handle a Web request. + * + * Retrieves URI string, routes this URI using the provided routes, + * appends output and adds listener to view output on shutdown. + * + * @return bool + * @throws OutputException + * @throws RouterException * @throws WebException + * @throws EventException + * @throws FactoryException */ 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 () { + // Set the output to display when shutting down + Events::addListener(function ($event) { /** @var Output $output */ Logger::logInfo("Parsing output..."); $output = Factory::getInstance()->output; $output->display(); + return $event; }, 'coreShutdownEvent', Priority::NORMAL); + + // Add HTTP method prefix to requests to views + Events::addListener(function($event){ + /** @var Input $input */ + /** @var RouterLoadViewAndControllerEvent $event */ + $input = Factory::getInstance('input'); + $methods = $event->viewMethods[Priority::NORMAL]; + foreach ($methods as $method) + $event->addMethod(strtolower($input->method()) . '_' . $method); + return $event; + }, 'routerLoadViewAndControllerEvent', Priority::NORMAL); + + // Create an error 500 page when a haltEvent is fired + Events::addListener([$this, 'haltEventListener'], 'haltExecutionEvent', Priority::NORMAL); } catch (EventException $e) { throw new WebException("Could not route web request. coreShutdownEvent threw EventException: '".$e->getMessage()."'"); } + // Remove the X-Powered-By header, since it's a security risk + header_remove("X-Powered-By"); + /** @var Router $router */ - $router = Factory::getInstance()->router; - - /** @var URI $uriObject */ - $uriObject = Factory::getInstance()->uri; - $uri = $uriObject->uriString(); - + /** @var URI $uri */ /** @var Output $output */ - $output = Factory::getInstance()->output; + /** @var Security $security */ + /** @var Resources $resources */ + $router = Factory::getInstance('router'); + $uri = Factory::getInstance('uri'); + $output = Factory::getInstance('output'); + $security = Factory::getInstance('security'); + $resources = Factory::getInstance('resources'); + + // And start logging the request + Logger::newLevel("Routing web request..."); + + // First check if a cached page is available + $uriString = $uri->uriString(); + if ($output->getCache($uriString)) + return true; + + // Send webRequestEvent, if no cache is found + /** @var RouteWebRequestEvent $event */ + $event = Events::fireEvent('routeWebRequestEvent', $uriString); + if ($event->isCancelled()) + return true; + + // Attempt to load a static resource + if ($resources->serveResource($uri->segmentArray())) + return true; + + // First test for Cross Site Request Forgery + try { + $security->csrf_verify(); + } catch (SecurityException $exception) { + // If a SecurityException is thrown, first log it + Logger::logWarning("SecurityException thrown. Registering listener to verify handler in View"); + + // Register a listener + Events::addListener([$this, 'callViewEventListener'], 'routerCallViewEvent', Priority::HIGHEST, $exception); + } // Attempt to load the requested page try { - $viewOutput = $router->route($uri); + $viewOutput = $router->route($event->uriString); } catch (NotFoundException $e) { Logger::logWarning("Requested page not found. Requesting Error/error404 View"); $output->setStatusHeader(404); - // Request 404 page= + // Remove listener so that error pages won't be intercepted + Events::removeListener([$this, 'callViewEventListener'], 'routerCallViewEvent',Priority::HIGHEST); + + // Request 404 page try { $viewOutput = $router->route('Error/error404'); } catch (NotFoundException $e) { @@ -158,14 +238,21 @@ class WebComponent implements iComponent Logger::exceptionHandler($e, false); $viewOutput = 'ERROR 404. Page was not found.'; } - } catch (Exception $e) { - Logger::exceptionHandler($e, false); - $output->setStatusHeader(500); + } catch (HaltException $e) { + Logger::logWarning("Requested page was denied. Requesting Error/error403 View."); + $output->setStatusHeader(403); + + // Remove listener so that error pages won't be intercepted + Events::removeListener([$this, 'callViewEventListener'], 'routerCallViewEvent',Priority::HIGHEST); + try { - $viewOutput = $router->route('Error/error500'); - } catch (Exception $error500Exception) { - Logger::exceptionHandler($error500Exception, false); - $viewOutput = 'ERROR 500. Page could not be loaded.'; + $viewOutput = $router->route('Error/error403'); + } catch (NotFoundException $e) { + // If still resulting in an error, do something else + $viewOutput = 'ERROR 403. Forbidden.'; + } catch (Exception $e) { + Logger::exceptionHandler($e, false); + $viewOutput = 'ERROR 403. Forbidden.'; } } @@ -173,6 +260,99 @@ class WebComponent implements iComponent if (!empty($viewOutput)) $output->appendOutput($viewOutput); + Logger::stopLevel(); return true; } + + /** + * Listener for routerCallViewEvent + * + * Fired when a SecurityException is thrown. Verifies if a securityExceptionHandler() method exists. + * If not, the calling of the view is cancelled. If yes, the calling of the view depends on the + * result of the method + * + * @param RouterCallViewEvent $event + * @param SecurityException $exception + */ + public function callViewEventListener(RouterCallViewEvent $event, SecurityException $exception) + { + /** @var RouterCallViewEvent $event */ + // If the securityExceptionHandler method exists, cancel based on that methods output + if (method_exists($event->view, 'securityExceptionHandler')) + $event->setCancelled(!$event->view->securityExceptionHandler($exception)); + + // If not, cancel it immediately + else + $event->setCancelled(true); + } + + /** + * Listener for haltExecutionEvent + * + * Fired when FuzeWorks halts it's execution. Loads an error 500 page. + * + * @param $event + * @throws EventException + * @throws FactoryException + * @TODO remove FuzeWorks\Layout dependency + */ + public function haltEventListener(HaltExecutionEvent $event) + { + // Dependencies + /** @var Output $output */ + /** @var Router $router */ + /** @var Event $event */ + /** @var Layout $layout */ + $output = Factory::getInstance()->output; + $router = Factory::getInstance()->router; + $layout = Factory::getInstance()->layouts; + + // Cancel event + $event->setCancelled(true); + + // Reset the layout engine + $layout->reset(); + + // Remove listener so that error pages won't be intercepted + Events::removeListener([$this, 'callViewEventListener'], 'routerCallViewEvent',Priority::HIGHEST); + + try { + // And handle consequences + Logger::logError("Execution halted. Providing error 500 page."); + $output->setStatusHeader(500); + $viewOutput = $router->route('Error/error500'); + } catch (Exception $error500Exception) { + Logger::exceptionHandler($error500Exception, false); + $viewOutput = 'ERROR 500. Page could not be loaded.'; + } + + // Finally append output and shutdown + $output->appendOutput($viewOutput); + } + + /** + * Listener for layoutLoadEvent + * + * Assigns variables from the WebComponent to Layout engines. + * + * @param LayoutLoadEvent $event + * @throws ConfigException + * @throws FactoryException + */ + public function layoutLoadEventListener($event) + { + // Dependencies + /** @var Security $security */ + /** @var Config $config */ + $security = Factory::getInstance()->security; + $config = Factory::getInstance()->config; + + /** @var LayoutLoadEvent $event */ + $event->assign('csrfHash', $security->get_csrf_hash()); + $event->assign('csrfTokenName', $security->get_csrf_token_name()); + $event->assign('siteURL', $config->getConfig('web')->get('base_url')); + $event->assign('serverName', $config->getConfig('web')->get('serverName')); + + Logger::logInfo("Assigned variables to TemplateEngine from WebComponent"); + } } \ No newline at end of file diff --git a/src/FuzeWorks/WebController.php b/src/FuzeWorks/WebController.php index bc0dbec..00734b7 100644 --- a/src/FuzeWorks/WebController.php +++ b/src/FuzeWorks/WebController.php @@ -36,33 +36,6 @@ namespace FuzeWorks; -class WebController extends Controller +abstract class WebController extends Controller { - - /** - * @var Input - */ - protected $input; - - /** - * @var Output - */ - protected $output; - - /** - * @var URI - */ - protected $uri; - - /** - * WebView constructor. - */ - public function __construct() - { - parent::__construct(); - $this->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 index 92207e5..75c20a2 100644 --- a/src/FuzeWorks/WebModel.php +++ b/src/FuzeWorks/WebModel.php @@ -36,31 +36,6 @@ namespace FuzeWorks; -class WebModel extends Model +abstract class WebModel extends Model { - /** - * @var Input - */ - protected $input; - - /** - * @var Output - */ - protected $output; - - /** - * @var URI - */ - protected $uri; - - /** - * WebView constructor. - */ - public function __construct() - { - parent::__construct(); - $this->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 index 02fc2fd..6e4bd78 100644 --- a/src/FuzeWorks/WebView.php +++ b/src/FuzeWorks/WebView.php @@ -36,7 +36,7 @@ namespace FuzeWorks; -class WebView extends View +abstract class WebView extends View { /** * @var Input @@ -53,6 +53,11 @@ class WebView extends View */ protected $uri; + /** + * @var Security + */ + protected $security; + /** * WebView constructor. */ @@ -62,5 +67,6 @@ class WebView extends View $this->input = Factory::getInstance()->input; $this->output = Factory::getInstance()->output; $this->uri = Factory::getInstance()->uri; + $this->security = Factory::getInstance()->security; } } \ No newline at end of file