diff --git a/src/Config/config.security.php b/src/Config/config.security.php index 47749b9..346fcbc 100644 --- a/src/Config/config.security.php +++ b/src/Config/config.security.php @@ -51,8 +51,14 @@ return [ */ '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(), + + // 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/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->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])); @@ -376,5 +376,28 @@ class Input 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 cfd66e7..b77197d 100644 --- a/src/FuzeWorks/Output.php +++ b/src/FuzeWorks/Output.php @@ -163,6 +163,9 @@ class Output ob_start('ob_gzhandler'); } + // Remove the X-Powered-By header, since it's a security risk + header_remove("X-Powered-By"); + // Send all available headers if (!empty($this->headers)) foreach ($this->headers as $header) diff --git a/src/FuzeWorks/Security.php b/src/FuzeWorks/Security.php index 8398f80..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 @@ -169,6 +169,13 @@ class Security { */ private $config; + /** + * Input. A dependency for this class + * + * @var Input + */ + private $input; + /** * Class constructor * @@ -178,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) @@ -210,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) @@ -232,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(); @@ -258,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 * @@ -267,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; } // -------------------------------------------------------------------- @@ -293,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); } // -------------------------------------------------------------------- @@ -728,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 d135c5c..29fa54b 100644 --- a/src/FuzeWorks/URI.php +++ b/src/FuzeWorks/URI.php @@ -84,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))); } @@ -222,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 a87a443..f40723d 100644 --- a/src/FuzeWorks/WebComponent.php +++ b/src/FuzeWorks/WebComponent.php @@ -36,12 +36,17 @@ namespace FuzeWorks; +use FuzeWorks\Event\HaltExecutionEvent; +use FuzeWorks\Event\LayoutLoadEvent; +use FuzeWorks\Event\RouterCallViewEvent; +use FuzeWorks\Exception\CSRFException; use FuzeWorks\Exception\EventException; use FuzeWorks\Exception\Exception; 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 @@ -100,6 +105,9 @@ class WebComponent implements iComponent { // First init UTF8 UTF8::init(); + + // Register some base events + Events::addListener([$this, 'layoutLoadEventListener'], 'layoutLoadEvent', Priority::NORMAL); } /** @@ -137,7 +145,7 @@ class WebComponent implements iComponent try { // Set the output to display when shutting down - Events::addListener(function () { + Events::addListener(function ($event) { /** @var Output $output */ Logger::logInfo("Parsing output..."); $output = Factory::getInstance()->output; @@ -153,9 +161,25 @@ class WebComponent implements iComponent /** @var Router $router */ /** @var URI $uri */ /** @var Output $output */ + /** @var Security $security */ $router = Factory::getInstance()->router; $uri = Factory::getInstance()->uri; $output = Factory::getInstance()->output; + $security = Factory::getInstance()->security; + + // And start logging the request + Logger::newLevel("Routing web request..."); + + // 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 { @@ -165,6 +189,9 @@ class WebComponent implements iComponent Logger::logWarning("Requested page not found. Requesting Error/error404 View"); $output->setStatusHeader(404); + // 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'); @@ -175,15 +202,54 @@ class WebComponent implements iComponent Logger::exceptionHandler($e, false); $viewOutput = 'ERROR 404. Page was not found.'; } + } 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/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.'; + } } // Append the output 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 * @@ -191,7 +257,7 @@ class WebComponent implements iComponent * * @param $event */ - public function haltEventListener($event) + public function haltEventListener(HaltExecutionEvent $event) { // Dependencies /** @var Output $output */ @@ -203,6 +269,9 @@ class WebComponent implements iComponent // Cancel event $event->setCancelled(true); + // 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."); @@ -216,4 +285,28 @@ class WebComponent implements iComponent // Finally append output and shutdown $output->appendOutput($viewOutput); } + + /** + * Listener for layoutLoadEvent + * + * Assigns variables from the WebComponent to Layout engines. + * + * @param $event + * @throws Exception\ConfigException + */ + public function layoutLoadEventListener(LayoutLoadEvent $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')); + + Logger::logInfo("Assigned variables to TemplateEngine from WebComponent"); + } } \ No newline at end of file