CVE-2023-6974 (published 2023-12-20T06:15Z)

A malicious user could use this issue to access internal HTTP(s) servers and in the worst case (ie: aws instance) it could be abuse to get a remote code execution on the victim machine.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/mlflow/mlflow
commit_details: repo_url: https://github.com/mlflow/mlflow
author: Daniel Lok
author_date: 2023-12-13 11:43:32+08:00
msg: Add env var to disable redirects again (#10673)

Signed-off-by: Daniel Lok <daniel.lok@databricks.com>
repository: repo_url: https://github.com/mlflow/mlflow
repo_name: mlflow/mlflow
description: Open source platform for the machine learning lifecycle
repo_language: Python
owner: mlflow
file_changes: filename: environment_variables.py
change_type: ModificationType.MODIFY
diff: @@ -493,3 +493,9 @@ MLFLOW_MULTIPART_UPLOAD_CHUNK_SIZE = _EnvironmentVariable(
 MLFLOW_MULTIPART_DOWNLOAD_CHUNK_SIZE = _EnvironmentVariable(
     "MLFLOW_MULTIPART_DOWNLOAD_CHUNK_SIZE", int, 100 * 1024**2
 )
+
+#: Specifies whether or not to allow the MLflow server to follow redirects when
+#: making HTTP requests. If set to False, the server will throw an exception if it
+#: encounters a redirect response.
+#: (default: ``True``)
+MLFLOW_ALLOW_HTTP_REDIRECTS = _BooleanEnvironmentVariable("MLFLOW_ALLOW_HTTP_REDIRECTS", True)

programming_language: Python
filename: request_utils.py
change_type: ModificationType.MODIFY
diff: @@ -165,6 +165,7 @@ def _get_http_response_with_retries(
     backoff_jitter,
     retry_codes,
     raise_on_status=True,
+    allow_redirects=None,
     **kwargs,
 ):
     """
@@ -187,7 +188,13 @@ def _get_http_response_with_retries(
     session = _get_request_session(
         max_retries, backoff_factor, backoff_jitter, retry_codes, raise_on_status
     )
-    return session.request(method, url, **kwargs)
+
+    # the environment variable is hardcoded here to avoid importing mlflow.
+    # however, documentation is available in environment_variables.py
+    env_value = os.getenv("MLFLOW_ALLOW_HTTP_REDIRECTS", "true").lower() in ["true", "1"]
+    allow_redirects = env_value if allow_redirects is None else allow_redirects
+
+    return session.request(method, url, allow_redirects=allow_redirects, **kwargs)
 
 
 def cloud_storage_http_request(

programming_language: Python
filename: test_databricks.py
change_type: ModificationType.MODIFY
diff: @@ -449,6 +449,7 @@ def test_databricks_http_request_integration(get_config, request):
         headers["Authorization"] = "Basic dXNlcjpwYXNz"
         assert args == ("PUT", "host/clusters/list")
         assert kwargs == {
+            "allow_redirects": True,
             "headers": headers,
             "verify": True,
             "json": {"a": "b"},

programming_language: Python
filename: test_databricks_artifact_repo.py
change_type: ModificationType.MODIFY
diff: @@ -258,6 +258,7 @@ def test_log_artifact_azure_with_headers(
         request_mock.assert_called_with(
             "put",
             f"{MOCK_AZURE_SIGNED_URI}?comp=blocklist",
+            allow_redirects=True,
             data=ANY,
             headers=filtered_azure_headers,
             timeout=None,
@@ -344,12 +345,14 @@ def test_log_artifact_adls_gen2_with_headers(
         request_mock.assert_any_call(
             "put",
             f"{MOCK_ADLS_GEN2_SIGNED_URI}?resource=file",
+            allow_redirects=True,
             headers=filtered_azure_headers,
             timeout=None,
         )
         request_mock.assert_any_call(
             "patch",
             f"{MOCK_ADLS_GEN2_SIGNED_URI}?action=append&position=0",
+            allow_redirects=True,
             data=ANY,
             headers=filtered_azure_headers,
             timeout=None,
@@ -357,6 +360,7 @@ def test_log_artifact_adls_gen2_with_headers(
         request_mock.assert_any_call(
             "patch",
             f"{MOCK_ADLS_GEN2_SIGNED_URI}?action=append&position=5",
+            allow_redirects=True,
             data=ANY,
             headers=filtered_azure_headers,
             timeout=None,
@@ -364,6 +368,7 @@ def test_log_artifact_adls_gen2_with_headers(
         request_mock.assert_any_call(
             "patch",
             f"{MOCK_ADLS_GEN2_SIGNED_URI}?action=append&position=10",
+            allow_redirects=True,
             data=ANY,
             headers=filtered_azure_headers,
             timeout=None,
@@ -371,6 +376,7 @@ def test_log_artifact_adls_gen2_with_headers(
         request_mock.assert_called_with(
             "patch",
             f"{MOCK_ADLS_GEN2_SIGNED_URI}?action=flush&position=14",
+            allow_redirects=True,
             headers=filtered_azure_headers,
             timeout=None,
         )
@@ -402,12 +408,14 @@ def test_log_artifact_adls_gen2_flush_error(databricks_artifact_repo, test_file)
             mock.call(
                 "put",
                 f"{MOCK_ADLS_GEN2_SIGNED_URI}?resource=file",
+                allow_redirects=True,
                 headers={},
                 timeout=None,
             ),
             mock.call(
                 "patch",
                 f"{MOCK_ADLS_GEN2_SIGNED_URI}?action=append&position=0&flush=true",
+                allow_redirects=True,
                 data=ANY,
                 headers={},
                 timeout=None,
@@ -434,7 +442,7 @@ def test_log_artifact_aws(databricks_artifact_repo, test_file, artifact_path, ex
             GetCredentialsForWrite, MOCK_RUN_ID, [expected_location]
         )
         request_mock.assert_called_with(
-            "put", MOCK_AWS_SIGNED_URI, data=ANY, headers={}, timeout=None
+            "put", MOCK_AWS_SIGNED_URI, allow_redirects=True, data=ANY, headers={}, timeout=None
         )
 
 
@@ -462,7 +470,12 @@ def test_log_artifact_aws_with_headers(
             GetCredentialsForWrite, MOCK_RUN_ID, [expected_location]
         )
         request_mock.assert_called_with(
-            "put", MOCK_AWS_SIGNED_URI, data=ANY, headers=expected_headers, timeout=None
+            "put",
+            MOCK_AWS_SIGNED_URI,
+            allow_redirects=True,
+            data=ANY,
+            headers=expected_headers,
+            timeout=None,
         )
 
 
@@ -500,7 +513,7 @@ def test_log_artifact_gcp(databricks_artifact_repo, test_file, artifact_path, ex
             GetCredentialsForWrite, MOCK_RUN_ID, [expected_location]
         )
         request_mock.assert_called_with(
-            "put", MOCK_GCP_SIGNED_URL, data=ANY, headers={}, timeout=None
+            "put", MOCK_GCP_SIGNED_URL, allow_redirects=True, data=ANY, headers={}, timeout=None
         )
 
 
@@ -528,7 +541,12 @@ def test_log_artifact_gcp_with_headers(
             GetCredentialsForWrite, MOCK_RUN_ID, [expected_location]
         )
         request_mock.assert_called_with(
-            "put", MOCK_GCP_SIGNED_URL, data=ANY, headers=expected_headers, timeout=None
+            "put",
+            MOCK_GCP_SIGNED_URL,
+            allow_redirects=True,
+            data=ANY,
+            headers=expected_headers,
+            timeout=None,
         )
 
 
@@ -1294,6 +1312,7 @@ def test_multipart_upload(databricks_artifact_repo, large_file, mock_chunk_size)
                 mock.call(
                     "put",
                     f"{MOCK_AWS_SIGNED_URI}partNumber={i + 1}",
+                    allow_redirects=True,
                     data=f.read(mock_chunk_size),
                     headers={"header": f"part-{i + 1}"},
                     timeout=None,
@@ -1383,6 +1402,7 @@ def test_multipart_upload_retry_part_upload(databricks_artifact_repo, large_file
                 mock.call(
                     "put",
                     f"{MOCK_AWS_SIGNED_URI}partNumber={i + 1}",
+                    allow_redirects=True,
                     data=f.read(mock_chunk_size),
                     headers={"header": f"part-{i + 1}"},
                     timeout=None,
@@ -1441,6 +1461,7 @@ def test_multipart_upload_abort(databricks_artifact_repo, large_file, mock_chunk
                 mock.call(
                     "put",
                     f"{MOCK_AWS_SIGNED_URI}partNumber={i + 1}",
+                    allow_redirects=True,
                     data=f.read(mock_chunk_size),
                     headers={"header": f"part-{i + 1}"},
                     timeout=None,
@@ -1459,6 +1480,7 @@ def test_multipart_upload_abort(databricks_artifact_repo, large_file, mock_chunk
         assert abort_call == mock.call(
             "delete",
             f"{MOCK_AWS_SIGNED_URI}uploadId=abort",
+            allow_redirects=True,
             headers={"header": "abort"},
             timeout=None,
         )

programming_language: Python
filename: test_rest_store.py
change_type: ModificationType.MODIFY
diff: @@ -70,6 +70,7 @@ def test_successful_http_request(request):
         assert args == ("POST", "https://hello/api/2.0/mlflow/experiments/search")
         kwargs = {k: v for k, v in kwargs.items() if v is not None}
         assert kwargs == {
+            "allow_redirects": True,
             "json": {"view_type": "ACTIVE_ONLY"},
             "headers": DefaultRequestHeaderProvider().request_headers(),
             "verify": True,

programming_language: Python
filename: test_request_utils.py
change_type: ModificationType.MODIFY
diff: @@ -58,3 +58,105 @@ def test_download_chunk_incomplete_read(tmp_path):
                 download_path=download_path,
                 http_uri="https://example.com",
             )
+
+
+@pytest.mark.parametrize("env_value", ["0", "false", "False", "FALSE"])
+def test_redirects_disabled_if_env_var_set(monkeypatch, env_value):
+    monkeypatch.setenv("MLFLOW_ALLOW_HTTP_REDIRECTS", env_value)
+
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = request_utils.cloud_storage_http_request("GET", "http://localhost:5000")
+
+        assert response.text == "mock response"
+        mock_request.assert_called_once_with(
+            "GET",
+            "http://localhost:5000",
+            allow_redirects=False,
+            timeout=None,
+        )
+
+
+@pytest.mark.parametrize("env_value", ["1", "true", "True", "TRUE"])
+def test_redirects_enabled_if_env_var_set(monkeypatch, env_value):
+    monkeypatch.setenv("MLFLOW_ALLOW_HTTP_REDIRECTS", env_value)
+
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = request_utils.cloud_storage_http_request(
+            "GET",
+            "http://localhost:5000",
+        )
+
+        assert response.text == "mock response"
+        mock_request.assert_called_once_with(
+            "GET",
+            "http://localhost:5000",
+            allow_redirects=True,
+            timeout=None,
+        )
+
+
+@pytest.mark.parametrize("env_value", ["0", "false", "False", "FALSE"])
+def test_redirect_kwarg_overrides_env_value_false(monkeypatch, env_value):
+    monkeypatch.setenv("MLFLOW_ALLOW_HTTP_REDIRECTS", env_value)
+
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = request_utils.cloud_storage_http_request(
+            "GET", "http://localhost:5000", allow_redirects=True
+        )
+
+        assert response.text == "mock response"
+        mock_request.assert_called_once_with(
+            "GET",
+            "http://localhost:5000",
+            allow_redirects=True,
+            timeout=None,
+        )
+
+
+@pytest.mark.parametrize("env_value", ["1", "true", "True", "TRUE"])
+def test_redirect_kwarg_overrides_env_value_true(monkeypatch, env_value):
+    monkeypatch.setenv("MLFLOW_ALLOW_HTTP_REDIRECTS", env_value)
+
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = request_utils.cloud_storage_http_request(
+            "GET", "http://localhost:5000", allow_redirects=False
+        )
+
+        assert response.text == "mock response"
+        mock_request.assert_called_once_with(
+            "GET",
+            "http://localhost:5000",
+            allow_redirects=False,
+            timeout=None,
+        )
+
+
+def test_redirects_enabled_by_default():
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = request_utils.cloud_storage_http_request(
+            "GET",
+            "http://localhost:5000",
+        )
+
+        assert response.text == "mock response"
+        mock_request.assert_called_once_with(
+            "GET",
+            "http://localhost:5000",
+            allow_redirects=True,
+            timeout=None,
+        )

programming_language: Python
filename: test_rest_utils.py
change_type: ModificationType.MODIFY
diff: @@ -116,6 +116,7 @@ def test_http_request_hostonly(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -133,6 +134,7 @@ def test_http_request_cleans_hostname(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -151,6 +153,7 @@ def test_http_request_with_basic_auth(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         headers=headers,
         timeout=120,
@@ -183,6 +186,7 @@ def test_http_request_with_aws_sigv4(request, monkeypatch):
     request.assert_called_once_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=mock.ANY,
         headers=mock.ANY,
         timeout=mock.ANY,
@@ -207,6 +211,7 @@ def test_http_request_with_auth(fetch_auth, request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=mock.ANY,
         headers=mock.ANY,
         timeout=mock.ANY,
@@ -226,6 +231,7 @@ def test_http_request_with_token(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         headers=headers,
         timeout=120,
@@ -242,6 +248,7 @@ def test_http_request_with_insecure(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=False,
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -258,6 +265,7 @@ def test_http_request_client_cert_path(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         cert="/some/path",
         headers=DefaultRequestHeaderProvider().request_headers(),
@@ -275,6 +283,7 @@ def test_http_request_server_cert_path(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify="/some/path",
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -295,6 +304,7 @@ def test_http_request_with_content_type_header(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=True,
         headers=headers,
         timeout=120,
@@ -320,6 +330,7 @@ def test_http_request_request_headers(request):
         request.assert_called_with(
             "GET",
             "http://my-host/my/endpoint",
+            allow_redirects=True,
             verify="/some/path",
             headers={**DefaultRequestHeaderProvider().request_headers(), "test": "header"},
             timeout=120,
@@ -356,6 +367,7 @@ def test_http_request_request_headers_user_agent(request):
         request.assert_called_with(
             "GET",
             "http://my-host/my/endpoint",
+            allow_redirects=True,
             verify="/some/path",
             headers=expected_headers,
             timeout=120,
@@ -393,6 +405,7 @@ def test_http_request_request_headers_user_agent_and_extra_header(request):
         request.assert_called_with(
             "GET",
             "http://my-host/my/endpoint",
+            allow_redirects=True,
             verify="/some/path",
             headers=expected_headers,
             timeout=120,
@@ -440,6 +453,7 @@ def test_http_request_wrapper(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=False,
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -450,6 +464,7 @@ def test_http_request_wrapper(request):
     request.assert_called_with(
         "GET",
         "http://my-host/my/endpoint",
+        allow_redirects=True,
         verify=False,
         headers=DefaultRequestHeaderProvider().request_headers(),
         timeout=120,
@@ -560,3 +575,26 @@ def test_augmented_raise_for_status():
     assert e.value.response == response
     assert e.value.request == response.request
     assert response.text in str(e.value)
+
+
+def test_provide_redirect_kwarg():
+    with mock.patch("requests.Session.request") as mock_request:
+        mock_request.return_value.status_code = 302
+        mock_request.return_value.text = "mock response"
+
+        response = http_request(
+            MlflowHostCreds("http://my-host"),
+            "/my/endpoint",
+            "GET",
+            allow_redirects=False,
+        )
+
+        assert response.text == "mock response"
+        mock_request.assert_called_with(
+            "GET",
+            "http://my-host/my/endpoint",
+            allow_redirects=False,
+            headers=mock.ANY,
+            verify=mock.ANY,
+            timeout=120,
+        )

programming_language: Python