Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions instructor/v2/core/multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def from_url(cls, url: str) -> Image:

if not media_type:
try:
response = requests.head(url, allow_redirects=True)
response = requests.head(url, allow_redirects=True, timeout=30)
media_type = response.headers.get("Content-Type")
except requests.RequestException as e:
raise ValueError(f"Failed to fetch image from URL") from e
Expand Down Expand Up @@ -233,7 +233,7 @@ def from_path(cls, path: Union[str, Path]) -> Image: # noqa: UP007
@lru_cache
def url_to_base64(url: str) -> str:
"""Cachable helper method for getting image url and encoding to base64."""
response = requests.get(url)
response = requests.get(url, timeout=30)
response.raise_for_status()
return base64.b64encode(response.content).decode("utf-8")

Expand Down Expand Up @@ -324,7 +324,7 @@ def from_url(cls, url: str) -> Audio:
"""Create an Audio instance from a URL."""
if url.startswith("gs://"):
return cls.from_gs_url(url)
response = requests.get(url)
response = requests.get(url, timeout=30)
content_type = response.headers.get("content-type")
if content_type not in VALID_AUDIO_MIME_TYPES:
raise ValueError(
Expand Down Expand Up @@ -591,7 +591,7 @@ def from_url(cls, url: str) -> PDF:

if not media_type:
try:
response = requests.head(url, allow_redirects=True)
response = requests.head(url, allow_redirects=True, timeout=30)
media_type = response.headers.get("Content-Type")
except requests.RequestException as e:
raise ValueError("Failed to fetch PDF from URL") from e
Expand Down
2 changes: 1 addition & 1 deletion instructor/v2/providers/anthropic/multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def pdf_to_anthropic(pdf: Any) -> dict[str, Any]:
):
return {"type": "document", "source": {"type": "url", "url": pdf.source}}
if not pdf.data:
pdf.data = requests.get(str(pdf.source)).content
pdf.data = requests.get(str(pdf.source), timeout=30).content
pdf.data = base64.b64encode(pdf.data).decode("utf-8")
return {
"type": "document",
Expand Down
4 changes: 2 additions & 2 deletions instructor/v2/providers/genai/multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def image_to_genai(image: Any) -> Any:
("http://", "https://")
):
return types.Part.from_bytes(
data=requests.get(image.source).content,
data=requests.get(image.source, timeout=30).content,
mime_type=image.media_type,
)
if image.data or image.is_base64(str(image.source)):
Expand All @@ -55,7 +55,7 @@ def pdf_to_genai(pdf: Any) -> Any:
and pdf.source.startswith(("http://", "https://"))
and not pdf.data
):
data = requests.get(pdf.source).content
data = requests.get(pdf.source, timeout=30).content
encoded = base64.b64encode(data).decode("utf-8")
return types.Part.from_bytes(
data=base64.b64decode(encoded),
Expand Down
2 changes: 1 addition & 1 deletion instructor/v2/providers/openai/multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def pdf_to_openai(pdf: Any, mode: Mode) -> dict[str, Any]:
and pdf.source.startswith(("http://", "https://"))
and not pdf.data
):
response = requests.get(pdf.source)
response = requests.get(pdf.source, timeout=30)
data = base64.b64encode(response.content).decode("utf-8")
if mode in RESPONSES_MODES:
return {
Expand Down
32 changes: 32 additions & 0 deletions tests/multimodal/test_multimodal.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,3 +751,35 @@ def test_pdf_to_bedrock_missing_data_no_source():
match="PDF data is missing. Provide base64-encoded data or use an s3:// source.",
):
pdf.to_bedrock()


def test_url_to_base64_passes_timeout():
"""Remote fetches must pass a request timeout so a slow or unresponsive host
cannot hang the caller indefinitely (uncontrolled resource consumption, CWE-400)."""
with patch("instructor.v2.core.multimodal.requests") as mock_requests:
mock_response = MagicMock()
mock_response.content = b"fake image bytes"
mock_requests.get.return_value = mock_response

Image.url_to_base64("https://example.com/timeout-regression.jpg")

mock_requests.get.assert_called_once()
_, kwargs = mock_requests.get.call_args
assert kwargs.get("timeout") is not None


def test_image_from_url_head_passes_timeout():
"""The HEAD probe used to sniff an extensionless URL's content type must also
pass a timeout so it cannot hang the caller indefinitely (CWE-400)."""
with patch("instructor.v2.core.multimodal.requests") as mock_requests:
mock_requests.RequestException = Exception
mock_response = MagicMock()
mock_response.headers = {"Content-Type": "image/jpeg"}
mock_requests.head.return_value = mock_response

# URL has no file extension, forcing the HEAD content-type probe.
Image.from_url("https://example.com/no-extension")

mock_requests.head.assert_called_once()
_, kwargs = mock_requests.head.call_args
assert kwargs.get("timeout") is not None