CVE-2023-34110 (published 2023-06-22T23:15Z)

Flask-AppBuilder is an application development framework, built on top of Flask. Prior to version 4.3.2, an authenticated malicious actor with Admin privileges, could by adding a special character on the add, edit User forms trigger a database error, this error is surfaced back to this actor on the UI. On certain database engines this error can include the entire user row including the pbkdf2:sha256 hashed password. This vulnerability has been fixed in version 4.3.2.\n

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

Weakness information:
cwe_id: CWE-209
cwe_name: Generation of Error Message Containing Sensitive Information
description: The product generates an error message that includes sensitive information about its environment, users, or associated data.
extended_description: The sensitive information may be valuable information on its own (such as a password), or it may be useful for launching other, more serious attacks. The error message may be created in different ways:
           
             self-generated: the source code explicitly constructs the error message and delivers it
	     externally-generated: the external environment, such as a language interpreter, handles the error and constructs its own message, whose contents are not under direct control by the programmer
	   
	 An attacker may use the contents of error messages to help launch another, more focused attack. For example, an attempt to exploit a path traversal weakness (CWE-22) might yield the full pathname of the installed application. In turn, this could be used to select the proper number of ".." sequences to navigate to the targeted file. An attack using SQL injection (CWE-89) might not initially succeed, but an error message could reveal the malformed query, which would expose query logic and possibly even passwords or other sensitive information used within the query.
         

Fix information:
repo_url: https://github.com/dpgaspar/Flask-AppBuilder
commit_details: repo_url: https://github.com/dpgaspar/Flask-AppBuilder
author: Daniel Vaz Gaspar
author_date: 2023-05-19 15:51:38+01:00
msg: fix: CRUD MVC log message (#2045)

* fix: CRUD MVC log message

* lint

* add tests

* fix lint and tests

* fix lint and tests

* revert babel name refactor

* fix lint
repository: repo_url: https://github.com/dpgaspar/Flask-AppBuilder
repo_name: dpgaspar/Flask-AppBuilder
description: Simple and rapid application development framework, built on top of Flask. includes detailed security, auto CRUD generation for your models, google charts and much more. Demo (login with guest/welcome) - http://flaskappbuilder.pythonanywhere.com/
repo_language: Python
owner: dpgaspar
file_changes: filename: manager.py
change_type: ModificationType.MODIFY
diff: @@ -1,11 +1,10 @@
 import os
 
 from flask import has_request_context, request, session
+from flask_appbuilder.babel.views import LocaleView
+from flask_appbuilder.basemanager import BaseManager
 from flask_babel import Babel
 
-from .views import LocaleView
-from ..basemanager import BaseManager
-
 
 class BabelManager(BaseManager):
 

programming_language: Python
filename: base.py
change_type: ModificationType.MODIFY
diff: @@ -41,6 +41,8 @@ class BaseInterface:
     )
     general_error_message = lazy_gettext("General Error")
 
+    database_error_message = lazy_gettext("Database Error")
+
     """ Tuple with message and text with severity type ex: ("Added Row", "info") """
     message = ()
 
@@ -103,13 +105,13 @@ class BaseInterface:
 
     def _get_values(self, lst, list_columns):
         """
-            Get Values: formats values for list template.
-            returns [{'col_name':'col_value',....},{'col_name':'col_value',....}]
+        Get Values: formats values for list template.
+        returns [{'col_name':'col_value',....},{'col_name':'col_value',....}]
 
-            :param lst:
-                The list of item objects from query
-            :param list_columns:
-                The list of columns to include
+        :param lst:
+            The list of item objects from query
+        :param list_columns:
+            The list of columns to include
         """
         retlst = []
         for item in lst:
@@ -121,13 +123,13 @@ class BaseInterface:
 
     def get_values(self, lst, list_columns):
         """
-            Get Values: formats values for list template.
-            returns [{'col_name':'col_value',....},{'col_name':'col_value',....}]
+        Get Values: formats values for list template.
+        returns [{'col_name':'col_value',....},{'col_name':'col_value',....}]
 
-            :param lst:
-                The list of item objects from query
-            :param list_columns:
-                The list of columns to include
+        :param lst:
+            The list of item objects from query
+        :param list_columns:
+            The list of columns to include
         """
         for item in lst:
             retdict = {}
@@ -137,7 +139,7 @@ class BaseInterface:
 
     def get_values_json(self, lst, list_columns):
         """
-            Converts list of objects from query to JSON
+        Converts list of objects from query to JSON
         """
         result = []
         for item in self.get_values(lst, list_columns):
@@ -264,19 +266,19 @@ class BaseInterface:
 
     def add(self, item):
         """
-            Adds object
+        Adds object
         """
         raise NotImplementedError
 
     def edit(self, item):
         """
-            Edit (change) object
+        Edit (change) object
         """
         raise NotImplementedError
 
     def delete(self, item):
         """
-            Deletes object
+        Deletes object
         """
         raise NotImplementedError
 
@@ -285,7 +287,7 @@ class BaseInterface:
 
     def get_keys(self, lst):
         """
-            return a list of pk values from object list
+        return a list of pk values from object list
         """
         pk_name = self.get_pk_name()
         if self.is_pk_composite():
@@ -295,7 +297,7 @@ class BaseInterface:
 
     def get_pk_name(self):
         """
-            Returns the primary key name
+        Returns the primary key name
         """
         raise NotImplementedError
 
@@ -308,8 +310,8 @@ class BaseInterface:
 
     def get(self, pk, filter=None):
         """
-            return the record from key, you can optionally pass filters
-            if pk exits on the db but filters exclude it it will return none.
+        return the record from key, you can optionally pass filters
+        if pk exits on the db but filters exclude it it will return none.
         """
         pass
 
@@ -318,11 +320,11 @@ class BaseInterface:
 
     def get_related_interface(self, col_name):
         """
-            Returns a BaseInterface for the related model
-            of column name.
+        Returns a BaseInterface for the related model
+        of column name.
 
-            :param col_name: Column name with relation
-            :return: BaseInterface
+        :param col_name: Column name with relation
+        :return: BaseInterface
         """
         raise NotImplementedError
 
@@ -334,25 +336,25 @@ class BaseInterface:
 
     def get_columns_list(self):
         """
-            Returns a list of all the columns names
+        Returns a list of all the columns names
         """
         return []
 
     def get_user_columns_list(self):
         """
-            Returns a list of user viewable columns names
+        Returns a list of user viewable columns names
         """
         return self.get_columns_list()
 
     def get_search_columns_list(self):
         """
-            Returns a list of searchable columns names
+        Returns a list of searchable columns names
         """
         return []
 
     def get_order_columns_list(self, list_columns=None):
         """
-            Returns a list of order columns names
+        Returns a list of order columns names
         """
         return []
 

programming_language: Python
filename: interface.py
change_type: ModificationType.MODIFY
diff: @@ -1,14 +1,11 @@
 # -*- coding: utf-8 -*-
 from contextlib import suppress
 import logging
-import sys
 from typing import Any, Dict, List, Optional, Tuple, Type, Union
 
 from flask_appbuilder._compat import as_unicode
 from flask_appbuilder.const import (
-    LOGMSG_ERR_DBI_ADD_GENERIC,
     LOGMSG_ERR_DBI_DEL_GENERIC,
-    LOGMSG_ERR_DBI_EDIT_GENERIC,
     LOGMSG_WAR_DBI_ADD_INTEGRITY,
     LOGMSG_WAR_DBI_DEL_INTEGRITY,
     LOGMSG_WAR_DBI_EDIT_INTEGRITY,
@@ -736,11 +733,8 @@ class SQLAInterface(BaseInterface):
                 raise e
             return False
         except Exception as e:
-            self.message = (
-                as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
-                "danger",
-            )
-            log.exception(LOGMSG_ERR_DBI_ADD_GENERIC.format(str(e)))
+            self.message = (as_unicode(self.database_error_message), "danger")
+            log.exception("Database error")
             self.session.rollback()
             if raise_exception:
                 raise e
@@ -760,11 +754,8 @@ class SQLAInterface(BaseInterface):
                 raise e
             return False
         except Exception as e:
-            self.message = (
-                as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
-                "danger",
-            )
-            log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC.format(str(e)))
+            self.message = (as_unicode(self.database_error_message), "danger")
+            log.exception("Database error")
             self.session.rollback()
             if raise_exception:
                 raise e
@@ -785,11 +776,8 @@ class SQLAInterface(BaseInterface):
                 raise e
             return False
         except Exception as e:
-            self.message = (
-                as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
-                "danger",
-            )
-            log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e)))
+            self.message = (as_unicode(self.database_error_message), "danger")
+            log.exception("Database error")
             self.session.rollback()
             if raise_exception:
                 raise e
@@ -809,10 +797,7 @@ class SQLAInterface(BaseInterface):
             self.session.rollback()
             return False
         except Exception as e:
-            self.message = (
-                as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
-                "danger",
-            )
+            self.message = (as_unicode(self.database_error_message), "danger")
             log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e)))
             self.session.rollback()
             return False

programming_language: Python
filename: test_mvc_security.py
change_type: ModificationType.MODIFY
diff: @@ -1,3 +1,5 @@
+from unittest.mock import patch
+
 from flask_appbuilder import ModelView
 from flask_appbuilder.exceptions import PasswordComplexityValidationError
 from flask_appbuilder.models.sqla.filters import FilterEqual
@@ -422,3 +424,117 @@ class MVCSecurityTestCase(BaseMVCTestCase):
         )
         self.db.session.delete(user)
         self.db.session.commit()
+
+    def test_edit_user(self):
+        """
+        Test edit user
+        """
+        client = self.app.test_client()
+        _ = self.browser_login(client, USERNAME_ADMIN, PASSWORD_ADMIN)
+
+        _tmp_user = self.create_user(
+            self.appbuilder,
+            "tmp_user",
+            "password1",
+            "",
+            first_name="tmp",
+            last_name="user",
+            email="tmp@fab.org",
+            role_names=["Admin"],
+        )
+
+        # use all required params
+        rv = client.get(f"/users/edit/{_tmp_user.id}", follow_redirects=True)
+        data = rv.data.decode("utf-8")
+        self.assertIn("Edit User", data)
+        rv = client.post(
+            f"/users/edit/{_tmp_user.id}",
+            data=dict(
+                first_name=_tmp_user.first_name,
+                last_name=_tmp_user.last_name,
+                username=_tmp_user.username,
+                email="changed@changed.org",
+                roles=_tmp_user.roles[0].id,
+            ),
+            follow_redirects=True,
+        )
+        data = rv.data.decode("utf-8")
+        self.assertIn("Changed Row", data)
+
+        user = (
+            self.db.session.query(User)
+            .filter(User.username == _tmp_user.username)
+            .one_or_none()
+        )
+
+        assert user.email == "changed@changed.org"
+        self.db.session.delete(user)
+        self.db.session.commit()
+
+    def test_edit_user_email_validation(self):
+        """
+        Test edit user with email not null validation
+        """
+        client = self.app.test_client()
+        _ = self.browser_login(client, USERNAME_ADMIN, PASSWORD_ADMIN)
+
+        read_ony_user: User = (
+            self.db.session.query(User)
+            .filter(User.username == USERNAME_READONLY)
+            .one_or_none()
+        )
+
+        # use all required params
+        rv = client.get(f"/users/edit/{read_ony_user.id}", follow_redirects=True)
+        data = rv.data.decode("utf-8")
+        self.assertIn("Edit User", data)
+        rv = client.post(
+            f"/users/edit/{read_ony_user.id}",
+            data=dict(
+                first_name=read_ony_user.first_name,
+                last_name=read_ony_user.last_name,
+                username=read_ony_user.username,
+                email=None,
+                roles=read_ony_user.roles[0].id,
+            ),
+            follow_redirects=True,
+        )
+        data = rv.data.decode("utf-8")
+        self.assertIn("This field is required", data)
+
+    def test_edit_user_db_fail(self):
+        """
+        Test edit user with DB fail
+        """
+        client = self.app.test_client()
+        _ = self.browser_login(client, USERNAME_ADMIN, PASSWORD_ADMIN)
+
+        read_ony_user: User = (
+            self.db.session.query(User)
+            .filter(User.username == USERNAME_READONLY)
+            .one_or_none()
+        )
+
+        # use all required params
+        rv = client.get(f"/users/edit/{read_ony_user.id}", follow_redirects=True)
+        data = rv.data.decode("utf-8")
+        self.assertIn("Edit User", data)
+
+        with patch.object(self.appbuilder.session, "merge") as mock_merge:
+            with patch.object(self.appbuilder.sm, "has_access", return_value=True) as _:
+                mock_merge.side_effect = Exception("BANG!")
+
+                rv = client.post(
+                    f"/users/edit/{read_ony_user.id}",
+                    data=dict(
+                        first_name=read_ony_user.first_name,
+                        last_name=read_ony_user.last_name,
+                        username=read_ony_user.username,
+                        email="changed@changed.org",
+                        roles=read_ony_user.roles[0].id,
+                    ),
+                    follow_redirects=True,
+                )
+
+                data = rv.data.decode("utf-8")
+                self.assertIn("Database Error", data)

programming_language: Python