CVE-2026-2734
Description
In mlflow/mlflow versions up to 3.9.0, the SearchModelVersions REST API endpoint and the mlflowSearchModelVersions GraphQL query lack proper per-model authorization checks when basic authentication is enabled. This allows any authenticated user to enumerate all model versions across all registered models, regardless of their permission level. The issue arises due to the absence of SearchModelVersions in the BEFORE_REQUEST_VALIDATORS and AFTER_REQUEST_HANDLERS for the REST API, and its omission from GraphQLAuthorizationMiddleware.PROTECTED_FIELDS for GraphQL. This vulnerability can expose sensitive information such as model names, version descriptions, source URIs, tags, and other metadata, potentially revealing proprietary or confidential details in multi-tenant environments. The issue is resolved in version 3.10.0.
Affected products
2Patches
16989066af33fAdd model version search filtering based on user permissions (#20964)
3 files changed · +147 −3
docs/docs/self-hosting/security/basic-http-auth.mdx+2 −2 modified@@ -615,7 +615,7 @@ Required Permissions for accessing registered models: </td> <td>`2.0/mlflow/model-versions/search`</td> <td>`GET`</td> - <td>None</td> + <td>None (results filtered by parent model permission)</td> </tr> <tr> <td> @@ -1219,7 +1219,7 @@ on that experiment, so that the creator can grant or revoke other users' access </td> <td>`2.0/mlflow/model-versions/search`</td> <td>`GET`</td> - <td>Only returns registered models which the user has `READ` permission on.</td> + <td>Only returns model versions whose parent registered model the user has `READ` permission on.</td> </tr> </tbody> </table>
mlflow/server/auth/__init__.py+46 −1 modified@@ -66,6 +66,7 @@ GetModelVersionDownloadUri, GetRegisteredModel, RenameRegisteredModel, + SearchModelVersions, SearchRegisteredModels, SetModelVersionTag, SetRegisteredModelAlias, @@ -2087,6 +2088,26 @@ def filter_search_registered_models(resp: Response): resp.data = message_to_json(response_message) +def filter_search_model_versions(resp: Response): + if sender_is_admin(): + return + + response_message = SearchModelVersions.Response() + parse_dict(resp.json, response_message) + + # fetch permissions + username = authenticate_request().username + perms = store.list_registered_model_permissions(username) + can_read = {p.name: get_permission(p.permission).can_read for p in perms} + default_can_read = get_permission(auth_config.default_permission).can_read + # filter out model versions whose parent model is unreadable + for mv in list(response_message.model_versions): + if not _has_registered_model_read_access(username, mv.name, can_read, default_can_read): + response_message.model_versions.remove(mv) + + resp.data = message_to_json(response_message) + + def rename_registered_model_permission(resp: Response): """ A model registry can be assigned to multiple users with different permissions. @@ -2163,6 +2184,7 @@ def delete_gateway_model_definition_permissions_cascade(resp: Response): DeleteRegisteredModel: delete_can_manage_registered_model_permission, SearchExperiments: filter_search_experiments, SearchLoggedModels: filter_search_logged_models, + SearchModelVersions: filter_search_model_versions, SearchRegisteredModels: filter_search_registered_models, RenameRegisteredModel: rename_registered_model_permission, RegisterScorer: set_can_manage_scorer_permission, @@ -2703,6 +2725,7 @@ class GraphQLAuthorizationMiddleware: "mlflowGetMetricHistoryBulkInterval", "mlflowSearchRuns", "mlflowSearchDatasets", + "mlflowSearchModelVersions", } def resolve(self, next, root, info, **args): @@ -2745,7 +2768,8 @@ def resolve(self, next, root, info, **args): _logger.warning(f"GraphQL authorization error for {field_name}", exc_info=True) return None - return next(root, info, **args) + result = next(root, info, **args) + return self._post_resolve(field_name, result, username) if result is not None else None def _check_authorization(self, field_name: str, args: dict[str, Any], username: str) -> bool: """ @@ -2793,6 +2817,27 @@ def _check_authorization(self, field_name: str, args: dict[str, Any], username: return True + def _post_resolve(self, field_name: str, result, username: str): + """Apply post-resolution filtering on GraphQL results.""" + if field_name == "mlflowSearchModelVersions": + return self._filter_model_versions_result(result, username) + return result + + def _filter_model_versions_result(self, result, username: str): + """Filter model versions the user doesn't have read access to.""" + perms = store.list_registered_model_permissions(username) + can_read = {p.name: get_permission(p.permission).can_read for p in perms} + default_can_read = get_permission(auth_config.default_permission).can_read + if hasattr(result, "model_versions") and result.model_versions is not None: + filtered = [ + mv + for mv in result.model_versions + if _has_registered_model_read_access(username, mv.name, can_read, default_can_read) + ] + del result.model_versions[:] + result.model_versions.extend(filtered) + return result + def get_graphql_authorization_middleware(): """
tests/server/auth/test_auth.py+99 −0 modified@@ -404,6 +404,105 @@ def test_search_registered_models(client, monkeypatch): assert names == [f"rm{i}" for i in readable] +def test_search_model_versions(client, monkeypatch): + username1, password1 = create_user(client.tracking_uri) + username2, password2 = create_user(client.tracking_uri) + + readable = [0, 2, 4] + + with User(username1, password1, monkeypatch): + experiment_id = client.create_experiment("mv_test_exp") + run = client.create_run(experiment_id) + run_id = run.info.run_id + for i in range(5): + rm = client.create_registered_model(f"mv_model{i}") + client.create_model_version(rm.name, f"runs:/{run_id}/model", run_id=run_id) + _send_rest_tracking_post_request( + client.tracking_uri, + "/api/2.0/mlflow/registered-models/permissions/create", + json_payload={ + "name": rm.name, + "username": username2, + "permission": "READ" if i in readable else "NO_PERMISSIONS", + }, + auth=(username1, password1), + ) + + # user1 (owner) sees all model versions + with User(username1, password1, monkeypatch): + versions = client.search_model_versions(filter_string="name LIKE 'mv_model%'") + names = sorted({mv.name for mv in versions}) + assert names == [f"mv_model{i}" for i in range(5)] + + # user2 only sees model versions for readable models + with User(username2, password2, monkeypatch): + versions = client.search_model_versions(filter_string="name LIKE 'mv_model%'") + names = sorted({mv.name for mv in versions}) + assert names == [f"mv_model{i}" for i in readable] + + +def test_graphql_search_model_versions(client, monkeypatch): + username1, password1 = create_user(client.tracking_uri) + username2, password2 = create_user(client.tracking_uri) + + readable = [0, 2, 4] + + with User(username1, password1, monkeypatch): + experiment_id = client.create_experiment("gql_mv_test_exp") + run = client.create_run(experiment_id) + run_id = run.info.run_id + for i in range(5): + rm = client.create_registered_model(f"gql_mv_model{i}") + client.create_model_version(rm.name, f"runs:/{run_id}/model", run_id=run_id) + _send_rest_tracking_post_request( + client.tracking_uri, + "/api/2.0/mlflow/registered-models/permissions/create", + json_payload={ + "name": rm.name, + "username": username2, + "permission": "READ" if i in readable else "NO_PERMISSIONS", + }, + auth=(username1, password1), + ) + + query = """ + query SearchModelVersions($input: MlflowSearchModelVersionsInput){ + mlflowSearchModelVersions(input: $input){ + modelVersions { name version } + } + } + """ + variables = {"input": {"filter": "name LIKE 'gql_mv_model%'"}} + + # user1 (owner) sees all via GraphQL + resp = requests.post( + f"{client.tracking_uri}/graphql", + json={"query": query, "variables": variables}, + auth=(username1, password1), + ) + resp.raise_for_status() + payload = resp.json() + assert payload.get("errors") in (None, []) + names = sorted( + {mv["name"] for mv in payload["data"]["mlflowSearchModelVersions"]["modelVersions"]} + ) + assert names == [f"gql_mv_model{i}" for i in range(5)] + + # user2 only sees versions for readable models via GraphQL + resp = requests.post( + f"{client.tracking_uri}/graphql", + json={"query": query, "variables": variables}, + auth=(username2, password2), + ) + resp.raise_for_status() + payload = resp.json() + assert payload.get("errors") in (None, []) + names = sorted( + {mv["name"] for mv in payload["data"]["mlflowSearchModelVersions"]["modelVersions"]} + ) + assert names == [f"gql_mv_model{i}" for i in readable] + + def test_create_and_delete_registered_model(client, monkeypatch): username1, password1 = create_user(client.tracking_uri)
Vulnerability mechanics
Root cause
"The `SearchModelVersions` REST API endpoint and the `mlflowSearchModelVersions` GraphQL query lack per-model authorization checks, allowing any authenticated user to enumerate all model versions regardless of their permission level."
Attack vector
An authenticated user with any permission level (including no explicit permission on a model) can call the `SearchModelVersions` REST API endpoint or the `mlflowSearchModelVersions` GraphQL query to list all model versions across all registered models. The REST endpoint was missing from `BEFORE_REQUEST_VALIDATORS` and `AFTER_REQUEST_HANDLERS`, and the GraphQL field was omitted from `GraphQLAuthorizationMiddleware.PROTECTED_FIELDS` [patch_id=1112148]. No special privileges or crafted payloads are required beyond valid authentication credentials.
Affected code
The vulnerability exists in `mlflow/server/auth/__init__.py` where `SearchModelVersions` was absent from the `AFTER_REQUEST_HANDLERS` dictionary and `mlflowSearchModelVersions` was missing from `GraphQLAuthorizationMiddleware.PROTECTED_FIELDS` [patch_id=1112148]. The REST API endpoint at `/api/2.0/mlflow/model-versions/search` and the GraphQL query `mlflowSearchModelVersions` both lacked post-query permission filtering.
What the fix does
The patch adds `SearchModelVersions` to the `AFTER_REQUEST_HANDLERS` dictionary, mapping it to a new `filter_search_model_versions` function that removes model versions whose parent registered model the user lacks read access to [patch_id=1112148]. For GraphQL, `mlflowSearchModelVersions` is added to `PROTECTED_FIELDS`, and a new `_post_resolve` method with `_filter_model_versions_result` applies the same permission-based filtering after query resolution [patch_id=1112148]. The documentation is also updated to reflect that results are now filtered by parent model permission [patch_id=1112148].
Preconditions
- authAttacker must have valid authentication credentials for the MLflow instance with basic authentication enabled.
- configBasic authentication must be enabled on the MLflow server.
Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.