diff --git a/defaults/config.ini.default b/defaults/config.ini.default index a505140f..11d6140b 100644 --- a/defaults/config.ini.default +++ b/defaults/config.ini.default @@ -18,6 +18,7 @@ enable_verbose_error_log = true ; internal use only enable_redirect_message = true ; internal use only enable_exception_handler = true ; internal use only enable_error_handler = true ; internal use only +session_cleanup_idle_seconds = 1800 ; how long a session must be idle before messages and CSRF tokens are cleared [ldap] uri = "ldap://identity" ; URI of remote LDAP server diff --git a/resources/autoload.php b/resources/autoload.php index 158db4bc..8affc966 100644 --- a/resources/autoload.php +++ b/resources/autoload.php @@ -24,6 +24,7 @@ require_once __DIR__ . "/lib/UnityWebhook.php"; require_once __DIR__ . "/lib/UnityGithub.php"; require_once __DIR__ . "/lib/utils.php"; +require_once __DIR__ . "/lib/CSRFToken.php"; require_once __DIR__ . "/lib/exceptions/NoDieException.php"; require_once __DIR__ . "/lib/exceptions/SSOException.php"; require_once __DIR__ . "/lib/exceptions/ArrayKeyException.php"; diff --git a/resources/init.php b/resources/init.php index f23f2c2d..b4c2f20d 100644 --- a/resources/init.php +++ b/resources/init.php @@ -21,8 +21,6 @@ set_error_handler(["UnityWebPortal\lib\UnityHTTPD", "errorHandler"]); } -session_start(); - if (isset($GLOBALS["ldapconn"])) { $LDAP = $GLOBALS["ldapconn"]; } else { @@ -34,10 +32,24 @@ $WEBHOOK = new UnityWebhook(); $GITHUB = new UnityGithub(); +session_start(); +// https://stackoverflow.com/a/1270960/18696276 +if (time() - ($_SESSION["LAST_ACTIVITY"] ?? 0) > CONFIG["site"]["session_cleanup_idle_seconds"]) { + $_SESSION["csrf_tokens"] = []; + $_SESSION["messages"] = []; + session_write_close(); + session_start(); +} +$_SESSION["LAST_ACTIVITY"] = time(); + if (!array_key_exists("messages", $_SESSION)) { $_SESSION["messages"] = []; } +if (!array_key_exists("csrf_tokens", $_SESSION)) { + $_SESSION["csrf_tokens"] = []; +} + if (isset($_SERVER["REMOTE_USER"])) { // Check if SSO is enabled on this page $SSO = UnitySSO::getSSO(); diff --git a/resources/lib/CSRFToken.php b/resources/lib/CSRFToken.php new file mode 100644 index 00000000..730539fb --- /dev/null +++ b/resources/lib/CSRFToken.php @@ -0,0 +1,67 @@ + $_SESSION], + ); + $_SESSION["csrf_tokens"] = []; + } + if (!is_array($_SESSION["csrf_tokens"])) { + UnityHTTPD::errorLog( + "invalid session", + '$_SESSION["csrf_tokens"] is not an array', + data: ['$_SESSION' => $_SESSION], + ); + $_SESSION["csrf_tokens"] = []; + } + } + + public static function generate(): string + { + self::ensureSessionCSRFTokensSanity(); + $token = bin2hex(random_bytes(32)); + $_SESSION["csrf_tokens"][$token] = false; + return $token; + } + + public static function validate(string $token): bool + { + self::ensureSessionCSRFTokensSanity(); + if ($token === "") { + UnityHTTPD::errorLog("empty CSRF token", ""); + return false; + } + if (!array_key_exists($token, $_SESSION["csrf_tokens"])) { + UnityHTTPD::errorLog("unknown CSRF token", $token); + return false; + } + $entry = $_SESSION["csrf_tokens"][$token]; + if ($entry === true) { + UnityHTTPD::errorLog("reused CSRF token", $token); + return false; + } + $_SESSION["csrf_tokens"][$token] = true; + return true; + } + + public static function clear(): void + { + if (!isset($_SESSION)) { + return; + } + if (array_key_exists("csrf_tokens", $_SESSION)) { + unset($_SESSION["csrf_tokens"]); + } + $_SESSION["csrf_tokens"] = []; + } +} diff --git a/resources/lib/UnityHTTPD.php b/resources/lib/UnityHTTPD.php index 016bedda..7952a176 100644 --- a/resources/lib/UnityHTTPD.php +++ b/resources/lib/UnityHTTPD.php @@ -390,4 +390,18 @@ public static function deleteMessage(UnityHTTPDMessageLevel $level, string $titl unset($_SESSION["messages"][$index]); $_SESSION["messages"] = array_values($_SESSION["messages"]); } + + public static function validatePostCSRFToken(): void + { + $token = self::getPostData("csrf_token"); + if (!CSRFToken::validate($token)) { + self::badRequest("CSRF token validation failed", data: ["token" => $token]); + } + } + + public static function getCSRFTokenHiddenFormInput(): string + { + $token = htmlspecialchars(CSRFToken::generate()); + return ""; + } } diff --git a/resources/templates/header.php b/resources/templates/header.php index bc055b1f..3ab4d742 100644 --- a/resources/templates/header.php +++ b/resources/templates/header.php @@ -3,6 +3,8 @@ use UnityWebPortal\lib\UnityHTTPD; if ($_SERVER["REQUEST_METHOD"] == "POST") { + // another page should have already validated and we can't validate the same token twice + // UnityHTTPD::validatePostCSRFToken(); if ( ($_SESSION["is_admin"] ?? false) == true && ($_POST["form_type"] ?? null) == "clearView" @@ -179,10 +181,12 @@ && isset($_SESSION["viewUser"]) ) { $viewUser = $_SESSION["viewUser"]; + $CSRFTokenHiddenFormInput = UnityHTTPD::getCSRFTokenHiddenFormInput(); echo "