CVE-2024-2213 (published 2024-06-06T19:15Z)

An issue was discovered in zenml-io/zenml versions up to and including 0.55.4. Due to improper authentication mechanisms, an attacker with access to an active user session can change the account password without needing to know the current password. This vulnerability allows for unauthorized account takeover by bypassing the standard password change verification process. The issue was fixed in version 0.56.3.


Weakness information:
cwe_id: CWE-287
cwe_name: Improper Authentication
description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
extended_description: 
            
			
         
         

Fix information:
repo_url: https://github.com/zenml-io/zenml
commit_details: repo_url: https://github.com/zenml-io/zenml
author: Stefan Nica
author_date: 2024-04-09 09:22:03+02:00
msg: Check old password during password change and add missing CLI commands (#2587)

* Check old password during password change and add missing CLI commands

* Fix docstrings

* Ask to re-enter password during CLI password change

* Auto-update of E2E template

* Auto-update of LLM Finetuning template

* Auto-update of NLP template

---------

Co-authored-by: GitHub Actions <actions@github.com>
repository: repo_url: https://github.com/zenml-io/zenml
repo_name: zenml-io/zenml
description: ZenML 🙏: Build portable, production-ready MLOps pipelines. https://zenml.io.
repo_language: Python
owner: zenml-io
file_changes: filename: server.py
change_type: ModificationType.MODIFY
diff: @@ -32,7 +32,7 @@ from zenml.client import Client
 from zenml.config.global_config import GlobalConfiguration
 from zenml.console import console
 from zenml.enums import ServerProviderType, StoreType
-from zenml.exceptions import IllegalOperationError
+from zenml.exceptions import AuthorizationException, IllegalOperationError
 from zenml.logger import get_logger
 from zenml.utils import terraform_utils, yaml_utils
 from zenml.zen_server.utils import get_active_deployment
@@ -624,12 +624,6 @@ def status() -> None:
     required=False,
     type=str,
 )
-@click.option(
-    "--workspace",
-    help="The workspace to use when connecting to the ZenML server.",
-    required=False,
-    type=str,
-)
 @click.option(
     "--no-verify-ssl",
     is_flag=True,
@@ -661,7 +655,6 @@ def connect(
     username: Optional[str] = None,
     password: Optional[str] = None,
     api_key: Optional[str] = None,
-    workspace: Optional[str] = None,
     no_verify_ssl: bool = False,
     ssl_ca_cert: Optional[str] = None,
     config: Optional[str] = None,
@@ -677,8 +670,6 @@ def connect(
             server.
         api_key: The API key that is used to authenticate with the ZenML
             server.
-        workspace: The active workspace that is used to connect to the ZenML
-            server.
         no_verify_ssl: Whether to verify the server's TLS certificate.
         ssl_ca_cert: A path to a CA bundle to use to verify the server's TLS
             certificate or the CA bundle value itself.
@@ -689,6 +680,12 @@ def connect(
     from zenml.config.store_config import StoreConfiguration
     from zenml.zen_stores.base_zen_store import BaseZenStore
 
+    if password is not None:
+        cli_utils.warning(
+            "Supplying password values in the command line is not safe. "
+            "Please consider using the prompt option."
+        )
+
     # Raise an error if a local server is running when trying to connect to
     # another server
     active_deployment = get_active_deployment(local=True)
@@ -767,6 +764,16 @@ def connect(
             username = click.prompt("Username", type=str)
 
     if username:
+        cli_utils.warning(
+            "Connecting to a ZenML server using a username and password is "
+            "not recommended because the password is locally stored on your "
+            "filesystem. You should consider using the web login workflow by "
+            "omitting the `--username` and `--password` flags. An alternative "
+            "for non-interactive environments is to create and use a service "
+            "account API key (see https://docs.zenml.io/user-guide/advanced-guide/configuring-zenml/connecting-to-zenml#using-service-accounts-to-connect-to-a-deployed-zenml-server "
+            "for more information)."
+        )
+
         store_dict["username"] = username
 
         if password is None:
@@ -790,16 +797,8 @@ def connect(
             f"User '{username}' does not have sufficient permissions to "
             f"access the server at '{url}'."
         )
-
-    if workspace:
-        try:
-            Client().set_active_workspace(workspace_name_or_id=workspace)
-        except KeyError:
-            cli_utils.warning(
-                f"The workspace {workspace} does not exist or is not accessible. "
-                f"Please set another workspace by running `zenml "
-                f"workspace set`."
-            )
+    except AuthorizationException as e:
+        cli_utils.warning(f"Authorization error: {e}")
 
 
 @cli.command("disconnect", help="Disconnect from a ZenML server.")

programming_language: Python
filename: user_management.py
change_type: ModificationType.MODIFY
diff: @@ -21,9 +21,14 @@ from zenml.cli import utils as cli_utils
 from zenml.cli.cli import TagGroup, cli
 from zenml.cli.utils import is_sorted_or_filtered, list_options
 from zenml.client import Client
+from zenml.config.global_config import GlobalConfiguration
 from zenml.console import console
 from zenml.enums import CliCategories, StoreType
-from zenml.exceptions import EntityExistsError, IllegalOperationError
+from zenml.exceptions import (
+    AuthorizationException,
+    EntityExistsError,
+    IllegalOperationError,
+)
 from zenml.models import UserFilter
 
 
@@ -156,6 +161,11 @@ def create_user(
                 default="",
                 hide_input=True,
             )
+    else:
+        cli_utils.warning(
+            "Supplying password values in the command line is not safe. "
+            "Please consider using the prompt option."
+        )
 
     try:
         new_user = client.create_user(
@@ -204,14 +214,6 @@ def create_user(
     required=False,
     help="New user email.",
 )
-@click.option(
-    "--password",
-    "-p",
-    "updated_password",
-    type=str,
-    required=False,
-    help="New user password.",
-)
 @click.option(
     "--admin",
     "-a",
@@ -230,14 +232,22 @@ def create_user(
     default=None,
     help="Whether the user should be a regular user.",
 )
+@click.option(
+    "--active",
+    "active",
+    type=bool,
+    required=False,
+    default=None,
+    help="Use to activate or deactivate a user account.",
+)
 def update_user(
     user_name_or_id: str,
     updated_name: Optional[str] = None,
     updated_full_name: Optional[str] = None,
     updated_email: Optional[str] = None,
-    updated_password: Optional[str] = None,
     make_admin: Optional[bool] = None,
     make_user: Optional[bool] = None,
+    active: Optional[bool] = None,
 ) -> None:
     """Update an existing user.
 
@@ -246,9 +256,9 @@ def update_user(
         updated_name: The name of the user to create.
         updated_full_name: The name of the user to create.
         updated_email: The name of the user to create.
-        updated_password: The name of the user to create.
         make_admin: Whether the user should be an admin.
         make_user: Whether the user should be a regular user.
+        active: Use to activate or deactivate a user account.
     """
     if make_admin is not None and make_user is not None:
         cli_utils.error(
@@ -260,7 +270,8 @@ def update_user(
         )
         if current_user.is_admin and make_user:
             confirmation = cli_utils.confirmation(
-                f"Currently user `{current_user.name}` is an admin. Are you sure you want to make them a regular user?"
+                f"Currently user `{current_user.name}` is an admin. Are you "
+                "sure you want to make them a regular user?"
             )
             if not confirmation:
                 cli_utils.declare("User update canceled.")
@@ -276,13 +287,137 @@ def update_user(
             updated_name=updated_name,
             updated_full_name=updated_full_name,
             updated_email=updated_email,
-            updated_password=updated_password,
             updated_is_admin=updated_is_admin,
+            active=active,
         )
     except (KeyError, IllegalOperationError) as err:
         cli_utils.error(str(err))
 
 
+@user.command(
+    "change-password",
+    help="Change the password for the current user account.",
+)
+@click.option(
+    "--password",
+    help=(
+        "The new user password. If omitted, a prompt will be shown to enter "
+        "the password."
+    ),
+    required=False,
+    type=str,
+)
+@click.option(
+    "--old-password",
+    help=(
+        "The old user password. If omitted, a prompt will be shown to enter "
+        "the old password."
+    ),
+    required=False,
+    type=str,
+)
+def change_user_password(
+    password: Optional[str] = None, old_password: Optional[str] = None
+) -> None:
+    """Change the password of the current user.
+
+    Args:
+        password: The new password for the current user.
+        old_password: The old password for the current user.
+    """
+    active_user = Client().active_user
+
+    if old_password is not None or password is not None:
+        cli_utils.warning(
+            "Supplying password values in the command line is not safe. "
+            "Please consider using the prompt option."
+        )
+
+    if old_password is None:
+        old_password = click.prompt(
+            f"Current password for user {active_user.name}",
+            hide_input=True,
+        )
+    if password is None:
+        password = click.prompt(
+            f"New password for user {active_user.name}",
+            hide_input=True,
+        )
+        password_again = click.prompt(
+            f"Please re-enter the new password for user {active_user.name}",
+            hide_input=True,
+        )
+        if password != password_again:
+            cli_utils.error("Passwords do not match.")
+
+    try:
+        Client().update_user(
+            name_id_or_prefix=active_user.id,
+            old_password=old_password,
+            updated_password=password,
+        )
+    except (KeyError, IllegalOperationError, AuthorizationException) as err:
+        cli_utils.error(str(err))
+
+    cli_utils.declare(
+        f"Successfully updated password for active user '{active_user.name}'."
+    )
+
+    store = GlobalConfiguration().store_configuration
+    if store.type == StoreType.REST:
+        from zenml.zen_stores.rest_zen_store import RestZenStoreConfiguration
+
+        assert isinstance(store, RestZenStoreConfiguration)
+
+        if store.password is not None:
+            cli_utils.declare(
+                "You may need to log in again with your new password by "
+                "running `zenml connect`."
+            )
+
+
+@user.command(
+    "deactivate",
+    help="Generate an activation token to reset the password for a user account",
+)
+@click.argument("user_name_or_id", type=str, required=True)
+def deactivate_user(
+    user_name_or_id: str,
+) -> None:
+    """Reset the password of a user.
+
+    Args:
+        user_name_or_id: The name or ID of the user to reset the password for.
+    """
+    client = Client()
+
+    store = GlobalConfiguration().store_configuration
+    if store.type != StoreType.REST:
+        cli_utils.error(
+            "Deactivating users is only supported when connected to a ZenML "
+            "server."
+        )
+
+    try:
+        if not client.active_user.is_admin:
+            cli_utils.error(
+                "Only admins can reset the password of other users."
+            )
+
+        user = client.deactivate_user(
+            name_id_or_prefix=user_name_or_id,
+        )
+    except (KeyError, IllegalOperationError) as err:
+        cli_utils.error(str(err))
+
+    cli_utils.declare(
+        f"Successfully deactivated user account '{user.name}'."
+        f"To reactivate the account, please visit the dashboard at the "
+        "following URL:\n"
+        f"{client.zen_store.url}/signup?user={str(user.id)}&username={user.name}&token={user.activation_token}\n"
+    )
+
+
 @user.command("delete")
 @click.argument("user_name_or_id", type=str, required=True)
 def delete_user(user_name_or_id: str) -> None:

programming_language: Python
filename: client.py
change_type: ModificationType.MODIFY
diff: @@ -814,7 +814,9 @@ class Client(metaclass=ClientMetaClass):
         updated_email_opt_in: Optional[bool] = None,
         updated_hub_token: Optional[str] = None,
         updated_password: Optional[str] = None,
+        old_password: Optional[str] = None,
         updated_is_admin: Optional[bool] = None,
+        active: Optional[bool] = None,
     ) -> UserResponse:
         """Update a user.
 
@@ -826,10 +828,17 @@ class Client(metaclass=ClientMetaClass):
             updated_email_opt_in: The new email opt-in status of the user.
             updated_hub_token: Update the hub token
             updated_password: The new password of the user.
+            old_password: The old password of the user. Required for password
+                update.
             updated_is_admin: Whether the user should be an admin.
+            active: Use to activate or deactivate the user.
 
         Returns:
             The updated user.
+
+        Raises:
+            ValidationError: If the old password is not provided when updating
+                the password.
         """
         user = self.get_user(
             name_id_or_prefix=name_id_or_prefix, allow_name_prefix_match=False
@@ -848,13 +857,36 @@ class Client(metaclass=ClientMetaClass):
             user_update.hub_token = updated_hub_token
         if updated_password is not None:
             user_update.password = updated_password
+            if old_password is None:
+                raise ValidationError(
+                    "Old password is required to update the password."
+                )
+            user_update.old_password = old_password
         if updated_is_admin is not None:
             user_update.is_admin = updated_is_admin
+        if active is not None:
+            user_update.active = active
 
         return self.zen_store.update_user(
             user_id=user.id, user_update=user_update
         )
 
+    @_fail_for_sql_zen_store
+    def deactivate_user(self, name_id_or_prefix: str) -> "UserResponse":
+        """Deactivate a user and generate an activation token.
+
+        Args:
+            name_id_or_prefix: The name or ID of the user to reset.
+
+        Returns:
+            The deactivated user.
+        """
+        from zenml.zen_stores.rest_zen_store import RestZenStore
+
+        user = self.get_user(name_id_or_prefix, allow_name_prefix_match=False)
+        assert isinstance(self.zen_store, RestZenStore)
+        return self.zen_store.deactivate_user(user_name_or_id=user.name)
+
     def delete_user(self, name_id_or_prefix: str) -> None:
         """Delete a user.
 

programming_language: Python
filename: user.py
change_type: ModificationType.MODIFY
diff: @@ -201,6 +201,12 @@ class UserUpdate(UserBase, BaseZenModel):
     active: Optional[bool] = Field(
         default=None, title="Whether the account is active."
     )
+    old_password: Optional[str] = Field(
+        default=None,
+        title="The previous password for the user. Only relevant for user "
+        "accounts. Required when updating the password.",
+        max_length=STR_FIELD_MAX_LENGTH,
+    )
 
     @root_validator
     def user_email_updates(cls, values: Dict[str, Any]) -> Dict[str, Any]:

programming_language: Python
filename: users_endpoints.py
change_type: ModificationType.MODIFY
diff: @@ -32,6 +32,7 @@ from zenml.exceptions import AuthorizationException, IllegalOperationError
 from zenml.logger import get_logger
 from zenml.models import (
     Page,
+    UserAuthModel,
     UserFilter,
     UserRequest,
     UserResponse,
@@ -253,7 +254,10 @@ if server_config().auth_scheme != AuthScheme.EXTERNAL:
 
         Raises:
             IllegalOperationError: if the user tries change admin status,
-                while not an admin
+                while not an admin, if the user tries to change the password
+                of another user, or if the user tries to change their own
+                password without providing the old password or providing
+                an incorrect old password.
         """
         user = zen_store().get_user(user_name_or_id)
         if user.id != auth_context.user.id:
@@ -264,6 +268,29 @@ if server_config().auth_scheme != AuthScheme.EXTERNAL:
                 user,
                 action=Action.UPDATE,
             )
+
+            if user_update.password is not None:
+                raise IllegalOperationError(
+                    "Users cannot change the password of other users. Use the "
+                    "account deactivation and activation flow instead."
+                )
+
+        elif user_update.password is not None:
+            # If the user is updating their own password, we need to verify
+            # the old password
+            if user_update.old_password is None:
+                raise IllegalOperationError(
+                    "The current password must be supplied when changing the "
+                    "password."
+                )
+            auth_user = zen_store().get_auth_user(user_name_or_id)
+            if not UserAuthModel.verify_password(
+                user_update.old_password, auth_user
+            ):
+                raise IllegalOperationError(
+                    "The current password is incorrect."
+                )
+
         if (
             user_update.is_admin is not None
             and user.is_admin != user_update.is_admin
@@ -512,8 +539,27 @@ if server_config().auth_scheme != AuthScheme.EXTERNAL:
 
         Returns:
             The updated user.
+
+        Raises:
+            IllegalOperationError: if the current password is not supplied when
+                changing the password or if the current password is incorrect.
         """
         current_user = zen_store().get_user(auth_context.user.id)
+
+        if user.password is not None:
+            # If the user is updating their password, we need to verify
+            # the old password
+            if user.old_password is None:
+                raise IllegalOperationError(
+                    "The current password must be supplied when changing the "
+                    "password."
+                )
+            auth_user = zen_store().get_auth_user(auth_context.user.id)
+            if not UserAuthModel.verify_password(user.old_password, auth_user):
+                raise IllegalOperationError(
+                    "The current password is incorrect."
+                )
+
         user.activation_token = current_user.activation_token
         user.active = current_user.active
         user.is_admin = current_user.is_admin

programming_language: Python
filename: rest_zen_store.py
change_type: ModificationType.MODIFY
diff: @@ -50,6 +50,7 @@ from zenml.constants import (
     CODE_REFERENCES,
     CODE_REPOSITORIES,
     CURRENT_USER,
+    DEACTIVATE,
     DEFAULT_HTTP_TIMEOUT,
     DEVICES,
     DISABLE_CLIENT_SERVER_MISMATCH_WARNING,
@@ -2943,6 +2944,23 @@ class RestZenStore(BaseZenStore):
             response_model=UserResponse,
         )
 
+    def deactivate_user(
+        self, user_name_or_id: Union[str, UUID]
+    ) -> UserResponse:
+        """Deactivates a user.
+
+        Args:
+            user_name_or_id: The name or ID of the user to delete.
+
+        Returns:
+            The deactivated user containing the activation token.
+        """
+        response_body = self.put(
+            f"{USERS}/{str(user_name_or_id)}{DEACTIVATE}",
+        )
+
+        return UserResponse.parse_obj(response_body)
+
     def delete_user(self, user_name_or_id: Union[str, UUID]) -> None:
         """Deletes a user.
 

programming_language: Python
filename: user_schemas.py
change_type: ModificationType.MODIFY
diff: @@ -206,6 +206,9 @@ class UserSchema(NamedSchema, table=True):
             The updated `UserSchema`.
         """
         for field, value in user_update.dict(exclude_unset=True).items():
+            if field == "old_password":
+                continue
+
             if field == "password":
                 setattr(self, field, user_update.create_hashed_password())
             elif field == "activation_token":

programming_language: Python