mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
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:
@@ -19,6 +19,7 @@ from urllib.parse import quote
|
||||
import aisuite as ai
|
||||
|
||||
from ..secrets import SecretStore
|
||||
from ..web.guard import get_checked
|
||||
from .browser_automation import make_browser_automation_tools
|
||||
from .email_tools import make_email_tools
|
||||
from .tool_defs import approval_for_tool, connector_for_tool
|
||||
@@ -305,15 +306,39 @@ def _gmail_is_hidden(
|
||||
|
||||
|
||||
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]:
|
||||
"""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:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.request(
|
||||
method, url, headers=headers, params=params, json=json, auth=auth
|
||||
)
|
||||
with httpx.Client(
|
||||
timeout=30.0, follow_redirects=not check_addresses
|
||||
) 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", "")
|
||||
data: Any = resp.json() if "json" in ctype.lower() else resp.text
|
||||
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]:
|
||||
if not url.lower().startswith(("http://", "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:
|
||||
return out
|
||||
data = out["data"]
|
||||
|
||||
Reference in New Issue
Block a user