CVE-2024-25128 (published 2024-02-29T01:44Z)

Flask-AppBuilder is an application development framework, built on top of Flask. When Flask-AppBuilder is set to AUTH_TYPE AUTH_OID, it allows an attacker to forge an HTTP request, that could deceive the backend into using any requested OpenID service. This vulnerability could grant an attacker unauthorised privilege access if a custom OpenID service is deployed by the attacker and accessible by the backend. This vulnerability is only exploitable when the application is using the OpenID 2.0 authorization protocol. Upgrade to Flask-AppBuilder 4.3.11 to fix the vulnerability.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

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: 2024-02-06 20:51:56+00:00
msg: fix: openID provider validation flow (#2186)

* fix: openID provider validation flow

* remove test cleanup
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: @@ -1447,6 +1447,14 @@ class BaseSecurityManager(AbstractSecurityManager):
         # If it's not a builtin role check against database store roles
         return self.exist_permission_on_roles(view_name, permission_name, db_role_ids)
 
+    def get_oid_identity_url(self, provider_name: str) -> Optional[str]:
+        """
+        Returns the OIDC identity provider URL
+        """
+        for provider in self.openid_providers:
+            if provider.get("name") == provider_name:
+                return provider.get("url")
+
     def get_user_roles(self, user) -> List[object]:
         """
         Get current user roles, if user is not authenticated returns the public role

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -565,8 +565,12 @@ class AuthOIDView(AuthView):
             form = LoginForm_oid()
             if form.validate_on_submit():
                 session["remember_me"] = form.remember_me.data
+                identity_url = self.appbuilder.sm.get_oid_identity_url(form.openid.data)
+                if identity_url is None:
+                    flash(as_unicode(self.invalid_login_message), "warning")
+                    return redirect(self.appbuilder.get_url_for_login)
                 return self.appbuilder.sm.oid.try_login(
-                    form.openid.data,
+                    identity_url,
                     ask_for=self.oid_ask_for,
                     ask_for_optional=self.oid_ask_for_optional,
                 )

programming_language: Python
filename: login_oid.html
change_type: ModificationType.MODIFY
diff: @@ -36,13 +36,9 @@
                                 <label class="hidden control-label" id="label-username"
                                        for="username">{{ _("Enter your OpenID Username") }}:</label>
                                 {{ form.username(size = 80, class = "hidden form-control", autofocus = true) }}
-                            </div>
-                        </div>
-                        <div class="control-group">
-                            <div class="controls">
                                 <label class="checkbox" for="remember_me">
-                                    {{ form.remember_me }} Remember Me
                                 </label>
+                                {{ form.remember_me }} Remember Me
                             </div>
                         </div>
                         <input
@@ -133,7 +129,7 @@
         {% for pr in providers %}
             document.getElementById("btn-oid-provider-{{ pr.name }}")
                 .addEventListener("click", function () {
-                    set_openid("{{ pr.url | safe }}", "{{ pr.name }}");
+                    set_openid("{{ pr.name | safe }}", "{{ pr.name }}");
                 });
         {% endfor %}
         document.getElementById("btn-oid-before-submit")

programming_language: HTML
filename: config_oid.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,29 @@
+import os
+
+from flask_appbuilder.security.manager import AUTH_OID
+
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+SQLALCHEMY_DATABASE_URI = os.environ.get(
+    "SQLALCHEMY_DATABASE_URI"
+) or "sqlite:///" + os.path.join(basedir, "app.db")
+
+SECRET_KEY = "thisismyscretkey"
+
+AUTH_TYPE = AUTH_OID
+
+OPENID_PROVIDERS = [
+    {"name": "Google", "url": "https://www.google.com/accounts/o8/id"},
+    {"name": "Yahoo", "url": "https://me.yahoo.com"},
+    {"name": "AOL", "url": "http://openid.aol.com/<username>"},
+    {"name": "Flickr", "url": "http://www.flickr.com/<username>"},
+    {"name": "OpenStack", "url": "https://openstackid.org/"},
+]
+
+WTF_CSRF_ENABLED = False
+
+# Will allow user self registration
+AUTH_USER_REGISTRATION = True
+
+# The default user self registration role for all users
+AUTH_USER_REGISTRATION_ROLE = "Admin"

programming_language: Python
filename: test_mvc_oauth.py
change_type: ModificationType.MODIFY
diff: @@ -26,7 +26,7 @@ class OAuthRemoteMock:
             return UserInfoReponseMock()
 
 
-class APICSRFTestCase(FABTestCase):
+class MVCOAuthTestCase(FABTestCase):
     def setUp(self):
         from flask import Flask
         from flask_wtf import CSRFProtect

programming_language: Python
filename: test_mvc_oid.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,53 @@
+from unittest.mock import MagicMock
+
+from flask_appbuilder import SQLA
+from tests.base import FABTestCase
+
+
+class MVCOIDTestCase(FABTestCase):
+    def setUp(self):
+        from flask import Flask
+        from flask_appbuilder import AppBuilder
+
+        self.app = Flask(__name__)
+        self.app.config.from_object("tests.config_oid")
+        self.db = SQLA(self.app)
+        self.appbuilder = AppBuilder(self.app, self.db.session)
+
+    def test_oid_login_get(self):
+        """
+        OID: Test login get
+        """
+        self.appbuilder.sm.oid.try_login = MagicMock(return_value="Login ok")
+
+        with self.app.test_client() as client:
+            response = client.get("/login/")
+        self.assertEqual(response.status_code, 200)
+        for provider in self.app.config["OPENID_PROVIDERS"]:
+            self.assertIn(provider["name"], response.data.decode("utf-8"))
+
+    def test_oid_login_post(self):
+        """
+        OID: Test login post with a valid provider
+        """
+        self.appbuilder.sm.oid.try_login = MagicMock(return_value="Login ok")
+
+        with self.app.test_client() as client:
+            response = client.post("/login/", data=dict(openid="OpenStack"))
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.data, b"Login ok")
+        self.appbuilder.sm.oid.try_login.assert_called_with(
+            "https://openstackid.org/", ask_for=["email"], ask_for_optional=[]
+        )
+
+    def test_oid_login_post_invalid_provider(self):
+        """
+        OID: Test login post with an invalid provider
+        """
+        self.appbuilder.sm.oid.try_login = MagicMock(return_value="Not Ok")
+
+        with self.app.test_client() as client:
+            response = client.post("/login/", data=dict(openid="DoesNotExist"))
+            self.assertEqual(response.status_code, 302)
+            self.assertEqual(response.location, "/login/")
+        self.appbuilder.sm.oid.try_login.assert_not_called()

programming_language: Python
filename: test_security_api.py
change_type: ModificationType.MODIFY
diff: @@ -444,6 +444,8 @@ class RolePermissionAPITestCase(FABTestCase):
             if hasattr(b, "datamodel") and b.datamodel.session is not None:
                 b.datamodel.session = self.db.session
 
+        self.create_default_users(self.appbuilder)
+
     def tearDown(self):
         self.appbuilder.session.close()
         engine = self.appbuilder.session.get_bind(mapper=None, clause=None)

programming_language: Python