Skip to content
Open
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
20 changes: 19 additions & 1 deletion agentkit/tools/builtin/http_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import ipaddress
import logging
import math
import socket
from urllib.parse import urljoin, urlparse

Expand All @@ -16,6 +17,8 @@

DEFAULT_TIMEOUT_S = 10.0
DEFAULT_MAX_CHARS = 100_000
MAX_TIMEOUT_S = 30.0
MAX_CHARS = DEFAULT_MAX_CHARS
MAX_REDIRECTS = 3

#: Hard ceiling on bytes read from a response body. ``max_chars`` bounds what is *returned*;
Expand Down Expand Up @@ -180,8 +183,12 @@ async def http_fetch(
Redirects are validated individually against SSRF restrictions, and the connected peer
address is re-checked against the validated addresses.
"""
if not math.isfinite(timeout_s):
raise ValueError("timeout_s must be finite")
timeout_s = max(0.1, min(timeout_s, MAX_TIMEOUT_S))
max_chars = max(1, min(max_chars, MAX_CHARS))
current_url = url
read_budget = min(MAX_RESPONSE_BYTES, max(max_chars, 1))
read_budget = min(MAX_RESPONSE_BYTES, max_chars)

async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_s)) as client:
for _ in range(MAX_REDIRECTS + 1):
Expand All @@ -200,6 +207,17 @@ async def http_fetch(

response.raise_for_status()

content_type = (
response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
)
if content_type and not (
content_type.startswith("text/")
or content_type
in {"application/json", "application/xml", "application/javascript"}
or content_type.endswith(("+json", "+xml"))
):
raise ValueError(f"Unsupported content type: {content_type}")

chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_tool_http_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,78 @@ async def test_http_fetch_size_limit() -> None:
assert len(content) == 500


@pytest.mark.asyncio
@pytest.mark.parametrize(("requested", "expected"), [(-20, 1), (10**9, 100_000)])
async def test_http_fetch_clamps_max_chars(requested: int, expected: int) -> None:
response = httpx.Response(
200, text="x" * 100_001, request=httpx.Request("GET", "https://example.com")
)
with (
patch("agentkit.tools.builtin.http_fetch.resolve_host_ips", return_value=[PUBLIC_IP]),
_patch_stream(response),
):
assert len(await http_fetch("https://example.com", max_chars=requested)) == expected


@pytest.mark.asyncio
@pytest.mark.parametrize(("requested", "expected"), [(-1.0, 0.1), (10**9, 30.0)])
async def test_http_fetch_clamps_timeout(requested: float, expected: float) -> None:
response = httpx.Response(200, text="ok", request=httpx.Request("GET", "https://example.com"))
original_client = httpx.AsyncClient
with (
patch("agentkit.tools.builtin.http_fetch.resolve_host_ips", return_value=[PUBLIC_IP]),
_patch_stream(response),
patch(
"agentkit.tools.builtin.http_fetch.httpx.AsyncClient", wraps=original_client
) as client,
):
assert await http_fetch("https://example.com", timeout_s=requested) == "ok"
assert client.call_args.kwargs["timeout"].connect == expected


@pytest.mark.asyncio
@pytest.mark.parametrize("timeout", [float("nan"), float("inf"), float("-inf")])
async def test_http_fetch_rejects_nonfinite_timeout(timeout: float) -> None:
with pytest.raises(ValueError, match="finite"):
await http_fetch("https://example.com", timeout_s=timeout)


@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["application/octet-stream", "image/png"])
async def test_http_fetch_rejects_binary_before_reading(content_type: str) -> None:
response = httpx.Response(
200,
headers={"Content-Type": content_type},
content=b"\x00\xff",
request=httpx.Request("GET", "https://example.com"),
)
with (
patch("agentkit.tools.builtin.http_fetch.resolve_host_ips", return_value=[PUBLIC_IP]),
_patch_stream(response),
patch.object(response, "aiter_bytes", side_effect=AssertionError("body read")),
pytest.raises(ValueError, match="Unsupported content type"),
):
await http_fetch("https://example.com")


@pytest.mark.asyncio
@pytest.mark.parametrize(
"content_type", ["application/json; charset=utf-8", "application/problem+json", "text/html"]
)
async def test_http_fetch_accepts_textual_media_types(content_type: str) -> None:
response = httpx.Response(
200,
headers={"Content-Type": content_type},
content=b"hello",
request=httpx.Request("GET", "https://example.com"),
)
with (
patch("agentkit.tools.builtin.http_fetch.resolve_host_ips", return_value=[PUBLIC_IP]),
_patch_stream(response),
):
assert await http_fetch("https://example.com") == "hello"


@pytest.mark.asyncio
async def test_http_fetch_rejects_rebind_to_unvalidated_peer() -> None:
"""A connection that lands off the validated addresses must be refused.
Expand Down