From e04577d8d91a0c6e067ace328258601ce61998fd Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 15:59:53 +0000 Subject: [PATCH 1/4] fix(runwayml): fetch provider output URLs via safe_get RunwayML video content and TTS download paths called httpx directly on provider-returned output URLs with no SSRF validation. Route those fetches through safe_get/async_safe_get (same as Black Forest Labs image_edit) so private/link-local targets and unsafe redirects are rejected. --- litellm/llms/runwayml/text_to_speech/transformation.py | 9 +++++---- litellm/llms/runwayml/videos/transformation.py | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/litellm/llms/runwayml/text_to_speech/transformation.py b/litellm/llms/runwayml/text_to_speech/transformation.py index 1da8f0c66f0..6831015c38c 100644 --- a/litellm/llms/runwayml/text_to_speech/transformation.py +++ b/litellm/llms/runwayml/text_to_speech/transformation.py @@ -22,6 +22,7 @@ from litellm.llms.base_llm.text_to_speech.transformation import ( TextToSpeechRequestData, ) from litellm.secret_managers.main import get_secret_str +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -496,11 +497,11 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig): if not isinstance(audio_url, str): raise ValueError(f"RunwayML TTS audio URL is not a string: {audio_url}") - # Download the audio file + # Download the audio file with SSRF guards (provider output URLs are untrusted). from litellm.llms.custom_httpx.http_handler import _get_httpx_client client: Final = _get_httpx_client() - audio_response: Final = client.get(url=audio_url) + audio_response: Final = safe_get(client, audio_url) audio_response.raise_for_status() verbose_logger.debug("RunwayML TTS audio downloaded successfully") @@ -565,11 +566,11 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig): if not isinstance(audio_url, str): raise ValueError(f"RunwayML TTS audio URL is not a string: {audio_url}") - # Download the audio file (async) + # Download the audio file (async) with SSRF guards. from litellm.llms.custom_httpx.http_handler import get_async_httpx_client client: Final = get_async_httpx_client(llm_provider=litellm.LlmProviders.RUNWAYML) - audio_response: Final = await client.get(url=audio_url) + audio_response: Final = await async_safe_get(client, audio_url) audio_response.raise_for_status() verbose_logger.debug("RunwayML TTS audio downloaded successfully (async)") diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index 2e0ae30a192..af1cd49989c 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -6,7 +6,7 @@ from httpx._types import RequestFiles import litellm from litellm.constants import RUNWAYML_DEFAULT_API_VERSION -from litellm.litellm_core_utils.url_utils import encode_url_path_segment +from litellm.litellm_core_utils.url_utils import async_safe_get, encode_url_path_segment, safe_get from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.custom_httpx.http_handler import ( @@ -376,9 +376,9 @@ class RunwayMLVideoConfig(BaseVideoConfig): response_data: Final = raw_response.json() video_url: Final = self._extract_video_url_from_response(response_data) - # Download the video from the CloudFront URL synchronously + # Download the video from the provider URL with SSRF guards (same as BFL image_edit). httpx_client: Final[HTTPHandler] = _get_httpx_client() - video_response: Final = httpx_client.get(video_url) + video_response: Final = safe_get(httpx_client, video_url) video_response.raise_for_status() return video_response.content @@ -405,11 +405,11 @@ class RunwayMLVideoConfig(BaseVideoConfig): response_data: Final = raw_response.json() video_url: Final = self._extract_video_url_from_response(response_data) - # Download the video from the CloudFront URL asynchronously + # Download the video from the provider URL with SSRF guards (same as BFL image_edit). async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client( llm_provider=litellm.LlmProviders.RUNWAYML, ) - video_response: Final = await async_httpx_client.get(video_url) + video_response: Final = await async_safe_get(async_httpx_client, video_url) video_response.raise_for_status() return video_response.content From 74c6f8f1c6a7ef1ebc4d1df0112956c95c51185a Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 16:21:41 +0000 Subject: [PATCH 2/4] fix(runwayml): satisfy I001 import ordering in text_to_speech transformation --- litellm/llms/runwayml/text_to_speech/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/runwayml/text_to_speech/transformation.py b/litellm/llms/runwayml/text_to_speech/transformation.py index 6831015c38c..8909638fd87 100644 --- a/litellm/llms/runwayml/text_to_speech/transformation.py +++ b/litellm/llms/runwayml/text_to_speech/transformation.py @@ -17,12 +17,12 @@ from litellm.constants import ( RUNWAYML_DEFAULT_API_VERSION, RUNWAYML_POLLING_TIMEOUT, ) +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, ) from litellm.secret_managers.main import get_secret_str -from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj From 35d45a99920d752bdbe4ca00c902fc0da1e12fe7 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 16:21:41 +0000 Subject: [PATCH 3/4] test(runwayml): add coverage for safe_get download paths --- .../test_text_to_speech_transformation.py | 85 +++++++++++++++++++ .../test_runway_video_transformation.py | 57 +++++++++++++ 2 files changed, 142 insertions(+) diff --git a/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py b/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py index 8871260813d..16d8a033321 100644 --- a/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py +++ b/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py @@ -65,3 +65,88 @@ def test_runwayml_native_voice_passthrough(): assert "runwayml_voice" in mapped_params assert mapped_params["runwayml_voice"]["type"] == "runway-preset" assert mapped_params["runwayml_voice"]["presetId"] == runway_voice + +def test_transform_text_to_speech_response(): + """Test TTS audio download with SSRF-protected fetch.""" + from unittest.mock import Mock, patch + + import httpx + + from litellm.types.llms.openai import HttpxBinaryResponseContent + + config = RunwayMLTextToSpeechConfig() + + # Mock the initial response (task created) + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"id": "task-123", "status": "PENDING"} + mock_response.request.headers = {"Authorization": "Bearer test"} + + # Mock the polled response (task completed) + mock_polled = Mock(spec=httpx.Response) + mock_polled.json.return_value = { + "id": "task-123", + "status": "SUCCEEDED", + "output": ["https://example.com/audio.mp3"], + } + + # Mock the audio download response + mock_audio_response = Mock(spec=httpx.Response) + mock_audio_response.raise_for_status = Mock() + + with patch.object(config, "_poll_task_sync", return_value=mock_polled): + with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client"): + with patch("litellm.llms.runwayml.text_to_speech.transformation.safe_get") as mock_safe_get: + mock_safe_get.return_value = mock_audio_response + result = config.transform_text_to_speech_response( + model="eleven_multilingual_v2", + raw_response=mock_response, + logging_obj=Mock(), + ) + + assert isinstance(result, HttpxBinaryResponseContent) + mock_safe_get.assert_called_once() + + +def test_async_transform_text_to_speech_response(): + """Test async TTS audio download with SSRF-protected fetch.""" + import asyncio + from unittest.mock import AsyncMock, Mock, patch + + import httpx + + from litellm.types.llms.openai import HttpxBinaryResponseContent + + config = RunwayMLTextToSpeechConfig() + + # Mock the initial response (task created) + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"id": "task-123", "status": "PENDING"} + mock_response.request.headers = {"Authorization": "Bearer test"} + + # Mock the polled response (task completed) + mock_polled = Mock(spec=httpx.Response) + mock_polled.json.return_value = { + "id": "task-123", + "status": "SUCCEEDED", + "output": ["https://example.com/audio.mp3"], + } + + # Mock the audio download response + mock_audio_response = Mock(spec=httpx.Response) + mock_audio_response.raise_for_status = Mock() + + async def run_test(): + with patch.object(config, "_poll_task_async", new_callable=AsyncMock, return_value=mock_polled): + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client"): + with patch("litellm.llms.runwayml.text_to_speech.transformation.async_safe_get", new_callable=AsyncMock) as mock_safe_get: + mock_safe_get.return_value = mock_audio_response + result = await config.async_transform_text_to_speech_response( + model="eleven_multilingual_v2", + raw_response=mock_response, + logging_obj=Mock(), + ) + return result + + result = asyncio.run(run_test()) + assert isinstance(result, HttpxBinaryResponseContent) + diff --git a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py index 24879ce83f9..e235ab7448d 100644 --- a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py +++ b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py @@ -213,5 +213,62 @@ class TestRunwayMLVideoTransformation: assert isinstance(status_obj.completed_at, int) + + def test_transform_video_content_response(self): + """Test video content download with SSRF-protected fetch.""" + from unittest.mock import patch + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "test-id", + "status": "SUCCEEDED", + "output": ["https://example.com/video.mp4"], + } + + mock_video_response = Mock(spec=httpx.Response) + mock_video_response.content = b"fake-video-bytes" + mock_video_response.raise_for_status = Mock() + + with patch("litellm.llms.runwayml.videos.transformation._get_httpx_client") as mock_client: + with patch("litellm.llms.runwayml.videos.transformation.safe_get") as mock_safe_get: + mock_safe_get.return_value = mock_video_response + result = self.config.transform_video_content_response( + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + ) + + assert result == b"fake-video-bytes" + mock_safe_get.assert_called_once() + + def test_async_transform_video_content_response(self): + """Test async video content download with SSRF-protected fetch.""" + import asyncio + from unittest.mock import AsyncMock, patch + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "test-id", + "status": "SUCCEEDED", + "output": ["https://example.com/video.mp4"], + } + + mock_video_response = Mock(spec=httpx.Response) + mock_video_response.content = b"fake-video-bytes" + mock_video_response.raise_for_status = Mock() + + async def run_test(): + with patch("litellm.llms.runwayml.videos.transformation.get_async_httpx_client") as mock_client: + with patch("litellm.llms.runwayml.videos.transformation.async_safe_get", new_callable=AsyncMock) as mock_safe_get: + mock_safe_get.return_value = mock_video_response + result = await self.config.async_transform_video_content_response( + raw_response=mock_response, + logging_obj=self.mock_logging_obj, + ) + return result + + result = asyncio.run(run_test()) + assert result == b"fake-video-bytes" + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 16b964b473d784fb277de4a4a9b56cc8a8cd68ae Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 16:21:41 +0000 Subject: [PATCH 4/4] test(runwayml): cover safe_get execution path in download tests --- .../test_text_to_speech_transformation.py | 25 ++++++++++++------- .../test_runway_video_transformation.py | 23 +++++++++++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py b/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py index 16d8a033321..04ee6a315b3 100644 --- a/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py +++ b/tests/test_litellm/llms/runwayml/test_text_to_speech_transformation.py @@ -72,6 +72,7 @@ def test_transform_text_to_speech_response(): import httpx + import litellm from litellm.types.llms.openai import HttpxBinaryResponseContent config = RunwayMLTextToSpeechConfig() @@ -93,10 +94,12 @@ def test_transform_text_to_speech_response(): mock_audio_response = Mock(spec=httpx.Response) mock_audio_response.raise_for_status = Mock() - with patch.object(config, "_poll_task_sync", return_value=mock_polled): - with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client"): - with patch("litellm.llms.runwayml.text_to_speech.transformation.safe_get") as mock_safe_get: - mock_safe_get.return_value = mock_audio_response + mock_client = Mock() + mock_client.get.return_value = mock_audio_response + + with patch.object(litellm, "user_url_validation", False): + with patch.object(config, "_poll_task_sync", return_value=mock_polled): + with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client", return_value=mock_client): result = config.transform_text_to_speech_response( model="eleven_multilingual_v2", raw_response=mock_response, @@ -104,7 +107,7 @@ def test_transform_text_to_speech_response(): ) assert isinstance(result, HttpxBinaryResponseContent) - mock_safe_get.assert_called_once() + mock_client.get.assert_called_once() def test_async_transform_text_to_speech_response(): @@ -114,6 +117,7 @@ def test_async_transform_text_to_speech_response(): import httpx + import litellm from litellm.types.llms.openai import HttpxBinaryResponseContent config = RunwayMLTextToSpeechConfig() @@ -135,11 +139,13 @@ def test_async_transform_text_to_speech_response(): mock_audio_response = Mock(spec=httpx.Response) mock_audio_response.raise_for_status = Mock() + mock_client = Mock() + mock_client.get = AsyncMock(return_value=mock_audio_response) + async def run_test(): - with patch.object(config, "_poll_task_async", new_callable=AsyncMock, return_value=mock_polled): - with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client"): - with patch("litellm.llms.runwayml.text_to_speech.transformation.async_safe_get", new_callable=AsyncMock) as mock_safe_get: - mock_safe_get.return_value = mock_audio_response + with patch.object(litellm, "user_url_validation", False): + with patch.object(config, "_poll_task_async", new_callable=AsyncMock, return_value=mock_polled): + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=mock_client): result = await config.async_transform_text_to_speech_response( model="eleven_multilingual_v2", raw_response=mock_response, @@ -149,4 +155,5 @@ def test_async_transform_text_to_speech_response(): result = asyncio.run(run_test()) assert isinstance(result, HttpxBinaryResponseContent) + mock_client.get.assert_called_once() diff --git a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py index e235ab7448d..d5cf6c24260 100644 --- a/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py +++ b/tests/test_litellm/llms/runwayml/videos/test_runway_video_transformation.py @@ -218,6 +218,8 @@ class TestRunwayMLVideoTransformation: """Test video content download with SSRF-protected fetch.""" from unittest.mock import patch + import litellm + mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { "id": "test-id", @@ -229,22 +231,26 @@ class TestRunwayMLVideoTransformation: mock_video_response.content = b"fake-video-bytes" mock_video_response.raise_for_status = Mock() - with patch("litellm.llms.runwayml.videos.transformation._get_httpx_client") as mock_client: - with patch("litellm.llms.runwayml.videos.transformation.safe_get") as mock_safe_get: - mock_safe_get.return_value = mock_video_response + mock_client = Mock() + mock_client.get.return_value = mock_video_response + + with patch.object(litellm, "user_url_validation", False): + with patch("litellm.llms.runwayml.videos.transformation._get_httpx_client", return_value=mock_client): result = self.config.transform_video_content_response( raw_response=mock_response, logging_obj=self.mock_logging_obj, ) assert result == b"fake-video-bytes" - mock_safe_get.assert_called_once() + mock_client.get.assert_called_once() def test_async_transform_video_content_response(self): """Test async video content download with SSRF-protected fetch.""" import asyncio from unittest.mock import AsyncMock, patch + import litellm + mock_response = Mock(spec=httpx.Response) mock_response.json.return_value = { "id": "test-id", @@ -256,10 +262,12 @@ class TestRunwayMLVideoTransformation: mock_video_response.content = b"fake-video-bytes" mock_video_response.raise_for_status = Mock() + mock_client = Mock() + mock_client.get = AsyncMock(return_value=mock_video_response) + async def run_test(): - with patch("litellm.llms.runwayml.videos.transformation.get_async_httpx_client") as mock_client: - with patch("litellm.llms.runwayml.videos.transformation.async_safe_get", new_callable=AsyncMock) as mock_safe_get: - mock_safe_get.return_value = mock_video_response + with patch.object(litellm, "user_url_validation", False): + with patch("litellm.llms.runwayml.videos.transformation.get_async_httpx_client", return_value=mock_client): result = await self.config.async_transform_video_content_response( raw_response=mock_response, logging_obj=self.mock_logging_obj, @@ -268,6 +276,7 @@ class TestRunwayMLVideoTransformation: result = asyncio.run(run_test()) assert result == b"fake-video-bytes" + mock_client.get.assert_called_once() if __name__ == "__main__":