Skip to content
Closed
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
93 changes: 93 additions & 0 deletions backend/apps/agents/tools/url_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""SSRF guard for the WebFetch tool.

The agent picks which URL to fetch, and that choice can be steered by
content on pages it already read (prompt injection). So every fetch URL,
and every redirect hop, is treated as hostile: only http(s), and never a
host that resolves to a private, loopback, link-local, or cloud-metadata
address. Redirects are followed manually so a public URL can't 30x-pivot
into the LAN behind our back.
"""

from __future__ import annotations

import ipaddress
import socket
from urllib.parse import urlsplit

_ALLOWED_SCHEMES = ("http", "https")
_MAX_REDIRECTS = 5


class BlockedURLError(Exception):
"""Raised when a URL (or a redirect target) points somewhere unsafe."""


def _ip_is_blocked(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return True
return (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast
or addr.is_unspecified
or not addr.is_global
)


def _resolve_host(host: str) -> list[str]:
"""All A/AAAA records for host. A hostname can resolve to several IPs
(round-robin, dual-stack); one bad one is enough to block."""
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror as exc:
raise BlockedURLError(f"could not resolve host {host!r}") from exc
return [info[4][0] for info in infos]


def validate_fetch_url(url: str) -> None:
"""Reject non-http(s) schemes and hosts that resolve to non-public IPs.

A bare IP literal is checked directly; a hostname is resolved and every
returned address must be public. Raises BlockedURLError otherwise.
"""
parts = urlsplit(url)
if parts.scheme.lower() not in _ALLOWED_SCHEMES:
raise BlockedURLError(
f"url scheme must be http or https (got {parts.scheme or 'none'!r})"
)
host = parts.hostname
if not host:
raise BlockedURLError("url has no host")

# IP literal: check as-is so DNS rebinding can't sneak one past us.
try:
if _ip_is_blocked(str(ipaddress.ip_address(host))):
raise BlockedURLError(f"host {host!r} resolves to a non-public address")
return
except ValueError:
pass

for ip in _resolve_host(host):
if _ip_is_blocked(ip):
raise BlockedURLError(f"host {host!r} resolves to a non-public address")


async def fetch_guarded(client, url: str):
"""GET `url` with manual redirect following, validating every hop.

`client` must be an httpx.AsyncClient created with follow_redirects=False
so this layer (not httpx) decides whether each Location is safe.
"""
current = url
for _ in range(_MAX_REDIRECTS + 1):
validate_fetch_url(current)
resp = await client.get(current)
if resp.is_redirect and resp.headers.get("location"):
current = str(resp.next_request.url) if resp.next_request else resp.headers["location"]
continue
return resp
raise BlockedURLError(f"too many redirects fetching {url}")
9 changes: 7 additions & 2 deletions backend/apps/agents/tools/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx

from backend.apps.agents.tools.base import BaseTool, ToolContext
from backend.apps.agents.tools.url_guard import BlockedURLError, fetch_guarded

_HTTP_TIMEOUT = 30
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
Expand Down Expand Up @@ -168,13 +169,17 @@ async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
prompt: str | None = input_data.get("prompt")

try:
# follow_redirects=False so url_guard re-checks every hop; httpx's own
# auto-redirect would happily 30x-pivot us into the LAN.
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
follow_redirects=False,
headers={"User-Agent": _USER_AGENT},
) as client:
resp = await client.get(url)
resp = await fetch_guarded(client, url)
resp.raise_for_status()
except BlockedURLError as exc:
return [{"type": "text", "text": f"Refused to fetch {url}: {exc}"}]
except httpx.HTTPStatusError as exc:
return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
except Exception as exc:
Expand Down
101 changes: 101 additions & 0 deletions backend/tests/test_url_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""SSRF guard: WebFetch must refuse non-http(s) schemes and any host that
resolves to a private/loopback/link-local/cloud-metadata address, including
after a redirect 30x-pivots toward the LAN."""
import asyncio

import pytest

from backend.apps.agents.tools.url_guard import (
BlockedURLError,
fetch_guarded,
validate_fetch_url,
)


# ---------------- validate_fetch_url: schemes ----------------

@pytest.mark.parametrize("url", [
"file:///etc/passwd",
"ftp://internal/secret",
"gopher://169.254.169.254/",
"data:text/html,<script>",
"//169.254.169.254/",
])
def test_rejects_non_http_schemes(url):
with pytest.raises(BlockedURLError):
validate_fetch_url(url)


# ---------------- validate_fetch_url: IP literals ----------------

@pytest.mark.parametrize("url", [
"http://169.254.169.254/latest/meta-data/", # AWS/GCP metadata
"http://127.0.0.1/", # loopback
"http://localhost/", # loopback by name (resolves)
"http://10.0.0.5/", # private
"http://192.168.1.1/", # private
"http://172.16.0.1/", # private
"http://[::1]/", # ipv6 loopback
"http://[fd00::1]/", # ipv6 unique-local
"http://0.0.0.0/", # unspecified
])
def test_rejects_internal_ip_literals(url):
with pytest.raises(BlockedURLError):
validate_fetch_url(url)


def test_rejects_url_with_no_host():
with pytest.raises(BlockedURLError):
validate_fetch_url("http:///nohost")


def test_allows_public_ip_literal():
validate_fetch_url("https://8.8.8.8/") # public, should not raise


# ---------------- fetch_guarded: redirect pivot ----------------

class _Resp:
def __init__(self, location=None):
self.is_redirect = location is not None
self.headers = {"location": location} if location else {}
self.next_request = None
if location:
class _Req:
url = location
self.next_request = _Req()


class _FakeClient:
"""Records every URL it was asked to GET so we can assert the guard
blocks before the request, not after."""
def __init__(self, script):
self._script = script
self.requested = []

async def get(self, url):
self.requested.append(url)
return self._script.pop(0)


def test_redirect_to_internal_is_blocked_before_request():
# public URL 30x to metadata; the second hop must be validated and blocked,
# and we must never GET the internal address.
client = _FakeClient([_Resp(location="http://169.254.169.254/latest/")])
with pytest.raises(BlockedURLError):
asyncio.run(fetch_guarded(client, "https://example.com/start"))
assert client.requested == ["https://example.com/start"]


def test_redirect_loop_caps_out():
script = [_Resp(location="https://example.com/again") for _ in range(20)]
client = _FakeClient(script)
with pytest.raises(BlockedURLError):
asyncio.run(fetch_guarded(client, "https://example.com/start"))


def test_non_redirect_response_returned():
final = _Resp()
client = _FakeClient([final])
out = asyncio.run(fetch_guarded(client, "https://example.com/page"))
assert out is final
Loading