CVE-2022-2821 (published 2022-08-15T11:21Z)

Missing Critical Step in Authentication in GitHub repository namelessmc/nameless prior to v2.0.2.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-304
cwe_name: Missing Critical Step in Authentication
description: The product implements an authentication technique, but it skips a step that weakens the technique.
extended_description: Authentication techniques should follow the algorithms that define them exactly, otherwise authentication can be bypassed or more easily subjected to brute force attacks.
         

Fix information:
repo_url: https://github.com/namelessmc/nameless
commit_details: repo_url: https://github.com/namelessmc/nameless
author: Sam
author_date: 2022-08-07 20:32:07+01:00
msg: Add ability to rate limit via Validate class (#2998)
repository: repo_url: https://github.com/namelessmc/nameless
repo_name: NamelessMC/Nameless
description: NamelessMC is a free, easy to use & powerful website software for your Minecraft server, which includes a large range of features.
repo_language: PHP
owner: namelessmc
file_changes: filename: Validate.php
change_type: ModificationType.MODIFY
diff: @@ -81,6 +81,11 @@ class Validate {
      */
     public const NOT_START_WITH = 'not_start_with';
 
+    /**
+     * @var string Set a rate limit
+     */
+    public const RATE_LIMIT = 'rate_limit';
+
     private DB $_db;
 
     private ?string $_message = null;
@@ -112,6 +117,7 @@ class Validate {
      * @param array $items subset of inputs to be validated
      *
      * @return Validate New instance of Validate.
+     * @throws Exception If provided configuration for a rule is invalid - not if a provided value is invalid!
      */
     public static function check(array $source, array $items = []): Validate {
         $validator = new Validate();
@@ -318,6 +324,51 @@ class Validate {
                             break;
                         }
                         break;
+
+                    case self::RATE_LIMIT:
+                        if (is_array($rule_value) && count($rule_value) === 2) {
+                            // If array treat as [limit, seconds]
+                            [$limit, $seconds] = $rule_value;
+                        } else if (is_int($rule_value)) {
+                            // If integer default seconds to 60
+                            [$limit, $seconds] = [$rule_value, 60];
+                        }
+
+                        if (!isset($limit) || !isset($seconds)) {
+                            throw new Exception('Invalid rate limit configuration');
+                        }
+
+                        $key = "rate_limit_{$item}";
+                        $session = $_SESSION[$key];
+                        $time = date('U');
+                        $limit_end = $time + $seconds;
+
+                        if (isset($session) && is_array($session) && count($session) === 2) {
+                            [$count, $expires] = $session;
+                            $diff = $expires - $time;
+
+                            if (++$count >= $limit && $diff > 0) {
+                                $validator->addError([
+                                    'field' => $item,
+                                    'rule' => self::RATE_LIMIT,
+                                    'fallback' => "$item has reached the rate limit which expires in $diff seconds.",
+                                    'meta' => ['expires' => $diff],
+                                ]);
+                                break;
+                            }
+
+                            if ($diff <= 0) {
+                                // Reset
+                                $_SESSION[$key] = [1, $limit_end];
+                                break;
+                            }
+
+                            $_SESSION[$key] = [$count, $expires];
+                        } else {
+                            $_SESSION[$key] = [1, $limit_end];
+                        }
+
+                        break;
                 }
             }
         }
@@ -379,7 +430,7 @@ class Validate {
         // Loop all errors to convert and get their custom messages
         foreach ($this->_to_convert as $error) {
 
-            $message = $this->getMessage($error['field'], $error['rule'], $error['fallback']);
+            $message = $this->getMessage($error['field'], $error['rule'], $error['fallback'], $error['meta']);
 
             // If there is no generic `message()` set or the translated message is not equal to generic message
             // we can continue without worrying about duplications
@@ -409,10 +460,11 @@ class Validate {
      * @param string $field name of field to search for.
      * @param string $rule rule which check failed. should be from the constants defined above.
      * @param string $fallback fallback default message if custom message and generic message are not supplied.
+     * @param ?array $meta optional meta to provide to message.
      *
      * @return string Message for this field and rule.
      */
-    private function getMessage(string $field, string $rule, string $fallback): string {
+    private function getMessage(string $field, string $rule, string $fallback, ?array $meta = []): string {
 
         // No custom messages defined for this field
         if (!isset($this->_messages[$field])) {
@@ -436,6 +488,11 @@ class Validate {
             return $this->_message ?? $fallback;
         }
 
+        // If the message is a callback function, provide it with meta
+        if (is_callable($this->_messages[$field][$rule])) {
+            return $this->_messages[$field][$rule]($meta);
+        }
+
         // Rule-specific custom message was supplied
         return $this->_messages[$field][$rule];
     }

programming_language: PHP
filename: en_UK.json
change_type: ModificationType.MODIFY
diff: @@ -798,6 +798,7 @@
     "general/previous": "Previous",
     "general/privacy_policy": "Privacy Policy",
     "general/profile": "Profile",
+    "general/rate_limit": "Please try again in {{expires}} seconds",
     "general/register": "Register",
     "general/remove": "Remove",
     "general/report": "Report",

programming_language: JavaScript
filename: login.php
change_type: ModificationType.MODIFY
diff: @@ -54,12 +54,15 @@ if (Input::exists()) {
                 unset($_SESSION['remember'], $_SESSION['password'], $_SESSION['tfa']);
             }
 
+            $rate_limit = [5, 60]; // 5 attempts in 60 seconds - TODO allow this to be customised?
+
             if ($login_method == 'email') {
                 $to_validate = [
                     'email' => [
                         Validate::REQUIRED => true,
                         Validate::IS_BANNED => true,
-                        Validate::IS_ACTIVE => true
+                        Validate::IS_ACTIVE => true,
+                        Validate::RATE_LIMIT => $rate_limit,
                     ],
                     'password' => [
                         Validate::REQUIRED => true
@@ -70,7 +73,8 @@ if (Input::exists()) {
                     'username' => [
                         Validate::REQUIRED => true,
                         Validate::IS_BANNED => true,
-                        Validate::IS_ACTIVE => true
+                        Validate::IS_ACTIVE => true,
+                        Validate::RATE_LIMIT => $rate_limit,
                     ],
                     'password' => [
                         Validate::REQUIRED => true
@@ -82,12 +86,14 @@ if (Input::exists()) {
                 'email' => [
                     Validate::REQUIRED => $language->get('user', 'must_input_email'),
                     Validate::IS_BANNED => $language->get('user', 'account_banned'),
-                    Validate::IS_ACTIVE => $language->get('user', 'inactive_account')
+                    Validate::IS_ACTIVE => $language->get('user', 'inactive_account'),
+                    Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta),
                 ],
                 'username' => [
                     Validate::REQUIRED => ($login_method == 'username' ? $language->get('user', 'must_input_username') : $language->get('user', 'must_input_email_or_username')),
                     Validate::IS_BANNED => $language->get('user', 'account_banned'),
-                    Validate::IS_ACTIVE => $language->get('user', 'inactive_account')
+                    Validate::IS_ACTIVE => $language->get('user', 'inactive_account'),
+                    Validate::RATE_LIMIT => fn($meta) => $language->get('general', 'rate_limit', $meta),
                 ],
                 'password' => $language->get('user', 'must_input_password')
             ]);

programming_language: PHP