CVE-2026-3198
Description
MLflow 3.9.0 with basic-auth mishandles authorization for gateway API list endpoints, exposing sensitive information.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
MLflow 3.9.0 with basic-auth mishandles authorization for gateway API list endpoints, exposing sensitive information.
Vulnerability
MLflow versions prior to 3.9.0, when configured with basic-auth (--app-name basic-auth), fail to enforce authorization checks for several Gateway API 'list' endpoints. The BEFORE_REQUEST_HANDLERS dictionary in mlflow/server/auth/__init__.py omits entries for ListGatewaySecretInfos, ListGatewayEndpoints, and ListGatewayModelDefinitions, allowing unauthorized access to these resources [1].
Exploitation
An attacker with authenticated access to the MLflow server can exploit this vulnerability by sending requests to the affected 'list' endpoints. Since the authorization checks are missing, any authenticated user can successfully enumerate all gateway secrets, endpoints, and model definitions without needing specific permissions [1].
Impact
Successful exploitation allows an attacker to gain unauthorized access to sensitive information. This includes API keys stored as gateway secrets, details of deployed gateway endpoints, and proprietary model definitions. This disclosure can lead to further compromise of systems and intellectual property theft [1].
Mitigation
MLflow versions prior to 3.9.0 are affected. A fix for this vulnerability has been released in MLflow version 3.9.0. Users are advised to upgrade to a patched version to address the security flaw [1].
AI Insight generated on Jun 2, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1cf3d582a7b8aAgentServer: fix streaming middleware bug (#20428)
2 files changed · +185 −38
mlflow/genai/agent_server/server.py+36 −6 modified@@ -161,6 +161,9 @@ async def chat_proxy_middleware(request: Request, call_next): The timeout for the proxy request is specified by the CHAT_PROXY_TIMEOUT_SECONDS environment variable (defaults to 300.0 seconds). + + For streaming responses (SSE), the proxy streams chunks as they arrive + rather than buffering the entire response. """ for route in self.app.routes: if hasattr(route, "path_regex") and route.path_regex.match(request.url.path): @@ -180,18 +183,45 @@ async def chat_proxy_middleware(request: Request, call_next): try: body = await request.body() if request.method in ["POST", "PUT", "PATCH"] else None target_url = f"http://localhost:{self.chat_app_port}/{path}" - proxy_response = await self.proxy_client.request( + + # Build and send request with streaming enabled + req = self.proxy_client.build_request( method=request.method, url=target_url, params=dict(request.query_params), headers={k: v for k, v in request.headers.items() if k.lower() != "host"}, content=body, ) - return Response( - proxy_response.content, - proxy_response.status_code, - headers=dict(proxy_response.headers), - ) + proxy_response = await self.proxy_client.send(req, stream=True) + + # Check if this is a streaming response (SSE) + content_type = proxy_response.headers.get("content-type", "") + if "text/event-stream" in content_type: + # Stream SSE responses chunk by chunk + async def stream_generator(): + try: + async for chunk in proxy_response.aiter_bytes(): + yield chunk + except Exception as e: + logger.error(f"Streaming error: {e}") + raise + finally: + await proxy_response.aclose() + + return StreamingResponse( + stream_generator(), + status_code=proxy_response.status_code, + headers=dict(proxy_response.headers), + ) + else: + # Non-streaming response - read fully then close + content = await proxy_response.aread() + await proxy_response.aclose() + return Response( + content, + proxy_response.status_code, + headers=dict(proxy_response.headers), + ) except httpx.ConnectError: return Response("Service unavailable", status_code=503, media_type="text/plain") except Exception as e:
tests/genai/test_agent_server.py+149 −32 modified@@ -1,6 +1,6 @@ import contextvars from typing import AsyncGenerator -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import httpx import pytest @@ -558,16 +558,21 @@ def test_invoke(request): server = AgentServer("ResponsesAgent", enable_chat_proxy=True) client = TestClient(server.app) - mock_response = Mock() - mock_response.content = b'{"chat": "response"}' + mock_response = AsyncMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} + mock_response.aread = AsyncMock(return_value=b'{"chat": "response"}') + mock_response.aclose = AsyncMock() - with patch.object(server.proxy_client, "request", return_value=mock_response) as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): response = client.get("/assets/index.js") assert response.status_code == 200 assert response.content == b'{"chat": "response"}' - mock_request.assert_called_once() + mock_build_request.assert_called_once() + mock_send.assert_called_once() @pytest.mark.asyncio @@ -589,20 +594,26 @@ def test_invoke(request): server = AgentServer("ResponsesAgent", enable_chat_proxy=True) client = TestClient(server.app) - with patch.object(server.proxy_client, "request") as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send"), + ): response = client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "healthy"} - mock_request.assert_not_called() + mock_build_request.assert_not_called() @pytest.mark.asyncio async def test_chat_proxy_handles_connect_error(): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - with patch.object( - server.proxy_client, "request", side_effect=httpx.ConnectError("Connection failed") + with ( + patch.object(server.proxy_client, "build_request"), + patch.object( + server.proxy_client, "send", side_effect=httpx.ConnectError("Connection failed") + ), ): response = client.get("/") assert response.status_code == 503 @@ -614,7 +625,10 @@ async def test_chat_proxy_handles_general_error(): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - with patch.object(server.proxy_client, "request", side_effect=Exception("Unexpected error")): + with ( + patch.object(server.proxy_client, "build_request"), + patch.object(server.proxy_client, "send", side_effect=Exception("Unexpected error")), + ): response = client.get("/") assert response.status_code == 502 assert "Proxy error: Unexpected error" in response.text @@ -625,18 +639,24 @@ async def test_chat_proxy_forwards_post_requests_with_body(): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - mock_response = Mock() - mock_response.content = b'{"result": "success"}' + mock_response = AsyncMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} + mock_response.aread = AsyncMock(return_value=b'{"result": "success"}') + mock_response.aclose = AsyncMock() # POST to root path (allowed) to test body forwarding - with patch.object(server.proxy_client, "request", return_value=mock_response) as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): response = client.post("/", json={"message": "hello"}) assert response.status_code == 200 assert response.content == b'{"result": "success"}' - call_args = mock_request.call_args + mock_build_request.assert_called_once() + mock_send.assert_called_once() + call_args = mock_build_request.call_args assert call_args.kwargs["method"] == "POST" assert call_args.kwargs["content"] is not None @@ -647,15 +667,20 @@ async def test_chat_proxy_respects_chat_app_port_env_var(monkeypatch): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - mock_response = Mock() - mock_response.content = b"test" + mock_response = AsyncMock() mock_response.status_code = 200 mock_response.headers = {} + mock_response.aread = AsyncMock(return_value=b"test") + mock_response.aclose = AsyncMock() - with patch.object(server.proxy_client, "request", return_value=mock_response) as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): client.get("/assets/test.js") - mock_request.assert_called_once() - call_args = mock_request.call_args + mock_build_request.assert_called_once() + mock_send.assert_called_once() + call_args = mock_build_request.call_args assert call_args.kwargs["url"] == "http://localhost:8080/assets/test.js" @@ -872,16 +897,21 @@ async def test_chat_proxy_forwards_allowlisted_paths(path): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - mock_response = Mock() - mock_response.content = b"response" + mock_response = AsyncMock() mock_response.status_code = 200 mock_response.headers = {} + mock_response.aread = AsyncMock(return_value=b"response") + mock_response.aclose = AsyncMock() - with patch.object(server.proxy_client, "request", return_value=mock_response) as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): response = client.get(path) assert response.status_code == 200 - mock_request.assert_called_once() - assert mock_request.call_args.kwargs["url"] == f"http://localhost:3000{path}" + mock_build_request.assert_called_once() + mock_send.assert_called_once() + assert mock_build_request.call_args.kwargs["url"] == f"http://localhost:3000{path}" @pytest.mark.asyncio @@ -893,11 +923,14 @@ async def test_chat_proxy_blocks_arbitrary_paths(path): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - with patch.object(server.proxy_client, "request") as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send"), + ): response = client.get(path) assert response.status_code == 404 assert response.text == "Not found" - mock_request.assert_not_called() + mock_build_request.assert_not_called() @pytest.mark.asyncio @@ -909,11 +942,14 @@ async def test_chat_proxy_blocks_path_traversal_attempts(path): server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - with patch.object(server.proxy_client, "request") as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send"), + ): response = client.get(path) assert response.status_code == 404 assert response.text == "Not found" - mock_request.assert_not_called() + mock_build_request.assert_not_called() @pytest.mark.asyncio @@ -937,15 +973,96 @@ async def test_chat_proxy_forwards_additional_paths_from_env_vars( server = AgentServer(enable_chat_proxy=True) client = TestClient(server.app) - mock_response = Mock() - mock_response.content = b"response" + mock_response = AsyncMock() mock_response.status_code = 200 mock_response.headers = {} + mock_response.aread = AsyncMock(return_value=b"response") + mock_response.aclose = AsyncMock() - with patch.object(server.proxy_client, "request", return_value=mock_response) as mock_request: + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): response = client.get(test_path) assert response.status_code == 200 - mock_request.assert_called_once() + mock_build_request.assert_called_once() + mock_send.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("content_type", "status_code", "custom_headers"), + [ + ("text/event-stream", 200, {}), + ("text/event-stream; charset=utf-8", 200, {}), + ("text/event-stream", 500, {}), + ("text/event-stream", 200, {"x-custom-header": "value", "cache-control": "no-cache"}), + ], +) +async def test_chat_proxy_sse_streaming(content_type, status_code, custom_headers): + server = AgentServer(enable_chat_proxy=True) + client = TestClient(server.app) + + chunks = [b"data: chunk1\n\n", b"data: chunk2\n\n"] + + async def mock_aiter_bytes(): + for chunk in chunks: + yield chunk + + mock_response = AsyncMock() + mock_response.status_code = status_code + mock_response.headers = {"content-type": content_type, **custom_headers} + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.aclose = AsyncMock() + + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): + response = client.get("/api/stream") + assert response.status_code == status_code + assert "text/event-stream" in response.headers["content-type"] + assert response.content == b"data: chunk1\n\ndata: chunk2\n\n" + mock_build_request.assert_called_once() + mock_response.aclose.assert_called_once() + assert mock_send.call_args.kwargs.get("stream") is True + for key, value in custom_headers.items(): + assert response.headers[key] == value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("content_type", "status_code", "custom_headers"), + [ + ("application/json", 200, {}), + ("text/html", 201, {"x-request-id": "req-123"}), + ("text/plain", 200, {}), + ("application/octet-stream", 200, {}), + ], +) +async def test_chat_proxy_non_sse_responses(content_type, status_code, custom_headers): + server = AgentServer(enable_chat_proxy=True) + client = TestClient(server.app) + + mock_response = AsyncMock() + mock_response.status_code = status_code + mock_response.headers = {"content-type": content_type, **custom_headers} + mock_response.aread = AsyncMock(return_value=b"content") + mock_response.aclose = AsyncMock() + + with ( + patch.object(server.proxy_client, "build_request") as mock_build_request, + patch.object(server.proxy_client, "send", return_value=mock_response) as mock_send, + ): + response = client.get("/") + assert response.status_code == status_code + assert response.content == b"content" + mock_build_request.assert_called_once() + mock_response.aread.assert_called_once() + mock_response.aclose.assert_called_once() + assert mock_send.call_args.kwargs.get("stream") is True + for key, value in custom_headers.items(): + assert response.headers[key] == value def test_return_trace_header_invoke_responses_agent():
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
1News mentions
0No linked articles in our index yet.