VYPR
Critical severity9.6NVD Advisory· Published May 19, 2026· Updated May 19, 2026

CVE-2026-2611

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

2
  • Mlflow/Mlflowreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <=3.9.0

Patches

1
8f9c8a53af90

Block CORS for ajax paths (#20832)

https://github.com/mlflow/mlflowTomuHirataFeb 16, 2026via nvd-ref
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

2

News mentions

0

No linked articles in our index yet.