Files
crewAI/lib/crewai-tools/tests/url_read_tool_test.py
João Moura 0c74f23596 feat(tools): add URLReadTool for reading arbitrary URLs (#6834)
* feat(tools): add URLReadTool for reading arbitrary URLs

FileReadTool is confined to the local filesystem, so there was no way for
an agent to read a document that lives behind an http(s) URL. Rather than
adding a flag to FileReadTool, this adds a separate tool: granting it
grants network egress to addresses an LLM picks at runtime, and that
should be a deliberate choice rather than a toggle on a filesystem tool.

URLReadTool fetches a URL and returns its content as text. PDF and DOCX
bodies have their text extracted, HTML is stripped to visible text, and
text-shaped types (plain text, Markdown, JSON, XML, YAML, CSV) are
decoded using the charset the server declares. Any other content type is
refused rather than returned as base64, keeping the output text-only.

Requests reuse the existing SSRF protections in security/safe_requests:
validate_url resolves every hostname and rejects private, loopback,
link-local and reserved addresses (covering cloud metadata endpoints),
and safe_get never auto-follows redirects, revalidating each hop and
dropping credentials on cross-origin ones. Resolving before validating
also normalizes encoded forms, so http://2130706433/ is rejected as
127.0.0.1 without needing a string blocklist.

Adds safe_get_bounded on top of that, which streams the body and
abandons it once it crosses max_bytes. The cap counts decoded bytes,
which is what a compressed response expands into -- Content-Length
describes the wire size and cannot bound that. It also closes the
redirect hops, which stream=True would otherwise leave holding their
connections.

Two risks are documented rather than closed. Validation resolves the
hostname and requests resolves it again to connect, so DNS rebinding
remains possible; closing it needs the connection pinned to the
validated address, which would change behavior for all existing
safe_get callers. And the returned text is untrusted remote content
entering an agent's context, which input validation cannot address.

Also fixes a temp file leak in PDFLoader, which reached the same
pymupdf-from-URL path. It wrote downloads to NamedTemporaryFile with
delete=False and never unlinked them, so every PDF ingested from a URL
left a file behind. It now opens from memory, the way URLReadTool does,
which removes the leak by construction instead of relying on cleanup on
each error path; its doc.close() also moves into a finally so a failure
mid-extraction still releases the handle. PDFLoader had no test file, so
this adds one covering both paths plus a regression test asserting no
temp file is created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): address review feedback on URLReadTool

Bound start_line and line_count with ge=1 in the args schema. Both were
unbounded, and _window computes stop as start + line_count, so a negative
line_count reached islice, which rejects a negative stop. The windowing
runs outside the tool's error handling, so that escaped _run as a raw
ValueError instead of an error string. BaseTool.run validates kwargs
against args_schema, so the constraint refuses the value before any
request is made. _window also clamps its own bounds now, so it cannot
raise if called directly.

Bound the PDFLoader download with safe_get_bounded. The body is held in
memory for the whole extraction, so it needed a ceiling; it defaults to
50 MiB and takes a max_bytes kwarg to load() for callers ingesting
larger documents.

Patch the loader's own seam in its tests rather than requests.get. Both
safe_get and safe_get_bounded resolve the hostname before requesting, so
the previous mocks made the tests depend on DNS for example.com and fail
in a network-isolated runner for reasons unrelated to the loader.

Exercise the content-type fallback through run() rather than asserting on
_resolve_kind, so the tests survive a refactor of the classification
internals, and cover the octet-stream PDF, missing-type, query-string and
unknown-extension cases as observable behavior.

Add docstrings to the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:38:36 -03:00

416 lines
16 KiB
Python

from unittest.mock import patch
import pytest
import requests
from crewai_tools import URLReadTool
from crewai_tools.security.safe_requests import safe_get_bounded
TOOL_MODULE = "crewai_tools.tools.url_read_tool.url_read_tool"
class FakeResponse:
"""Minimal stand-in for a streamed requests.Response."""
def __init__(
self,
body: bytes = b"",
content_type: str = "text/plain",
url: str = "https://example.com/file.txt",
status_code: int = 200,
chunk_size: int | None = None,
):
self._body = body
self._chunk_size = chunk_size
self.headers = {"Content-Type": content_type} if content_type else {}
self.url = url
self.status_code = status_code
self.history: list["FakeResponse"] = []
self.closed = False
def raise_for_status(self) -> None:
"""Mimic requests' error-status behavior."""
if self.status_code >= 400:
raise requests.HTTPError(f"{self.status_code} error")
def iter_content(self, chunk_size: int = 65536):
"""Yield the body in chunks, like a streamed response."""
size = self._chunk_size or chunk_size
for index in range(0, len(self._body), size):
yield self._body[index : index + size]
def close(self) -> None:
"""Record that the response was closed."""
self.closed = True
def build_pdf(text: str = "Quarterly revenue was 42") -> bytes:
"""Return the bytes of a one-page PDF containing *text*."""
pymupdf = pytest.importorskip("pymupdf")
document = pymupdf.open()
document.new_page().insert_text((72, 72), text)
try:
return document.tobytes()
finally:
document.close()
def fetch_result(
body: bytes,
content_type: str = "text/plain",
url: str = "https://example.com/f.txt",
):
"""Build the (body, content_type, final_url) tuple safe_get_bounded returns."""
return body, content_type, url
def test_reads_plain_text():
"""A text response is returned as-is, with the configured limits applied."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"hello world")
assert tool.run(url="https://example.com/f.txt") == "hello world"
assert fetch.call_args.kwargs["max_bytes"] == 5 * 1024 * 1024
assert fetch.call_args.kwargs["timeout"] == 30
def test_honors_declared_charset():
"""The charset in the Content-Type header drives decoding."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
"café".encode("latin-1"), "text/plain; charset=iso-8859-1"
)
assert tool.run(url="https://example.com/f.txt") == "café"
def test_encoding_override_wins_over_server_charset():
"""An explicit encoding beats whatever the server declares."""
tool = URLReadTool(encoding="latin-1")
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
"café".encode("latin-1"), "text/plain; charset=utf-8"
)
assert tool.run(url="https://example.com/f.txt") == "café"
def test_undecodable_bytes_fall_back_instead_of_failing():
"""Partially readable text beats an error for the agent."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"\xff\xfe bad bytes", "text/plain")
result = tool.run(url="https://example.com/f.txt")
assert "bad bytes" in result
assert not result.startswith("Error:")
def test_line_window():
"""start_line and line_count select a window of the extracted text."""
tool = URLReadTool()
body = b"one\ntwo\nthree\nfour\nfive\n"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(body)
result = tool.run(url="https://example.com/f.txt", start_line=2, line_count=2)
assert result == "two\nthree\n"
def test_start_line_past_end_reports_error():
"""Asking past the end of the content is reported, not silently empty."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"one\ntwo\n")
result = tool.run(url="https://example.com/f.txt", start_line=99)
assert "exceeds the number of lines" in result
@pytest.mark.parametrize(
"line_args",
[{"line_count": -5}, {"line_count": 0}, {"start_line": 0}, {"start_line": -5}],
)
def test_line_arguments_below_one_are_refused(line_args):
"""Out-of-range line arguments are rejected before any request is made.
islice raises on a negative stop index, and the windowing runs outside the
tool's error handling, so these have to be refused at validation time.
"""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
with pytest.raises(ValueError, match="greater than or equal to 1"):
tool.run(url="https://example.com/f.txt", **line_args)
fetch.assert_not_called()
def test_json_is_returned_verbatim():
"""JSON is passed through undecorated so callers can parse it."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b'{"a": 1}', "application/json")
assert tool.run(url="https://example.com/data.json") == '{"a": 1}'
def test_structured_suffix_type_is_treated_as_text():
"""A +json vendor type is text, not an unsupported binary type."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b'{"a": 1}', "application/vnd.api+json")
assert tool.run(url="https://example.com/data") == '{"a": 1}'
def test_html_is_stripped_to_visible_text():
"""HTML returns visible text with script and style content removed."""
tool = URLReadTool()
body = b"<html><head><style>p{color:red}</style></head><body><p>Hi</p><script>x=1</script></body></html>"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(body, "text/html; charset=utf-8")
result = tool.run(url="https://example.com/page")
assert "Hi" in result
assert "x=1" not in result
assert "color:red" not in result
def test_binary_content_type_is_rejected():
"""An unsupported type is refused rather than returned as base64."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"\x89PNG\r\n", "image/png")
result = tool.run(url="https://example.com/logo.png")
assert "Unsupported content type 'image/png'" in result
def test_octet_stream_pdf_falls_back_to_url_extension():
"""A PDF served as octet-stream is still extracted, via its extension."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
build_pdf("Fallback worked"),
"application/octet-stream",
"https://example.com/a/b.pdf",
)
result = tool.run(url="https://example.com/a/b.pdf")
assert "Fallback worked" in result
def test_missing_content_type_falls_back_to_url_extension():
"""No Content-Type at all still reads as text when the path says .csv."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"a,b\n1,2\n", "", "https://example.com/b.csv")
assert tool.run(url="https://example.com/b.csv") == "a,b\n1,2\n"
def test_query_string_does_not_break_extension_fallback():
"""A presigned-style query string does not hide the path's extension."""
tool = URLReadTool()
url = "https://example.com/b.csv?X-Amz-Signature=abc"
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"a,b\n", "application/octet-stream", url)
assert tool.run(url=url) == "a,b\n"
def test_extension_from_requested_url_survives_a_redirect():
"""A .pdf link that redirects to an extensionless path is still extracted.
Presigned CDN targets routinely drop the extension and serve octet-stream,
so the requested URL is the only place the type survives.
"""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
build_pdf("Survived the redirect"),
"application/octet-stream",
"https://cdn.example.com/objects/9f8a7b6c5d",
)
result = tool.run(url="https://example.com/report.pdf")
assert "Survived the redirect" in result
def test_octet_stream_with_unknown_extension_is_rejected():
"""With neither a usable type nor a known extension, the read is refused."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
b"\x00\x01", "application/octet-stream", "https://example.com/a/b.bin"
)
result = tool.run(url="https://example.com/a/b.bin")
assert "Unsupported content type" in result
def test_validation_failure_is_returned_as_error():
"""An SSRF rejection reaches the agent as an error string, not an exception."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.side_effect = ValueError(
"URL 'http://169.254.169.254/' resolves to private/reserved IP 169.254.169.254."
)
result = tool.run(url="http://169.254.169.254/")
assert result.startswith("Error:")
assert "private/reserved IP" in result
def test_request_failure_is_returned_as_error():
"""A transport failure is reported without raising out of the tool."""
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.side_effect = requests.ConnectionError("connection refused")
result = tool.run(url="https://example.com/f.txt")
assert result.startswith("Error: Failed to fetch")
def test_custom_headers_are_merged_over_defaults():
"""Caller headers win, but the default User-Agent survives."""
tool = URLReadTool(headers={"Authorization": "Bearer x", "Accept": "text/plain"})
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(b"ok")
tool.run(url="https://example.com/f.txt")
headers = fetch.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer x"
assert headers["Accept"] == "text/plain"
assert "crewai-tools URLReadTool" in headers["User-Agent"]
def test_reads_a_real_pdf_end_to_end():
"""Real PDF bytes are extracted page by page."""
pdf_bytes = build_pdf()
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
pdf_bytes, "application/pdf", "https://example.com/report.pdf"
)
result = tool.run(url="https://example.com/report.pdf")
assert "Page 1:" in result
assert "Quarterly revenue was 42" in result
def test_corrupt_pdf_reports_error_without_raising():
"""A malformed PDF becomes an error string, not a traceback."""
pytest.importorskip("pymupdf")
tool = URLReadTool()
with patch(f"{TOOL_MODULE}.safe_get_bounded") as fetch:
fetch.return_value = fetch_result(
b"%PDF-1.4 not really a pdf", "application/pdf"
)
result = tool.run(url="https://example.com/report.pdf")
assert result.startswith("Error: Failed to read PDF content")
class TestSafeGetBounded:
"""Tests for the bounded-fetch helper itself."""
def test_returns_body_content_type_and_final_url(self):
"""The helper reports the body alongside where it ended up."""
response = FakeResponse(b"payload", "text/plain", "https://example.com/final")
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
body, content_type, final_url = safe_get_bounded(
"https://example.com/start", max_bytes=1024
)
assert body == b"payload"
assert content_type == "text/plain"
assert final_url == "https://example.com/final"
assert response.closed
def test_rejects_body_over_the_limit(self):
"""Crossing max_bytes raises rather than truncating silently."""
response = FakeResponse(b"x" * 100, chunk_size=10)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError, match="exceeds the 25 byte limit"):
safe_get_bounded("https://example.com/big", max_bytes=25)
assert response.closed
def test_oversized_error_names_the_url_that_served_the_body(self):
"""After a redirect the requested URL is not the one that sent it."""
response = FakeResponse(
b"x" * 100, url="https://cdn.example.com/final", chunk_size=10
)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError, match="https://cdn.example.com/final"):
safe_get_bounded("https://example.com/start", max_bytes=25)
@pytest.mark.parametrize("max_bytes", [0, -1])
def test_non_positive_max_bytes_fails_before_requesting(self, max_bytes):
"""A misconfigured cap is caught without issuing a request."""
with patch("crewai_tools.security.safe_requests.safe_get") as safe_get:
with pytest.raises(ValueError, match="max_bytes must be positive"):
safe_get_bounded("https://example.com/f", max_bytes=max_bytes)
safe_get.assert_not_called()
def test_stops_reading_once_the_limit_is_crossed(self):
"""The cap must abandon the stream, not buffer the whole body first."""
chunks_yielded = 0
class CountingResponse(FakeResponse):
def iter_content(self, chunk_size: int = 65536):
nonlocal chunks_yielded
for _ in range(1000):
chunks_yielded += 1
yield b"x" * 10
response = CountingResponse()
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(ValueError):
safe_get_bounded("https://example.com/huge", max_bytes=25)
assert chunks_yielded == 3
def test_error_status_raises(self):
"""An error status propagates as an HTTPError."""
response = FakeResponse(b"nope", status_code=404)
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
with pytest.raises(requests.HTTPError):
safe_get_bounded("https://example.com/missing", max_bytes=1024)
assert response.closed
def test_closes_redirect_hops(self):
"""Streamed redirect hops hold connections until closed."""
hop = FakeResponse(b"", status_code=302)
response = FakeResponse(b"done")
response.history = [hop]
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
):
safe_get_bounded("https://example.com/start", max_bytes=1024)
assert hop.closed
assert response.closed
def test_requests_are_streamed(self):
"""Streaming is what lets an oversized body be abandoned early."""
response = FakeResponse(b"ok")
with patch(
"crewai_tools.security.safe_requests.safe_get", return_value=response
) as safe_get:
safe_get_bounded("https://example.com/f", max_bytes=1024)
assert safe_get.call_args.kwargs["stream"] is True