CVE-2022-2930 (published 2022-08-22T12:15Z)

Unverified Password Change in GitHub repository octoprint/octoprint prior to 1.8.3.

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

Weakness information:
cwe_id: CWE-620
cwe_name: Unverified Password Change
description: When setting a new password for a user, the product does not require knowledge of the original password, or using another form of authentication.
extended_description: This could be used by an attacker to change passwords for another user, thus gaining the privileges associated with that user.
         

Fix information:
repo_url: https://github.com/octoprint/octoprint
commit_details: repo_url: https://github.com/octoprint/octoprint
author: Gina Häußge
author_date: 2022-08-18 17:23:11+02:00
msg: 🔒️ Require the current password for changing it

Admins may still change existing user account passwords without
having to enter the current one, however the regular user oriented
password change dialog has been adjusted to require entry of the
current password. The API has been locked down accordingly and
the password change endpoint has seen a small change due to that,
please refer to the updated documentation.
repository: repo_url: https://github.com/octoprint/octoprint
repo_name: OctoPrint/OctoPrint
description: OctoPrint is the snappy web interface for your 3D printer!
repo_language: Python
owner: octoprint
file_changes: filename: access.rst
change_type: ModificationType.MODIFY
diff: @@ -238,16 +238,22 @@ Change a user's password
 
    Changes the password of a user.
 
-   Expects a JSON object with a single property ``password`` as request body.
+   Expects a JSON object with a property ``password`` containing the new password as
+   request body. Without the ``SETTINGS`` permission, an additional property ``current``
+   is also required to be set on the request body, containing the user's current password.
 
-   Requires the ``SETTINGS`` permission or to be logged in as the user.
+   Requires the ``SETTINGS`` permission or to be logged in as the user. Note that ``current``
+   will be evaluated even in presence of the ``SETTINGS`` permission, if set.
 
    :param username: Name of the user to change the password for
    :json password:  The new password to set
+   :json current:   The current password
    :status 200:     No error
-   :status 400:     If the request doesn't contain a ``password`` property or the request
+   :status 400:     If the request doesn't contain a ``password`` property, doesn't
+                    contain a ``current`` property even though required, or the request
                     is otherwise invalid
-   :status 403:     No admin rights and not logged in as the user
+   :status 403:     No admin rights, not logged in as the user or a current password
+                    mismatch
    :status 404:     The user is unknown
 
 .. _sec-api-access-users-settings-get:

programming_language: Markdown
filename: access.py
change_type: ModificationType.MODIFY
diff: @@ -241,7 +241,10 @@ def change_password_for_user(username):
     if (
         current_user is not None
         and not current_user.is_anonymous
-        and (current_user.get_name() == username or current_user.is_admin)
+        and (
+            current_user.get_name() == username
+            or current_user.has_permission(Permissions.SETTINGS)
+        )
     ):
         if "application/json" not in request.headers["Content-Type"]:
             abort(400, description="Expected content-type JSON")
@@ -252,7 +255,14 @@ def change_password_for_user(username):
             abort(400, description="Malformed JSON body in request")
 
         if "password" not in data or not data["password"]:
-            abort(400, description="password is missing")
+            abort(400, description="new password is missing")
+
+        if not current_user.has_permission(Permissions.SETTINGS) or "current" in data:
+            if "current" not in data or not data["current"]:
+                abort(400, description="current password is missing")
+
+            if not userManager.check_password(username, data["current"]):
+                abort(403, description="Invalid current password")
 
         try:
             userManager.change_user_password(username, data["password"])

programming_language: Python
filename: access.js
change_type: ModificationType.MODIFY
diff: @@ -176,17 +176,26 @@
     OctoPrintAccessUsersClient.prototype.changePassword = function (
         name,
         password,
+        oldpw,
         opts
     ) {
+        if (_.isObject(oldpw)) {
+            opts = oldpw;
+            oldpw = undefined;
+        }
+
         if (!name || !password) {
             throw new OctoPrintClient.InvalidArgumentError(
-                "user name and password must be set"
+                "user name and new password must be set"
             );
         }
 
         var data = {
             password: password
         };
+        if (oldpw) {
+            data["current"] = oldpw;
+        }
         return this.base.putJson(this.url(name, "password"), data, opts);
     };
 

programming_language: JavaScript
filename: access.js
change_type: ModificationType.MODIFY
diff: @@ -40,10 +40,12 @@ $(function () {
                 groups: ko.observableArray([]),
                 permissions: ko.observableArray([]),
                 password: ko.observable(undefined),
+                currentPassword: ko.observable(undefined),
                 repeatedPassword: ko.observable(undefined),
                 passwordMismatch: ko.pureComputed(function () {
                     return self.editor.password() !== self.editor.repeatedPassword();
                 }),
+                currentPasswordMismatch: ko.observable(false),
                 apikey: ko.observable(undefined),
                 active: ko.observable(undefined),
                 permissionSelectable: function (permission) {
@@ -128,6 +130,11 @@ $(function () {
                 }
                 self.editor.password(undefined);
                 self.editor.repeatedPassword(undefined);
+                self.editor.currentPassword(undefined);
+                self.editor.currentPasswordMismatch(false);
+            });
+            self.editor.currentPassword.subscribe(function () {
+                self.editor.currentPasswordMismatch(false);
             });
 
             self.requestData = function () {
@@ -244,13 +251,21 @@ $(function () {
             self.confirmChangePassword = function () {
                 if (!CONFIG_ACCESS_CONTROL) return;
 
-                self.updatePassword(self.currentUser().name, self.editor.password()).done(
-                    function () {
+                self.updatePassword(
+                    self.currentUser().name,
+                    self.editor.password(),
+                    self.editor.currentPassword()
+                )
+                    .done(function () {
                         // close dialog
                         self.currentUser(undefined);
                         self.changePasswordDialog.modal("hide");
-                    }
-                );
+                    })
+                    .fail(function (xhr) {
+                        if (xhr.status === 403) {
+                            self.currentPasswordMismatch(true);
+                        }
+                    });
             };
 
             self.confirmGenerateApikey = function () {
@@ -349,8 +364,8 @@ $(function () {
                     .done(self.fromResponse);
             };
 
-            self.updatePassword = function (username, password) {
-                return OctoPrint.access.users.changePassword(username, password);
+            self.updatePassword = function (username, password, current) {
+                return OctoPrint.access.users.changePassword(username, password, current);
             };
 
             self.generateApikey = function (username) {

programming_language: JavaScript
filename: usersettings.js
change_type: ModificationType.MODIFY
diff: @@ -25,6 +25,8 @@ $(function () {
 
         self.access_password = ko.observable(undefined);
         self.access_repeatedPassword = ko.observable(undefined);
+        self.access_currentPassword = ko.observable(undefined);
+        self.access_currentPasswordMismatch = ko.observable(false);
         self.access_apikey = ko.observable(undefined);
         self.interface_language = ko.observable(undefined);
 
@@ -32,6 +34,8 @@ $(function () {
         self.currentUser.subscribe(function (newUser) {
             self.access_password(undefined);
             self.access_repeatedPassword(undefined);
+            self.access_currentPassword(undefined);
+            self.access_currentPasswordMismatch(false);
             self.access_apikey(undefined);
             self.interface_language("_default");
 
@@ -45,6 +49,9 @@ $(function () {
                 }
             }
         });
+        self.access_currentPassword.subscribe(function () {
+            self.access_currentPasswordMismatch(false);
+        });
 
         self.passwordMismatch = ko.pureComputed(function () {
             return self.access_password() !== self.access_repeatedPassword();
@@ -81,25 +88,38 @@ $(function () {
 
             self.userSettingsDialog.trigger("beforeSave");
 
-            if (self.access_password() && !self.passwordMismatch()) {
-                self.users.updatePassword(
-                    self.currentUser().name,
-                    self.access_password(),
-                    function () {}
-                );
+            function process() {
+                var settings = {
+                    interface: {
+                        language: self.interface_language()
+                    }
+                };
+                self.updateSettings(self.currentUser().name, settings).done(function () {
+                    // close dialog
+                    self.currentUser(undefined);
+                    self.userSettingsDialog.modal("hide");
+                    self.loginState.reloadUser();
+                });
             }
 
-            var settings = {
-                interface: {
-                    language: self.interface_language()
-                }
-            };
-            self.updateSettings(self.currentUser().name, settings).done(function () {
-                // close dialog
-                self.currentUser(undefined);
-                self.userSettingsDialog.modal("hide");
-                self.loginState.reloadUser();
-            });
+            if (self.access_password() && !self.passwordMismatch()) {
+                self.users
+                    .updatePassword(
+                        self.currentUser().name,
+                        self.access_password(),
+                        self.access_currentPassword()
+                    )
+                    .done(function () {
+                        process();
+                    })
+                    .fail(function (xhr) {
+                        if (xhr.status === 403) {
+                            self.access_currentPasswordMismatch(true);
+                        }
+                    });
+            } else {
+                process();
+            }
         };
 
         self.copyApikey = function () {

programming_language: JavaScript
filename: access.jinja2
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,13 @@
         <p>
             {{ _('If you do not wish to change your password, just leave the following fields empty.') }}
         </p>
+        <div class="control-group" data-bind="css: {error: access_currentPasswordMismatch()}">
+            <label class="control-label" for="userSettings-access_currentPassword">{{ _('Current Password') }}</label>
+            <div class="controls">
+                <input type="password" class="input-block-level" id="userSettings-access_currentPassword" data-bind="value: access_currentPassword, valueUpdate: 'afterkeydown'" required>
+                <span class="help-inline" data-bind="visible: access_currentPasswordMismatch()">{{ _('Passwords do not match') }}</span>
+            </div>
+        </div>
         <div class="control-group">
             <label class="control-label" for="userSettings-access_password">{{ _('New Password') }}</label>
             <div class="controls">

programming_language: HTML