Skip to content

Commit fa38898

Browse files
joaomdmouraclaude
andcommitted
fix(tools): close streamed redirect hops and widen type fallback
Four findings from the Copilot and Cursor reviews. safe_get leaked its accumulated hops on every failure path. It closed the response it was about to abandon but not the ones already in history, and a caller handed an exception has no handle on them -- under stream=True each holds its connection until its body is read or closed. The loop now closes history before re-raising. Hops are still the caller's on success, where they arrive via response.history. safe_get_bounded rejected a non-positive max_bytes only after issuing the request, and then reported it as an oversized body. It now fails before the request. Its oversized-body error also named the requested URL rather than the one that served the body, which differ after a redirect. The content-type fallback consulted only the final URL for an extension, so a .pdf link redirecting to an extensionless CDN or presigned path was refused even though the requested URL identified the type. It now checks the final URL first, then the requested one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent be177e6 commit fa38898

4 files changed

Lines changed: 174 additions & 41 deletions

File tree

lib/crewai-tools/src/crewai_tools/security/safe_requests.py

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,43 +50,54 @@ def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str,
5050

5151

5252
def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response:
53-
"""GET a URL while validating each redirect target before following it."""
53+
"""GET a URL while validating each redirect target before following it.
54+
55+
On success the hops are attached to the returned response's ``history`` and
56+
are the caller's to close. On failure they are closed here: a caller given
57+
an exception has no handle on them, and a streamed hop holds its connection
58+
until its body is read or closed.
59+
"""
5460
current_url = validate_url(url)
5561
request_kwargs = {**kwargs, "allow_redirects": False}
5662
timeout = request_kwargs.pop("timeout", 30)
5763
history: list[requests.Response] = []
5864
redirects_followed = 0
5965

60-
while True:
61-
response = requests.get(current_url, timeout=timeout, **request_kwargs)
62-
if (
63-
response.status_code not in _REDIRECT_STATUS_CODES
64-
or "Location" not in response.headers
65-
):
66-
response.history = history
67-
return response
68-
69-
if redirects_followed >= max_redirects:
70-
response.close()
71-
raise ValueError(f"Too many redirects while fetching URL: {url}")
72-
73-
location = response.headers.get("Location")
74-
if not location:
75-
response.history = history
76-
return response
77-
78-
try:
79-
redirect_url = validate_url(urljoin(response.url, location))
80-
except ValueError:
81-
response.close()
82-
raise
83-
84-
if not _same_origin(current_url, redirect_url):
85-
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
86-
87-
history.append(response)
88-
current_url = redirect_url
89-
redirects_followed += 1
66+
try:
67+
while True:
68+
response = requests.get(current_url, timeout=timeout, **request_kwargs)
69+
if (
70+
response.status_code not in _REDIRECT_STATUS_CODES
71+
or "Location" not in response.headers
72+
):
73+
response.history = history
74+
return response
75+
76+
if redirects_followed >= max_redirects:
77+
response.close()
78+
raise ValueError(f"Too many redirects while fetching URL: {url}")
79+
80+
location = response.headers.get("Location")
81+
if not location:
82+
response.history = history
83+
return response
84+
85+
try:
86+
redirect_url = validate_url(urljoin(response.url, location))
87+
except ValueError:
88+
response.close()
89+
raise
90+
91+
if not _same_origin(current_url, redirect_url):
92+
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
93+
94+
history.append(response)
95+
current_url = redirect_url
96+
redirects_followed += 1
97+
except BaseException:
98+
for hop in history:
99+
hop.close()
100+
raise
90101

91102

92103
def safe_get_bounded(
@@ -116,11 +127,14 @@ def safe_get_bounded(
116127
last validated URL in the redirect chain.
117128
118129
Raises:
119-
ValueError: If URL validation fails, the redirect chain is too long, or
120-
the body exceeds *max_bytes*.
130+
ValueError: If *max_bytes* is not positive, URL validation fails, the
131+
redirect chain is too long, or the body exceeds *max_bytes*.
121132
requests.RequestException: If the request fails or returns an error
122133
status.
123134
"""
135+
if max_bytes <= 0:
136+
raise ValueError(f"max_bytes must be positive, got {max_bytes}.")
137+
124138
response = safe_get(
125139
url,
126140
max_redirects=max_redirects,
@@ -138,8 +152,11 @@ def safe_get_bounded(
138152
continue
139153
total += len(chunk)
140154
if total > max_bytes:
155+
# Names the URL that served the body, which after a redirect is
156+
# not the one that was requested.
141157
raise ValueError(
142-
f"Response body from '{url}' exceeds the {max_bytes} byte limit."
158+
f"Response body from '{response.url}' exceeds the "
159+
f"{max_bytes} byte limit."
143160
)
144161
chunks.append(chunk)
145162

lib/crewai-tools/src/crewai_tools/tools/url_read_tool/url_read_tool.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,15 @@ def _classify(media_type: str) -> str | None:
203203
return "text"
204204
return None
205205

206-
def _resolve_kind(self, content_type: str, url: str) -> str | None:
206+
def _resolve_kind(self, content_type: str, *urls: str) -> str | None:
207207
"""Decide how to extract text, by content type then by URL extension.
208208
209209
Args:
210210
content_type: The raw Content-Type header value.
211-
url: The final URL of the response.
211+
*urls: URLs to consult for an extension, most authoritative first.
212+
A ``.pdf`` link that redirects to an extensionless CDN or
213+
presigned path only carries its type on the requested URL, so
214+
both ends of the chain are worth checking.
212215
213216
Returns:
214217
The extractor name, or None when the content type is unsupported.
@@ -217,10 +220,11 @@ def _resolve_kind(self, content_type: str, url: str) -> str | None:
217220
if declared not in _UNINFORMATIVE_TYPES:
218221
return self._classify(declared)
219222

220-
path = urlparse(url).path.lower()
221-
for extension, media_type in _EXTENSION_TYPES.items():
222-
if path.endswith(extension):
223-
return self._classify(media_type)
223+
for url in urls:
224+
path = urlparse(url).path.lower()
225+
for extension, media_type in _EXTENSION_TYPES.items():
226+
if path.endswith(extension):
227+
return self._classify(media_type)
224228
return None
225229

226230
def _decode(self, body: bytes, content_type: str) -> str:
@@ -353,7 +357,7 @@ def _run(
353357
except requests.RequestException as e:
354358
return f"Error: Failed to fetch '{url}'. {format_error_for_display(e)}"
355359

356-
kind = self._resolve_kind(content_type, final_url)
360+
kind = self._resolve_kind(content_type, final_url, url)
357361
if kind is None:
358362
return (
359363
f"Error: Unsupported content type "

lib/crewai-tools/tests/url_read_tool_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ def test_query_string_does_not_break_extension_fallback():
216216
assert tool.run(url=url) == "a,b\n"
217217

218218

219+
def test_extension_from_requested_url_survives_a_redirect():
220+
"""A .pdf link that redirects to an extensionless path is still extracted.
221+
222+
Presigned CDN targets routinely drop the extension and serve octet-stream,
223+
so the requested URL is the only place the type survives.
224+
"""
225+
tool = URLReadTool()
226+
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
227+
fetch.return_value = fetch_result(
228+
build_pdf("Survived the redirect"),
229+
"application/octet-stream",
230+
"https://cdn.example.com/objects/9f8a7b6c5d",
231+
)
232+
result = tool.run(url="https://example.com/report.pdf")
233+
234+
assert "Survived the redirect" in result
235+
236+
219237
def test_octet_stream_with_unknown_extension_is_rejected():
220238
"""With neither a usable type nor a known extension, the read is refused."""
221239
tool = URLReadTool()
@@ -322,6 +340,26 @@ def test_rejects_body_over_the_limit(self):
322340

323341
assert response.closed
324342

343+
def test_oversized_error_names_the_url_that_served_the_body(self):
344+
"""After a redirect the requested URL is not the one that sent it."""
345+
response = FakeResponse(
346+
b"x" * 100, url="https://cdn.example.com/final", chunk_size=10
347+
)
348+
with patch(
349+
"crewai_tools.security.safe_requests.safe_get", return_value=response
350+
):
351+
with pytest.raises(ValueError, match="https://cdn.example.com/final"):
352+
safe_get_bounded("https://example.com/start", max_bytes=25)
353+
354+
@pytest.mark.parametrize("max_bytes", [0, -1])
355+
def test_non_positive_max_bytes_fails_before_requesting(self, max_bytes):
356+
"""A misconfigured cap is caught without issuing a request."""
357+
with patch("crewai_tools.security.safe_requests.safe_get") as safe_get:
358+
with pytest.raises(ValueError, match="max_bytes must be positive"):
359+
safe_get_bounded("https://example.com/f", max_bytes=max_bytes)
360+
361+
safe_get.assert_not_called()
362+
325363
def test_stops_reading_once_the_limit_is_crossed(self):
326364
"""The cap must abandon the stream, not buffer the whole body first."""
327365
chunks_yielded = 0

lib/crewai-tools/tests/utilities/test_safe_requests.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,80 @@ def fake_get(url: str, **kwargs: Any) -> requests.Response:
112112
safe_get("http://public.example/start", max_redirects=1, timeout=15)
113113

114114

115+
def _closable_response(
116+
url: str, status_code: int, *, location: str | None = None, closed: list[str]
117+
) -> requests.Response:
118+
"""Build a response that records its own URL when closed."""
119+
response = _response(url, status_code, location=location)
120+
response.close = lambda: closed.append(url) # type: ignore[method-assign]
121+
return response
122+
123+
124+
def test_safe_get_closes_earlier_hops_after_too_many_redirects(
125+
monkeypatch: pytest.MonkeyPatch, public_dns: None
126+
) -> None:
127+
"""Hops accumulated before the failure must not be left open.
128+
129+
Under stream=True each hop holds its connection until its body is read or
130+
closed, and a caller handed an exception has no handle on them.
131+
"""
132+
closed: list[str] = []
133+
134+
def fake_get(url: str, **kwargs: Any) -> requests.Response:
135+
return _closable_response(
136+
url, 302, location="http://safe.example/again", closed=closed
137+
)
138+
139+
_mock_get(monkeypatch, fake_get)
140+
141+
with pytest.raises(ValueError, match="Too many redirects"):
142+
safe_get("http://public.example/start", max_redirects=2, timeout=15, stream=True)
143+
144+
assert len(closed) == 3
145+
146+
147+
def test_safe_get_closes_earlier_hops_when_a_redirect_is_rejected(
148+
monkeypatch: pytest.MonkeyPatch, public_dns: None
149+
) -> None:
150+
"""A hop rejected mid-chain still releases the connections already open."""
151+
closed: list[str] = []
152+
153+
def fake_get(url: str, **kwargs: Any) -> requests.Response:
154+
if url == "http://public.example/start":
155+
return _closable_response(
156+
url, 302, location="http://safe.example/next", closed=closed
157+
)
158+
return _closable_response(
159+
url, 302, location="http://169.254.169.254/latest", closed=closed
160+
)
161+
162+
_mock_get(monkeypatch, fake_get)
163+
164+
with pytest.raises(ValueError, match="private/reserved IP"):
165+
safe_get("http://public.example/start", timeout=15, stream=True)
166+
167+
assert closed == ["http://safe.example/next", "http://public.example/start"]
168+
169+
170+
def test_safe_get_leaves_hops_open_on_success(
171+
monkeypatch: pytest.MonkeyPatch, public_dns: None
172+
) -> None:
173+
"""On success the hops belong to the caller, via response.history."""
174+
closed: list[str] = []
175+
176+
def fake_get(url: str, **kwargs: Any) -> requests.Response:
177+
if url == "http://public.example/start":
178+
return _closable_response(url, 302, location="/final", closed=closed)
179+
return _closable_response(url, 200, closed=closed)
180+
181+
_mock_get(monkeypatch, fake_get)
182+
183+
response = safe_get("http://public.example/start", timeout=15, stream=True)
184+
185+
assert closed == []
186+
assert len(response.history) == 1
187+
188+
115189
def test_safe_get_strips_credentials_on_cross_origin_redirect(
116190
monkeypatch: pytest.MonkeyPatch, public_dns: None
117191
) -> None:

0 commit comments

Comments
 (0)