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
+8 -2
View File
@@ -13,6 +13,8 @@ from typing import Any, Callable
import aisuite as ai
from .guard import get_checked
_MAX = 20000 # default chars returned
_SCHEMA = {
@@ -84,16 +86,20 @@ def make_web_fetch_tool() -> Callable[..., Any]:
try:
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(
follow_redirects=True,
follow_redirects=False,
timeout=20.0,
headers={"User-Agent": "coworker/0.1 (+desktop)"},
) as client:
resp = client.get(url)
resp = get_checked(client, url)
resp.raise_for_status()
ctype = resp.headers.get("content-type", "")
body = resp.text
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
return {"error": f"fetch failed: {exc}"}
text = _html_to_text(body) if "html" in ctype.lower() else body