From e06b38230ed48d837c6fd1e21ededada1939d164 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 28 Oct 2026 02:34:17 +0800 Subject: [PATCH] fix(tools): bound HTTP fetch inputs and content types --- agentkit/tools/builtin/http_fetch.py | 20 +++++++- tests/unit/test_tool_http_fetch.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/agentkit/tools/builtin/http_fetch.py b/agentkit/tools/builtin/http_fetch.py index b4c5bfa..72e0a35 100644 --- a/agentkit/tools/builtin/http_fetch.py +++ b/agentkit/tools/builtin/http_fetch.py @@ -5,6 +5,7 @@ import asyncio import ipaddress import logging +import math import socket from urllib.parse import urljoin, urlparse @@ -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*; @@ -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): @@ -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(): diff --git a/tests/unit/test_tool_http_fetch.py b/tests/unit/test_tool_http_fetch.py index 935c87c..288274e 100644 --- a/tests/unit/test_tool_http_fetch.py +++ b/tests/unit/test_tool_http_fetch.py @@ -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.