security: block loopback/private/metadata addresses in model-supplied URL fetches

web_fetch and browser_read_url take a URL straight from the model. The model's
input is untrusted by design - both tools' own descriptions call fetched
content "data to evaluate, not instructions" - and web_fetch is
requires_approval=False, so nothing prompts the user before the request goes
out.

Neither validated the address. Verified against a scratch server on loopback:

    web_fetch("http://127.0.0.1:9931/")
    -> {"text": "Directory listing for /\n.git/\n.github/..."}

No prompt, no error. The same call reaches http://169.254.169.254/ for cloud
metadata when OpenWorker runs on a VM, an Ollama instance on :11434, or any
service on the user's LAN. It cannot reach OpenWorker's own sidecar, which
requires COWORKER_API_TOKEN.

Adds coworker/web/guard.py: resolve the host and refuse when any answer lands
in loopback, private, link-local (which covers the metadata endpoint),
multicast or reserved space. Checking every resolved address means a name with
one public and one private A record is refused rather than raced.

Redirects are the usual bypass, so follow_redirects is off and the chain is
walked here with each hop checked before it is requested. _request grows an
opt-in check_addresses flag used only by browser_read_url; the hardcoded vendor
endpoints the rest of the connectors call skip the guard and its DNS lookup.

Not covered, and stated in the module docstring: DNS rebinding. The name is
resolved by the guard and again by the client when it connects, so a near-zero
TTL record can change in between. Closing that needs connection-level IP
pinning. The hop check is the cheap 90%.

Tests: tests/test_url_address_guard.py - literals, IPv4-mapped IPv6 loopback,
names resolving into private space, split-horizon answers, non-http schemes,
redirect into loopback proven not to be requested, and a bounded redirect
loop.
This commit is contained in:
Mr-Neutr0n
2026-07-29 02:22:10 +05:30
parent f96ad4c8e6
commit ff86735cf0
4 changed files with 311 additions and 8 deletions
+37 -6
View File
@@ -19,6 +19,7 @@ from urllib.parse import quote
import aisuite as ai import aisuite as ai
from ..secrets import SecretStore from ..secrets import SecretStore
from ..web.guard import get_checked
from .browser_automation import make_browser_automation_tools from .browser_automation import make_browser_automation_tools
from .email_tools import make_email_tools from .email_tools import make_email_tools
from .tool_defs import approval_for_tool, connector_for_tool from .tool_defs import approval_for_tool, connector_for_tool
@@ -305,15 +306,39 @@ def _gmail_is_hidden(
def _request( def _request(
method: str, url: str, *, headers=None, params=None, json=None, auth=None method: str,
url: str,
*,
headers=None,
params=None,
json=None,
auth=None,
check_addresses: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""HTTP for the connectors.
`check_addresses` is for URLs the *model* supplies (browser_read_url). It turns off
automatic redirects and walks the chain through the address guard instead, so a public
URL cannot 302 into loopback or the metadata endpoint. The vendor endpoints everything
else in this module calls are hardcoded, so they skip the guard and its DNS lookup.
"""
try: try:
import httpx import httpx
with httpx.Client(timeout=30.0, follow_redirects=True) as client: with httpx.Client(
resp = client.request( timeout=30.0, follow_redirects=not check_addresses
method, url, headers=headers, params=params, json=json, auth=auth ) as client:
) if check_addresses:
if method.upper() != "GET":
return {"error": "address-checked requests must be GET"}
try:
resp = get_checked(client, url)
except PermissionError as exc:
return {"error": str(exc)}
else:
resp = client.request(
method, url, headers=headers, params=params, json=json, auth=auth
)
ctype = resp.headers.get("content-type", "") ctype = resp.headers.get("content-type", "")
data: Any = resp.json() if "json" in ctype.lower() else resp.text data: Any = resp.json() if "json" in ctype.lower() else resp.text
if resp.status_code >= 400: if resp.status_code >= 400:
@@ -533,7 +558,13 @@ def make_integration_tools(
def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]: def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")): if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"} return {"error": "url must start with http:// or https://"}
out = _request("GET", url, headers={"User-Agent": "coworker/0.1 (+connector)"}) # Model-supplied URL: address-check every hop, same guard as web_fetch.
out = _request(
"GET",
url,
headers={"User-Agent": "coworker/0.1 (+connector)"},
check_addresses=True,
)
if "error" in out: if "error" in out:
return out return out
data = out["data"] data = out["data"]
+8 -2
View File
@@ -13,6 +13,8 @@ from typing import Any, Callable
import aisuite as ai import aisuite as ai
from .guard import get_checked
_MAX = 20000 # default chars returned _MAX = 20000 # default chars returned
_SCHEMA = { _SCHEMA = {
@@ -84,16 +86,20 @@ def make_web_fetch_tool() -> Callable[..., Any]:
try: try:
import httpx import httpx
# follow_redirects=False: guard.get_checked walks the chain so every hop is
# address-checked, not just the URL the model first supplied.
with httpx.Client( with httpx.Client(
follow_redirects=True, follow_redirects=False,
timeout=20.0, timeout=20.0,
headers={"User-Agent": "coworker/0.1 (+desktop)"}, headers={"User-Agent": "coworker/0.1 (+desktop)"},
) as client: ) as client:
resp = client.get(url) resp = get_checked(client, url)
resp.raise_for_status() resp.raise_for_status()
ctype = resp.headers.get("content-type", "") ctype = resp.headers.get("content-type", "")
body = resp.text body = resp.text
final_url = str(resp.url) final_url = str(resp.url)
except PermissionError as exc: # blocked address (loopback, private, metadata)
return {"error": str(exc)}
except Exception as exc: # network / HTTP / TLS except Exception as exc: # network / HTTP / TLS
return {"error": f"fetch failed: {exc}"} return {"error": f"fetch failed: {exc}"}
text = _html_to_text(body) if "html" in ctype.lower() else body text = _html_to_text(body) if "html" in ctype.lower() else body
+109
View File
@@ -0,0 +1,109 @@
"""Address guard for URLs the model chooses.
`web_fetch` and `browser_read_url` take a URL straight from the model, and the model's
input is untrusted by design it reads web pages, email and Slack messages, all of which
are documented as "data, not instructions". A page that talks the agent into fetching
`http://169.254.169.254/` or `http://127.0.0.1:11434/` turns a read-only research tool into
a probe of the machine's own network position, and `web_fetch` is `requires_approval=False`,
so no prompt ever appears.
This blocks the ranges that are only reachable *because* OpenWorker runs on the user's
machine: loopback, RFC1918 and other private space, link-local (which covers the cloud
metadata endpoint at 169.254.169.254), and the reserved/multicast blocks.
Every hop is checked, not just the first: `follow_redirects=True` otherwise lets a public
URL 302 straight to loopback, which is the standard way this filter is bypassed.
Not covered: DNS rebinding. The name is resolved here and resolved again by the client when
it connects, so a record with a ~0 TTL can change between the two. Closing that needs
connection-level IP pinning; the hop check is the cheap 90% and is stated as such.
"""
from __future__ import annotations
import ipaddress
import socket
from typing import Optional
from urllib.parse import urlsplit
MAX_REDIRECTS = 5
def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]:
if ip.is_loopback:
return "loopback"
if ip.is_link_local:
return "link-local (includes the cloud metadata endpoint)"
if ip.is_private:
return "a private network"
if ip.is_multicast:
return "multicast"
if ip.is_reserved or ip.is_unspecified:
return "a reserved range"
return None
def check_url(url: str) -> Optional[str]:
"""None if the URL may be fetched, else a human-readable refusal reason.
Resolves the host and rejects when *any* answer lands in a blocked range, so a name
with both a public and a private A record cannot be used to slip through.
"""
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
return "url must start with http:// or https://"
host = parts.hostname
if not host:
return "url has no host"
# A literal address needs no lookup.
try:
literal = ipaddress.ip_address(host)
except ValueError:
literal = None
if literal is not None:
reason = _blocked_reason(literal)
return f"refusing to fetch {host}: {reason}" if reason else None
try:
infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80),
proto=socket.IPPROTO_TCP)
except OSError as exc:
return f"could not resolve {host}: {exc}"
for info in infos:
raw = info[4][0]
try:
ip = ipaddress.ip_address(raw)
except ValueError:
continue
# ::ffff:127.0.0.1 and friends must be judged as the v4 address they carry.
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
reason = _blocked_reason(ip)
if reason:
return f"refusing to fetch {host} ({ip}): {reason}"
return None
def get_checked(client, url: str, *, max_redirects: int = MAX_REDIRECTS):
"""GET `url`, validating the address before every hop.
`client` must be built with `follow_redirects=False`; redirects are walked here so each
Location is checked. Returns the final response. Raises `PermissionError` when a hop is
refused, `RuntimeError` when the redirect budget is exhausted.
"""
seen = url
for _ in range(max_redirects + 1):
reason = check_url(seen)
if reason:
raise PermissionError(reason)
resp = client.get(seen)
if resp.status_code not in (301, 302, 303, 307, 308):
return resp
location = resp.headers.get("location")
if not location:
return resp
seen = str(resp.url.join(location))
raise RuntimeError(f"too many redirects (>{max_redirects})")
+157
View File
@@ -0,0 +1,157 @@
"""`web_fetch` / `browser_read_url` must not reach the machine's own network position.
Both take a URL straight from the model, and the model's input is untrusted by design —
the tools' own descriptions call fetched content "data to evaluate, not instructions".
`web_fetch` is additionally `requires_approval=False`, so nothing prompts the user.
"""
import socket
import pytest
from coworker.web import guard
from coworker.web.fetch import make_web_fetch_tool
def _resolves_to(monkeypatch, ip: str):
monkeypatch.setattr(
guard.socket, "getaddrinfo",
lambda *a, **k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 80))],
)
# -- literals -----------------------------------------------------------------
@pytest.mark.parametrize("url,needle", [
("http://127.0.0.1:11434/api/tags", "loopback"),
("http://localhost:8000/", "loopback"),
("http://[::1]:8080/", "loopback"),
("http://169.254.169.254/latest/meta-data/", "link-local"),
("http://10.0.0.5/admin", "private"),
("http://192.168.1.1/", "private"),
("http://172.16.4.4/", "private"),
("http://0.0.0.0/", "refusing to fetch"), # 0.0.0.0/8 lands in is_private first
])
def test_blocked_literals(url, needle):
reason = guard.check_url(url)
assert reason and needle in reason
def test_ipv4_mapped_ipv6_loopback_is_blocked():
"""::ffff:127.0.0.1 must be judged as the v4 address it carries."""
assert guard.check_url("http://[::ffff:127.0.0.1]/")
def test_public_literal_is_allowed():
assert guard.check_url("https://93.184.216.34/") is None
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://example.com/x",
"gopher://example.com/", "http://"])
def test_non_http_schemes_and_hostless_urls_are_refused(url):
assert guard.check_url(url)
# -- names --------------------------------------------------------------------
def test_hostname_resolving_to_loopback_is_blocked(monkeypatch):
"""`localtest.me` and friends are public names with private answers."""
_resolves_to(monkeypatch, "127.0.0.1")
assert "loopback" in guard.check_url("http://sneaky.example.com/")
def test_hostname_resolving_to_metadata_ip_is_blocked(monkeypatch):
_resolves_to(monkeypatch, "169.254.169.254")
assert guard.check_url("http://metadata.example.com/")
def test_any_private_answer_blocks_a_split_horizon_name(monkeypatch):
"""One public and one private A record must not be a way through."""
monkeypatch.setattr(
guard.socket, "getaddrinfo",
lambda *a, **k: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80)),
],
)
assert guard.check_url("http://split.example.com/")
def test_public_hostname_is_allowed(monkeypatch):
_resolves_to(monkeypatch, "93.184.216.34")
assert guard.check_url("https://example.com/docs") is None
def test_unresolvable_host_is_refused_not_fetched(monkeypatch):
def boom(*a, **k):
raise socket.gaierror("nodename nor servname provided")
monkeypatch.setattr(guard.socket, "getaddrinfo", boom)
assert "could not resolve" in guard.check_url("http://nope.invalid/")
# -- redirects ----------------------------------------------------------------
class _Resp:
def __init__(self, status=200, location=None, url="https://example.com/"):
self.status_code = status
self.headers = {"location": location} if location else {}
self.url = _Url(url)
self.text = "body"
def raise_for_status(self):
pass
class _Url(str):
def join(self, other):
return other
class _Client:
"""Records what was actually requested, so a blocked hop is provably not fetched."""
def __init__(self, script):
self.script = script
self.requested = []
def get(self, url):
self.requested.append(url)
return self.script.pop(0)
def test_redirect_into_loopback_is_blocked_before_the_second_request(monkeypatch):
_resolves_to(monkeypatch, "93.184.216.34")
client = _Client([_Resp(302, location="http://127.0.0.1:11434/api/tags")])
with pytest.raises(PermissionError, match="loopback"):
guard.get_checked(client, "https://example.com/start")
assert client.requested == ["https://example.com/start"], (
"the redirect target must never be requested"
)
def test_allowed_redirect_chain_is_followed(monkeypatch):
_resolves_to(monkeypatch, "93.184.216.34")
client = _Client([_Resp(302, location="https://example.com/b"), _Resp(200)])
resp = guard.get_checked(client, "https://example.com/a")
assert resp.status_code == 200
assert client.requested == ["https://example.com/a", "https://example.com/b"]
def test_redirect_loop_is_bounded(monkeypatch):
_resolves_to(monkeypatch, "93.184.216.34")
client = _Client([_Resp(302, location="https://example.com/loop")] * 50)
with pytest.raises(RuntimeError, match="too many redirects"):
guard.get_checked(client, "https://example.com/loop")
# -- the tool -----------------------------------------------------------------
def test_web_fetch_returns_the_refusal_as_a_tool_error(monkeypatch):
_resolves_to(monkeypatch, "127.0.0.1")
out = make_web_fetch_tool()("http://sneaky.example.com/")
assert "loopback" in out["error"]
assert "text" not in out
def test_web_fetch_still_rejects_non_http_schemes():
assert "http" in make_web_fetch_tool()("file:///etc/passwd")["error"]