CSRF
+Token should be used inside hidden input, text input just used for demo purpose.
+ + ++ + "; + echo "Expected token: " . Request::session("csrf-token") . "
"; + echo Misc::verifyCsrfTokenPost() ? "Valid" : "Invalid"; + } + ?> +
diff --git a/.gitignore b/.gitignore index 016e83c..e3b2917 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ build/ # Configuration files config/* -!config/config_sample.php +!config/*_sample.* .env *.local diff --git a/config/config_sample.php b/config/config_sample.php index 97f7fa1..f5d7835 100644 --- a/config/config_sample.php +++ b/config/config_sample.php @@ -5,15 +5,17 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) -Version: 2.1.4 +Version: 3.3.1 */ namespace FileRouter; -final class Config +class Config { // Paths (relative to the server root) const PATH_PUBLIC = "/../public/"; // Path to the public folder @@ -35,11 +37,25 @@ final class Config "samesite" => "Strict", ]; + // Security + const CSRF_TEMPLATE = "csrf-token"; // CSRF token template variable name, also used as session key + const CSRF_PARAMETER = "token"; // CSRF token parameter name for get and post requests + const CSRF_LENGTH = 64; // Length of the CSRF token, set to 0 to disable CSRF protection + // Page titles const TITLE_PREFIX = "FileRouter"; const TITLE_SUFFIX = ""; const TITLE_SEPARATOR = " | "; + // Error handling and logging + const DEBUG = true; // Enable debug mode (shows errors and warnings, disables error logging) + const LOG = true; // Enable logging + const LOG_MAX_FILE_SIZE = 1048576; // Maximum file size of log files before a new file is created (in bytes) + const LOG_PATH = [ // Paths to log files (relative to the server root, {date} will be replaced with the current date) + "error" => "/../logs/error_{date}.log", // Error log file required if logging is enabled + "additional" => "/../logs/additional.log", // Additional log files, can be used for custom logging by Logger::log("message", "additional") + ]; + // Other const ALLOW_PAGE_PHP = true; // Allow to execute php code in pages. Warning: This can be a security risk if not handled carefully. const IMAGE_RESIZE_QUERY = "res"; // Query parameter to specify the width of an image to resize it diff --git a/function/ControllerDefault.php b/function/ControllerDefault.php index acf85bd..82c6218 100644 --- a/function/ControllerDefault.php +++ b/function/ControllerDefault.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ diff --git a/function/ControllerHtml.php b/function/ControllerHtml.php index d69a449..ac25be9 100644 --- a/function/ControllerHtml.php +++ b/function/ControllerHtml.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ @@ -57,6 +59,11 @@ public static function handleHtml(Output $content): Output $content = self::handleHeader($content); $content = self::handleFooter($content); + // Output CSRF token if enabled + if (Config::SESSION && Config::CSRF_LENGTH > 0) { + $content->replaceAllSafe(Config::CSRF_TEMPLATE, Misc::generateCsrfToken()); + } + return $content; } diff --git a/function/ControllerImage.php b/function/ControllerImage.php index fa2223e..ce2ebe5 100644 --- a/function/ControllerImage.php +++ b/function/ControllerImage.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ diff --git a/function/Error.php b/function/ErrorPage.php similarity index 87% rename from function/Error.php rename to function/ErrorPage.php index b3699e0..8819566 100644 --- a/function/Error.php +++ b/function/ErrorPage.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ @@ -16,7 +18,7 @@ * * Represents an error with an error code and an error message. */ -class Error extends \Exception +class ErrorPage extends \Exception { /** @@ -34,7 +36,10 @@ public function __construct(int $errorCode, string $errorMessage = null) http_response_code($errorCode); // Set HTTP status code based on error code $pathErrorPage = $_SERVER["DOCUMENT_ROOT"] . Config::PATH_ERROR; - if (!file_exists($pathErrorPage)) die(Config::ERROR_FATAL); // Fatal error if error page does not exist + if (!file_exists($pathErrorPage)) { + // Fatal error if error page does not exist + throw new \ValueError("Error page (Config::PATH_ERROR) not found in {$pathErrorPage}", E_USER_ERROR); + } $output = new Output($pathErrorPage); // Load error page to output handler $settings = $output->getNodeContentArray("settings"); // Get error messages from error page @@ -48,6 +53,5 @@ public function __construct(int $errorCode, string $errorMessage = null) $output->replaceAll("error-message", $errorMessage); // Replace error message placeholder $output = ControllerHtml::handleHtml($output); // Handle html content (e.g. add head, header and footer) $output->print(); // Print output - exit; // Stop further execution } } diff --git a/function/FileRouter.php b/function/FileRouter.php index 530fb51..8c649d2 100644 --- a/function/FileRouter.php +++ b/function/FileRouter.php @@ -5,36 +5,98 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) -Version: 2.1.4 +Version: 3.3.1 */ namespace FileRouter; -// Autoload classes -spl_autoload_register(function ($class) { - if (str_starts_with($class, __NAMESPACE__ . "\\")) { - $class = str_replace(__NAMESPACE__ . "\\", "", $class); - require_once "{$class}.php"; +class FileRouter +{ + public function __construct() + { + try { + $this->autoload(); // Autoload classes + $this->setup(); // Setup environment + $this->handle(); // Handle request + } catch (ErrorPage) { + // Error page was displayed in ErrorPage exception + } catch (Redirect) { + // Redirect was handled in Redirect exception + } catch (\Throwable $exception) { + $this->handleException($exception); // Handle unhandled exceptions + } } -}); -// Start session if enabled in config -if (Config::SESSION) { - Misc::session(); -} + private function handle(): void + { + // Handle route file as proxy of request + $proxy = new Proxy(); + $proxyHandled = $proxy->handle(); + if ($proxyHandled) return; // Stop handling if request was handled by proxy + + // Handle request with router + $router = new Router(); + $routerHandled = $router->handle(); + if ($routerHandled) return; // Stop handling if request was handled by router -// Handle route file as proxy of request -$proxy = new Proxy(); -$proxyHandled = $proxy->handle(); -if ($proxyHandled) exit; // Stop handling if request was handled by proxy + // Error 404 if request was not handled before + throw new ErrorPage(404); + } + + private function setup(): void + { + // Set error log file if enabled + if (Config::LOG) { + ini_set("log_errors", 1); // Enable error logging + ini_set("error_log", Logger::logPath("error")); // Set error log file + } -// Handle request with router -$router = new Router(); -$routerHandled = $router->handle(); -if ($routerHandled) exit; // Stop handling if request was handled by router + // Set error reporting and display errors + if (Config::DEBUG) { + error_reporting(E_ALL); // Report all errors + ini_set("display_errors", 1); // Display errors + ini_set("log_errors", 0); // Disable error log + } else { + error_reporting(0); // Report no errors + ini_set("display_errors", 0); // Hide errors + } -// Error 404 if request was not handled before -throw new Error(404); + // Start session if enabled in config + if (Config::SESSION) { + Misc::session(); + } + } + + private function handleException(\Throwable $exception): void + { + if (CONFIG::DEBUG) { + throw $exception; // Rethrow exception if debug mode is enabled + } + try { + ob_end_clean(); // Clear output buffer + Logger::logError("{$exception->getMessage()} in {$exception->getFile()}({$exception->getLine()})", E_USER_ERROR); // Log unhandled exceptions + throw new ErrorPage(500); // Internal server error if unhandled exception occurred + } catch (\Throwable $e) { + if ($e instanceof ErrorPage) return; + if (Config::LOG) { + error_log("ERROR {$e->getMessage()} in exception handling"); // Log error message + } + die(Config::ERROR_FATAL ?? "
An error occurred in the request.
Please contact the webmaster
"); + } + } + + private function autoload() + { + spl_autoload_register(function ($class) { + if (str_starts_with($class, __NAMESPACE__ . "\\")) { + $class = str_replace(__NAMESPACE__ . "\\", "", $class); + require_once "{$class}.php"; + } + }); + } +} diff --git a/function/Logger.php b/function/Logger.php new file mode 100644 index 0000000..21a1a29 --- /dev/null +++ b/function/Logger.php @@ -0,0 +1,75 @@ + "NOTICE", + E_USER_NOTICE => "NOTICE", + E_WARNING => "WARNING", + E_USER_DEPRECATED => "DEPRECATED", + E_DEPRECATED => "DEPRECATED", + E_USER_WARNING => "WARNING", + E_ERROR => "ERROR", + E_USER_ERROR => "ERROR", + default => "UNKNOWN", + }; + error_log("$levelName: $message"); + } + + public static function logPath(string $type): string + { + $path = Config::LOG_PATH[$type] ?? "filerouter_{date}.log"; + $path = str_replace("{date}", date("Y-m-d"), $path); + $file = $_SERVER["DOCUMENT_ROOT"] . $path; + if (file_exists($file) && filesize($file) >= Config::LOG_MAX_FILE_SIZE) { + $file = self::getNewLogFile($file); + } + return $file; + } + + private static function getNewLogFile($file) + { + $logDir = dirname($file); + $baseName = basename($file, '.log'); + $index = 1; + + do { + $newFile = $logDir . DIRECTORY_SEPARATOR . $baseName . '_' . $index . '.log'; + $index++; + } while (file_exists($newFile)); + + return $newFile; + } +} diff --git a/function/Misc.php b/function/Misc.php index 536d89a..f3d00c2 100644 --- a/function/Misc.php +++ b/function/Misc.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ @@ -18,6 +20,8 @@ */ final class Misc { + private static string $csrfToken; + /** * Starts a session if one is not already started and returns the session ID. * Session name and cookie parameters are set in the Config class. @@ -34,6 +38,59 @@ public static function session(): string|false return session_id(); } + /** + * Generates a CSRF token and stores it in the session. + * The token is a 64-character hexadecimal string. + * + * @param bool $regenerate Flag indicating if the token should be regenerated. + * @return string The generated CSRF token. + */ + public static function generateCsrfToken(): string + { + if (empty(self::$csrfToken)) { + self::$csrfToken = bin2hex(random_bytes(Config::CSRF_LENGTH / 2)); + Request::setSession(self::$csrfToken, Config::CSRF_TEMPLATE); + } + return self::$csrfToken; + } + + /** + * Verifies a CSRF token by comparing it to the token stored in the session. + * Regenerates the token after verification. + * + * @param string $token The token to verify. + * @return bool True if the token is valid, false otherwise. + */ + public static function verifyCsrfToken(string|null $token): bool + { + if ($token == null) return false; + $verify = hash_equals(Request::session(Config::CSRF_TEMPLATE) ?? "", $token); + self::generateCsrfToken(); + return $verify; + } + + /** + * Verifies a CSRF token from a GET request. + * The token is retrieved from the query string. + * + * @return bool True if the token is valid, false otherwise. + */ + public static function verifyCsrfTokenGet(): bool + { + return self::verifyCsrfToken(Request::get(Config::CSRF_PARAMETER)); + } + + /** + * Verifies a CSRF token from a POST request. + * The token is retrieved from the request body. + * + * @return bool True if the token is valid, false otherwise. + */ + public static function verifyCsrfTokenPost(): bool + { + return self::verifyCsrfToken(Request::post(Config::CSRF_PARAMETER)); + } + /** * Retrieves the MIME type of a file. * Uses mime_content_type as default, but also provides a custom list of MIME types based on file extension. diff --git a/function/Output.php b/function/Output.php index f12bf6a..6396522 100644 --- a/function/Output.php +++ b/function/Output.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ @@ -13,6 +15,7 @@ use DOMDocument; use DOMNode; +use DOMXPath; /** * Class Output @@ -48,8 +51,7 @@ public function __construct(string $content, bool $directInput = false) // Get content from file without processing php $output = file_get_contents($content); } - - $output = mb_convert_encoding($output, "HTML-ENTITIES", "UTF-8"); + $output = mb_encode_numericentity($output, [0x80, 0x10FFFF, 0, ~0], "UTF-8"); $this->dom->loadHTML($output, LIBXML_NOERROR); // Load html content into dom } @@ -87,6 +89,22 @@ public function replaceAll(string $tag, string $content): void $this->replaceAttributes($tag, $content); // Replace attributes with tag } + /** + * Replaces all occurrences of a given tag with the specified content. + * This method replaces both nodes and attributes with the given tag. + * Replaces the nodes itself, not only the content of them. + * The tag is case-insensitive. + * The content is sanitized to prevent XSS attacks. + * + * @param string $tag The tag to be replaced. + * @param string $content The content to replace the tag with. + * @return void + */ + public function replaceAllSafe(string $tag, string $content): void + { + $this->replaceAll($tag, htmlspecialchars($content)); + } + /** * Replaces the content of nodes with a specified tag with new content. * Preserves the tag and attributes of the nodes. @@ -135,7 +153,7 @@ public function getContent(string ...$tags): string|null $dom = $domNew; } $content = $dom->saveHTML(); // Save html content from dom - $content = mb_convert_encoding($content, "HTML-ENTITIES", "UTF-8"); + $content = mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, ~0], "UTF-8"); $content = str_replace("%20", " ", $content); return trim($content); // Return html content as string } @@ -168,7 +186,7 @@ private function replaceNode(string $tag, string $content): void foreach (iterator_to_array($nodeList) as $node) { // Iterate over nodes with tag foreach ($replacement as $child) { $child = $child->cloneNode(true); - $node->parentNode->appendChild($child); // Append content as html nodes to parent node + $node->parentNode->insertBefore($child, $node); // Insert content as html nodes before old node } $node->parentNode->removeChild($node); // Remove old node } @@ -176,13 +194,15 @@ private function replaceNode(string $tag, string $content): void private function replaceAttributes(string $tag, string $content): void { - $nodeList = $this->dom->getElementsByTagName("*"); // Get all nodes - foreach ($nodeList as $node) { - foreach (iterator_to_array($node->attributes) as $attribute) { - $value = $attribute->value; // Get attribute value - if (stripos($value, "<{$tag}") !== false) { // Check if attribute value contains tag - $attribute->value = str_ireplace(["<{$tag}>{$tag}>", "<{$tag} />", "<{$tag}/>"], $content, $attribute->nodeValue); // Replace tag with content in attribute value - } + // Find nodes with attributes containing tag + $xpath = new DOMXPath($this->dom); + $query = "//" . "*[@*[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '" . strtolower($tag) . "')]]"; + $nodes = $xpath->query($query); + + foreach ($nodes as $node) { // Iterate over nodes with attributes containing tag + foreach ($node->attributes as $attribute) { + // Replace tag with content in attribute value + $attribute->value = str_ireplace(["<{$tag}>{$tag}>", "<{$tag} />", "<{$tag}/>", "<{$tag}>"], $content, $attribute->value); } } } @@ -195,7 +215,7 @@ private function importNodes(string $value): array } $valueDom = new DOMDocument(); - $valueDom->loadHTML(mb_convert_encoding($value, 'HTML-ENTITIES', 'UTF-8'), LIBXML_NOERROR); // Load html value into dom + $valueDom->loadHTML(mb_encode_numericentity($value, [0x80, 0x10FFFF, 0, ~0], "UTF-8"), LIBXML_NOERROR); // Load html value into dom $importedNodes = []; foreach ($valueDom->getElementsByTagName("body")->item(0)->childNodes as $child) { array_push($importedNodes, $this->dom->importNode($child, true)); // Import nodes from value dom to main dom and add to array diff --git a/function/Proxy.php b/function/Proxy.php index 8a73474..02fb64f 100644 --- a/function/Proxy.php +++ b/function/Proxy.php @@ -5,6 +5,8 @@ FileRouter A simple php router that allows to run code before accessing a file while keeping the file structure as the url structure. +https://github.com/Friedinger/FileRouter + by Friedinger (friedinger.org) */ @@ -18,7 +20,7 @@ */ class Proxy { - private static $handleCustom; + private static mixed $handleCustom; /** * Loads and processes route file. @@ -61,38 +63,37 @@ public function handle(string $uri = null): bool */ public static function handleCustom(Output $content, Output $settings): Output { - $handleCustom = self::$handleCustom ?? null; // Get custom route file callable if set - if (isset($handleCustom)) { - $parameters = [[$content, $settings], [$content], []]; // Define parameter combinations - $success = false; // Flag indicating if callable was successful - foreach ($parameters as $parameter) { - try { - $return = call_user_func_array($handleCustom, $parameter); // Call function with parameters - if ($return instanceof Output) $content = $return; // Set content to return value if output - $success = true; - break; - } catch (\Throwable $e) { - // Continue with next parameter combination if callable failed - } - } - if (!$success) { - throw new Error(500, "Error in route file callable: {$e->getMessage()}"); // Error 500 if callable failed with all parameter combinations - } - } + // No handling if no custom route file callable set + if (!isset(self::$handleCustom)) return $content; + + // Get parameters of custom route file callable + $reflection = new \ReflectionFunction(self::$handleCustom); + $parameters = array_map(function ($parameter) use ($content, $settings) { + return match ($parameter->getName()) { + "content" => $content, + "settings" => $settings, + default => null, + }; + }, $reflection->getParameters()); + + $return = call_user_func_array(self::$handleCustom, $parameters); // Call custom route file callable with parameters + if ($return instanceof Output) $content = $return; // Set content to return value if output return $content; // Return handled content } private function getRouteFile(string $path): string|null { - $path = $_SERVER["DOCUMENT_ROOT"] . Config::PATH_PUBLIC . $path; // Combine document root, public path and URI to get file path + // Combine document root, public path and URI to get full path + $path = rtrim($_SERVER["DOCUMENT_ROOT"] . Config::PATH_PUBLIC . $path, '/'); - // Find route file in directory structure up to 100 directories deep - for ($iteration = 0; $iteration < 100; $iteration++) { + while ($path != rtrim($_SERVER["DOCUMENT_ROOT"] . Config::PATH_PUBLIC, '/')) { $file = "{$path}/_route.php"; // Construct route file path - if (file_exists($file)) return $file; // Check if route file exists - if ($path == $_SERVER["DOCUMENT_ROOT"] . Config::PATH_PUBLIC) break; // Stop if public path reached - $path = dirname($path) . "/"; // Move up one directory + if (file_exists($file)) { + return $file; // Check if route file exists + } + $path = dirname($path); // Move up one directory } + return null; // Return null if no route file found } } diff --git a/function/Redirect.php b/function/Redirect.php new file mode 100644 index 0000000..d406f40 --- /dev/null +++ b/function/Redirect.php @@ -0,0 +1,27 @@ + - Header: Home Protect Proxy + Header: Home Protect Proxy CSRF Error Redirect \ No newline at end of file diff --git a/public/csrf/index.php b/public/csrf/index.php new file mode 100644 index 0000000..b35382a --- /dev/null +++ b/public/csrf/index.php @@ -0,0 +1,22 @@ +Token should be used inside hidden input, text input just used for demo purpose.
+ + +