Files
OpenJarvis/tests/tools/test_http_request.py
T
d7053c35d5 security: harden network-exposed surface (#509)
* security: harden network-exposed surface

Hardening for the network-reachable attack surface, prioritizing fixes
that are strong but do not change working local/loopback defaults.

- auth_middleware: constant-time API key comparison (secrets.compare_digest)
  for the HTTP path, and gate /metrics behind auth so operational counters
  are not readable unauthenticated. /health stays open.
- webhook_routes: fail closed when a channel's secret/token is unset. Twilio,
  BlueBubbles, WhatsApp (verify + inbound), and SendBlue now reject (403)
  instead of processing unsigned/unauthenticated input. Constant-time
  comparisons for BlueBubbles/SendBlue/WhatsApp verify token.
- http_request: follow redirects manually and re-run the SSRF check on every
  hop (capped at 5) so an allowed public URL cannot 30x-redirect to an
  internal/metadata address.
- api_routes /v1/memory/index: restrict indexing to OPENJARVIS_WORKSPACE roots
  when configured and refuse sensitive files (.env, keys, credentials).
- config.toml: default [server] host to 127.0.0.1 (loopback) with a comment
  on how to safely expose to a LAN (0.0.0.0 + API key).

Tests: new fail-closed webhook tests, /metrics auth tests, and SSRF
redirect block/follow tests; updated SendBlue tests for the new
secret-required behavior. Affected suites pass (95 tests), ruff clean.

* fix(http): keep SSRF redirect-following patchable via httpx.request

The manual redirect-following loop used a private httpx.Client, which
bypassed the `http_request.httpx.request` mock seam that consumers' tests
rely on (e.g. the twitter-bot GitHub-issue tests escaped to the real
network and 401'd). Issue each hop via module-level httpx.request with
follow_redirects=False instead — same per-hop SSRF re-check, restored
testability.

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

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:32:28 -07:00

312 lines
12 KiB
Python

"""Tests for the HTTP request tool with SSRF protection."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
from openjarvis.tools.http_request import HttpRequestTool
@pytest.fixture(autouse=True)
def _force_httpx_fallback():
"""Patch the Rust HTTP tool so it raises, falling back to httpx.
The Rust backend makes real HTTP requests that bypass respx mocks.
By making the Rust HttpRequestTool().execute() raise, the tool falls
through to the httpx code path where respx interception works.
"""
mock_rust = MagicMock()
mock_rust.HttpRequestTool.return_value.execute.side_effect = RuntimeError(
"mocked out"
)
with patch(
"openjarvis._rust_bridge.get_rust_module",
return_value=mock_rust,
):
yield
class TestHttpRequestTool:
def test_spec_name_and_category(self):
tool = HttpRequestTool()
assert tool.spec.name == "http_request"
assert tool.spec.category == "network"
def test_spec_required_capabilities(self):
tool = HttpRequestTool()
assert "network:fetch" in tool.spec.required_capabilities
def test_spec_parameters_require_url(self):
tool = HttpRequestTool()
assert "url" in tool.spec.parameters["properties"]
assert "url" in tool.spec.parameters["required"]
def test_tool_id(self):
tool = HttpRequestTool()
assert tool.tool_id == "http_request"
def test_to_openai_function(self):
tool = HttpRequestTool()
fn = tool.to_openai_function()
assert fn["type"] == "function"
assert fn["function"]["name"] == "http_request"
assert "url" in fn["function"]["parameters"]["properties"]
def test_no_url(self):
tool = HttpRequestTool()
result = tool.execute()
assert result.success is False
assert "No URL" in result.content
def test_empty_url(self):
tool = HttpRequestTool()
result = tool.execute(url="")
assert result.success is False
assert "No URL" in result.content
def test_ssrf_blocked_private_ip(self):
"""Request to private IP should be blocked by SSRF protection."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf") as mock_ssrf:
mock_ssrf.return_value = "URL resolves to private IP: 192.168.1.1"
result = tool.execute(url="http://192.168.1.1/admin")
assert result.success is False
assert "SSRF protection" in result.content
assert "private IP" in result.content
def test_ssrf_blocked_metadata_endpoint(self):
"""Request to cloud metadata endpoint should be blocked."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf") as mock_ssrf:
mock_ssrf.return_value = (
"Blocked host: 169.254.169.254 (cloud metadata endpoint)"
)
result = tool.execute(url="http://169.254.169.254/latest/meta-data/")
assert result.success is False
assert "SSRF protection" in result.content
assert "metadata" in result.content.lower() or "Blocked host" in result.content
@respx.mock
def test_successful_get(self):
"""Successful GET request returns response content and metadata."""
respx.get("https://api.example.com/data").mock(
return_value=httpx.Response(
200,
text='{"key": "value"}',
headers={"content-type": "application/json"},
)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://api.example.com/data")
assert result.success is True
assert '"key": "value"' in result.content
assert result.metadata["status_code"] == 200
assert "application/json" in result.metadata["content_type"]
assert "elapsed_ms" in result.metadata
@respx.mock
def test_post_with_body(self):
"""POST request with body sends content correctly."""
respx.post("https://api.example.com/submit").mock(
return_value=httpx.Response(
201,
text='{"id": 42}',
headers={"content-type": "application/json"},
)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(
url="https://api.example.com/submit",
method="POST",
body='{"name": "test"}',
headers={"Content-Type": "application/json"},
)
assert result.success is True
assert '"id": 42' in result.content
assert result.metadata["status_code"] == 201
@respx.mock
def test_put_method(self):
"""PUT request works correctly."""
respx.put("https://api.example.com/resource/1").mock(
return_value=httpx.Response(200, text="updated")
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(
url="https://api.example.com/resource/1",
method="PUT",
body="new data",
)
assert result.success is True
assert "updated" in result.content
@respx.mock
def test_delete_method(self):
"""DELETE request works correctly."""
respx.delete("https://api.example.com/resource/1").mock(
return_value=httpx.Response(204, text="")
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(
url="https://api.example.com/resource/1",
method="DELETE",
)
assert result.success is True
@respx.mock
def test_head_method(self):
"""HEAD request works correctly."""
respx.head("https://api.example.com/check").mock(
return_value=httpx.Response(
200,
text="",
headers={"x-custom": "header-value"},
)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(
url="https://api.example.com/check",
method="HEAD",
)
assert result.success is True
assert result.metadata["status_code"] == 200
def test_timeout_handling(self):
"""Timeout should produce a clear error."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
with patch.object(
HttpRequestTool,
"_request_following_redirects",
side_effect=httpx.TimeoutException("timed out"),
):
result = tool.execute(url="https://slow.example.com", timeout=5)
assert result.success is False
assert "timed out" in result.content.lower()
def test_request_error(self):
"""Connection error should produce a clear error."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
with patch.object(
HttpRequestTool,
"_request_following_redirects",
side_effect=httpx.ConnectError("Connection refused"),
):
result = tool.execute(url="https://down.example.com")
assert result.success is False
assert "Request error" in result.content
@respx.mock
def test_redirect_to_private_ip_blocked(self):
"""A redirect to an internal/metadata host must be re-checked + blocked."""
respx.get("https://public.example.com/start").mock(
return_value=httpx.Response(
302, headers={"location": "http://169.254.169.254/latest/"}
)
)
tool = HttpRequestTool()
# First check (initial URL) passes; the redirect target is blocked.
with patch(
"openjarvis.tools.http_request.check_ssrf",
side_effect=[None, "Blocked host: 169.254.169.254"],
):
result = tool.execute(url="https://public.example.com/start")
assert result.success is False
assert "SSRF protection blocked redirect" in result.content
@respx.mock
def test_safe_redirect_is_followed(self):
"""A redirect to another public URL is followed normally."""
respx.get("https://public.example.com/start").mock(
return_value=httpx.Response(
302, headers={"location": "https://public.example.com/final"}
)
)
respx.get("https://public.example.com/final").mock(
return_value=httpx.Response(200, text="done")
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://public.example.com/start")
assert result.success is True
assert "done" in result.content
def test_method_validation(self):
"""Invalid HTTP method should be rejected."""
tool = HttpRequestTool()
result = tool.execute(url="https://example.com", method="TRACE")
assert result.success is False
assert "Unsupported HTTP method" in result.content
assert "TRACE" in result.content
def test_method_case_insensitive(self):
"""Method should be case-insensitive."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
with respx.mock:
respx.get("https://api.example.com/data").mock(
return_value=httpx.Response(200, text="ok")
)
result = tool.execute(url="https://api.example.com/data", method="get")
assert result.success is True
@respx.mock
def test_response_truncation(self):
"""Response larger than 1 MB should be truncated."""
large_body = "x" * 2_000_000 # 2 MB
respx.get("https://api.example.com/large").mock(
return_value=httpx.Response(200, text=large_body)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://api.example.com/large")
assert result.success is True
assert "[Response truncated at 1 MB]" in result.content
assert result.metadata["truncated"] is True
@respx.mock
def test_response_not_truncated_when_small(self):
"""Response smaller than 1 MB should not be truncated."""
small_body = "hello world"
respx.get("https://api.example.com/small").mock(
return_value=httpx.Response(200, text=small_body)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://api.example.com/small")
assert result.success is True
assert result.content == "hello world"
assert result.metadata["truncated"] is False
@respx.mock
def test_metadata_includes_headers(self):
"""Response metadata should include headers dict."""
respx.get("https://api.example.com/data").mock(
return_value=httpx.Response(
200,
text="ok",
headers={
"content-type": "text/plain",
"x-request-id": "abc123",
},
)
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://api.example.com/data")
assert isinstance(result.metadata["headers"], dict)
assert result.metadata["headers"]["x-request-id"] == "abc123"
__all__ = ["TestHttpRequestTool"]