CVE-2026-2611
Description
In MLflow version 3.9.0, the MLflow Assistant feature introduced improper origin validation in its /ajax-api endpoints. This vulnerability allows a remote attacker to exploit cross-origin requests from a malicious webpage to interact with the MLflow Assistant running on a victim's local machine. By bypassing the loopback-only restriction, the attacker can modify the Assistant's configuration to enable full access, which in turn allows the execution of arbitrary commands via the Claude Code sub-agent. This issue is resolved in version 3.10.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Improper origin validation in MLflow Assistant’s ajax endpoints allows remote attackers to execute arbitrary commands via a cross-origin attack.
Vulnerability
In MLflow version 3.9.0, the MLflow Assistant feature introduced improper origin validation in its /ajax-api endpoints. The affected endpoints did not enforce a loopback-only restriction, allowing cross-origin requests from arbitrary origins. This vulnerability affects all deployments of MLflow 3.9.0 where the Assistant feature is enabled.
Exploitation
An attacker can craft a malicious webpage that, when visited by a victim running MLflow 3.9.0 on their local machine, sends unauthorized cross-origin requests to the MLflow Assistant’s /ajax-api endpoints. The attacker does not require authentication or prior access to the MLflow server; they only need to trick the victim into opening the malicious page. The requests can modify the Assistant’s configuration to enable full access, subsequently leveraging the Claude Code sub-agent to execute arbitrary commands.
Impact
Successful exploitation allows the attacker to execute arbitrary commands on the victim’s machine, leading to full compromise of the victim’s data and system. The impact is severe as the attacker gains remote code execution with the privileges of the MLflow user.
Mitigation
The vulnerability is fixed in MLflow version 3.10.0 [1]. Users should upgrade to version 3.10.0 or later immediately. There are no known workarounds; disabling the Assistant feature may reduce the attack surface but is not a full mitigation.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
18f9c8a53af90Block CORS for ajax paths (#20832)
4 files changed · +105 −16
mlflow/server/fastapi_security.py+5 −1 modified@@ -13,6 +13,7 @@ CORS_BLOCKED_MSG, HEALTH_ENDPOINTS, INVALID_HOST_MSG, + LOCALHOST_ORIGIN_PATTERNS, get_allowed_hosts_from_env, get_allowed_origins_from_env, get_default_allowed_hosts, @@ -175,10 +176,13 @@ def init_fastapi_security(app: FastAPI) -> None: expose_headers=["*"], ) else: + # Use CORSBlockingMiddleware for blocking CORS requests on the server side, + # and CORSMiddleware for responding to OPTIONS requests. app.add_middleware(CORSBlockingMiddleware, allowed_origins=allowed_origins) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed_origins, + allow_origin_regex="|".join(LOCALHOST_ORIGIN_PATTERNS), allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], allow_headers=["*"],
mlflow/server/security_utils.py+5 −2 modified@@ -23,8 +23,9 @@ # Paths exempt from host validation HEALTH_ENDPOINTS = ["/health", "/version"] -# API path prefix for MLflow endpoints +# API path prefixes for MLflow endpoints API_PATH_PREFIX = "/api/" +AJAX_API_PATH_PREFIX = "/ajax-api/" # Test-only endpoints that should not have CORS blocking TEST_ENDPOINTS = ["/test", "/api/test"] @@ -122,7 +123,9 @@ def should_block_cors_request(origin: str, method: str, allowed_origins: list[st def is_api_endpoint(path: str) -> bool: """Check if a path is an API endpoint that should have CORS/OPTIONS handling.""" - return path.startswith(API_PATH_PREFIX) and path not in TEST_ENDPOINTS + return ( + path.startswith(API_PATH_PREFIX) or path.startswith(AJAX_API_PATH_PREFIX) + ) and path not in TEST_ENDPOINTS def is_allowed_host_header(allowed_hosts: list[str], host: str) -> bool:
tests/server/conftest.py+18 −1 modified@@ -1,7 +1,11 @@ import pytest +from fastapi import FastAPI from flask import Flask +from starlette.testclient import TestClient from werkzeug.test import Client +from mlflow.server.fastapi_security import init_fastapi_security + @pytest.fixture def test_app(): @@ -12,7 +16,7 @@ def test_app(): def test_endpoint(): return "OK" - @app.route("/api/test", methods=["GET", "POST", "OPTIONS"]) + @app.route("/api/2.0/mlflow/experiments/list", methods=["GET", "POST", "OPTIONS"]) def api_endpoint(): return "OK" @@ -47,3 +51,16 @@ def mlflow_app_client(): security.init_security_middleware(app) return Client(app) + + +@pytest.fixture +def fastapi_client(): + """Minimal FastAPI app for unit testing.""" + app = FastAPI() + + @app.api_route("/api/2.0/mlflow/experiments/list", methods=["GET", "POST", "OPTIONS"]) + async def api_endpoint(): + return {"ok": True} + + init_fastapi_security(app) + return TestClient(app, raise_server_exceptions=False)
tests/server/test_security.py+77 −12 modified@@ -1,9 +1,12 @@ import pytest +from fastapi import FastAPI from flask import Flask +from starlette.testclient import TestClient from werkzeug.test import Client from mlflow.server import security -from mlflow.server.security_utils import is_allowed_host_header +from mlflow.server.fastapi_security import init_fastapi_security +from mlflow.server.security_utils import is_allowed_host_header, is_api_endpoint def test_default_allowed_hosts(): @@ -47,16 +50,16 @@ def test_dns_rebinding_protection( @pytest.mark.parametrize( - ("method", "origin", "expected_cors_header"), + ("method", "origin", "expected_status", "expected_cors_header"), [ - ("POST", "http://localhost:3000", "http://localhost:3000"), - ("POST", "http://evil.com", None), - ("POST", None, None), - ("GET", "http://evil.com", None), + ("POST", "http://localhost:3000", 200, "http://localhost:3000"), + ("POST", "http://evil.com", 403, None), + ("POST", None, 200, None), + ("GET", "http://evil.com", 200, None), ], ) def test_cors_protection( - test_app, method, origin, expected_cors_header, monkeypatch: pytest.MonkeyPatch + test_app, method, origin, expected_status, expected_cors_header, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setenv( "MLFLOW_SERVER_CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://app.example.com" @@ -65,8 +68,8 @@ def test_cors_protection( client = Client(test_app) headers = {"Origin": origin} if origin else {} - response = getattr(client, method.lower())("/api/test", headers=headers) - assert response.status_code == 200 + response = getattr(client, method.lower())("/api/2.0/mlflow/experiments/list", headers=headers) + assert response.status_code == expected_status if expected_cors_header: assert response.headers.get("Access-Control-Allow-Origin") == expected_cors_header @@ -77,7 +80,9 @@ def test_insecure_cors_mode(test_app, monkeypatch: pytest.MonkeyPatch): security.init_security_middleware(test_app) client = Client(test_app) - response = client.post("/api/test", headers={"Origin": "http://evil.com"}) + response = client.post( + "/api/2.0/mlflow/experiments/list", headers={"Origin": "http://evil.com"} + ) assert response.status_code == 200 assert response.headers.get("Access-Control-Allow-Origin") == "http://evil.com" @@ -97,14 +102,14 @@ def test_preflight_options_request( client = Client(test_app) response = client.options( - "/api/test", + "/api/2.0/mlflow/experiments/list", headers={ "Origin": origin, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "Content-Type", }, ) - assert response.status_code == 200 + assert response.status_code == 204 if expected_cors_header: assert response.headers.get("Access-Control-Allow-Origin") == expected_cors_header @@ -263,3 +268,63 @@ def test_environment_variable_configuration( result = security.get_allowed_hosts() for expected in expected_result: assert expected in result + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/api/2.0/mlflow/experiments/list", True), + ("/ajax-api/2.0/mlflow/experiments/list", True), + ("/ajax-api/3.0/mlflow/runs/search", True), + ("/api/test", False), + ("/test", False), + ("/health", False), + ("/static/index.html", False), + ], +) +def test_is_api_endpoint(path, expected): + assert is_api_endpoint(path) == expected + + +@pytest.mark.parametrize( + ("origin", "expect_cors_header"), + [ + ("http://localhost:3000", True), + ("http://127.0.0.1:5000", True), + ("http://[::1]:8080", True), + ("http://evil.com", False), + ], +) +def test_fastapi_cors_allows_localhost_origins(fastapi_client, origin, expect_cors_header): + response = fastapi_client.get( + "/api/2.0/mlflow/experiments/list", headers={"Host": "localhost", "Origin": origin} + ) + if expect_cors_header: + assert response.headers.get("access-control-allow-origin") == origin + else: + assert response.headers.get("access-control-allow-origin") is None + + +def test_fastapi_cors_allows_configured_origin(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MLFLOW_SERVER_CORS_ALLOWED_ORIGINS", "https://trusted.com") + + app = FastAPI() + + @app.api_route("/api/2.0/mlflow/experiments/list", methods=["GET", "POST", "OPTIONS"]) + async def api_endpoint(): + return {"ok": True} + + init_fastapi_security(app) + client = TestClient(app, raise_server_exceptions=False) + + response = client.get( + "/api/2.0/mlflow/experiments/list", + headers={"Host": "localhost", "Origin": "https://trusted.com"}, + ) + assert response.headers.get("access-control-allow-origin") == "https://trusted.com" + + response = client.get( + "/api/2.0/mlflow/experiments/list", + headers={"Host": "localhost", "Origin": "http://evil.com"}, + ) + assert response.headers.get("access-control-allow-origin") is None
Vulnerability mechanics
Root cause
"Missing origin validation on `/ajax-api/` endpoints allowed cross-origin requests to bypass the loopback-only restriction on the MLflow Assistant."
Attack vector
An attacker hosts a malicious webpage that issues cross-origin HTTP requests to the victim's local MLflow instance (e.g., `http://localhost:5000/ajax-api/...`). Because the `/ajax-api/` prefix was not recognized as an API path, the server did not block CORS requests from non-loopback origins [patch_id=601819]. The attacker can modify the Assistant's configuration to enable full access, then execute arbitrary commands via the Claude Code sub-agent. The victim must simply visit the attacker's page while MLflow is running locally.
Affected code
The vulnerability exists in `mlflow/server/security_utils.py` where `is_api_endpoint()` only checked for the `/api/` prefix, missing the `/ajax-api/` prefix used by the MLflow Assistant. The fix adds `AJAX_API_PATH_PREFIX = "/ajax-api/"` and includes it in the endpoint check. Additionally, `mlflow/server/fastapi_security.py` tightens `CORSMiddleware` to use explicit allowed origins instead of `["*"]`.
What the fix does
The patch adds `AJAX_API_PATH_PREFIX = "/ajax-api/"` to `security_utils.py` and updates `is_api_endpoint()` to treat paths starting with `/ajax-api/` as API endpoints [patch_id=601819]. In `fastapi_security.py`, the `CORSMiddleware` is now configured with the explicit `allowed_origins` list and a regex for localhost origins, rather than the permissive `allow_origins=["*"]`. This ensures that cross-origin requests to `/ajax-api/` endpoints are blocked unless the origin is a configured allowed origin or a loopback address.
Preconditions
- networkThe victim must have MLflow running locally and accessible on localhost.
- inputThe victim must visit a malicious webpage controlled by the attacker.
- configThe MLflow Assistant feature must be enabled (default in version 3.9.0).
Generated on May 19, 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.