Skip to content
Merged
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
8 changes: 8 additions & 0 deletions coworker/connectors/browser_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

import aisuite as ai

from ..web.guard import check_url


def _meta(
name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None
Expand Down Expand Up @@ -332,6 +334,12 @@ def browser_open_url(
) -> dict[str, Any]:
if not url.lower().startswith(("http://", "https://")):
return {"error": "url must start with http:// or https://"}
# Same address guard as web_fetch. This is approval gated, so it is defense in
# depth, not the primary control. It checks the initial model supplied URL only;
# redirects that the browser follows internally are not hop checked here.
blocked = check_url(url)
if blocked:
return {"error": blocked}
return _BROWSER.call(
"open_url",
lambda page: (
Expand Down
7 changes: 7 additions & 0 deletions coworker/web/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@

MAX_REDIRECTS = 5

# RFC 6598 shared address space. Python's is_private misses it, but it is carrier grade
# NAT space and Tailscale hands out internal hosts here (100.64.0.0/10), so a fetch to it
# is the same "reach the machine's network position" class as RFC1918.
_CGNAT = ipaddress.ip_network("100.64.0.0/10")


def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]:
if ip.is_loopback:
Expand All @@ -36,6 +41,8 @@ def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]:
return "link-local (includes the cloud metadata endpoint)"
if ip.is_private:
return "a private network"
if ip.version == 4 and ip in _CGNAT:
return "shared address space (CGNAT / RFC 6598)"
if ip.is_multicast:
return "multicast"
if ip.is_reserved or ip.is_unspecified:
Expand Down
21 changes: 21 additions & 0 deletions tests/test_url_address_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ def _resolves_to(monkeypatch, ip: str):
("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
("http://100.64.0.1/", "CGNAT"), # RFC 6598 shared space (Tailscale, CGNAT)
("http://100.127.255.254/", "CGNAT"),
])
def test_blocked_literals(url, needle):
reason = guard.check_url(url)
assert reason and needle in reason


def test_cgnat_neighbours_still_allowed(monkeypatch):
"""100.64.0.0/10 is blocked, but the adjacent public 100.63/100.128 space is not."""
_resolves_to(monkeypatch, "100.63.255.255")
assert guard.check_url("http://below.example/") is None
_resolves_to(monkeypatch, "100.128.0.0")
assert guard.check_url("http://above.example/") is None


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]/")
Expand Down Expand Up @@ -155,3 +165,14 @@ def test_web_fetch_returns_the_refusal_as_a_tool_error(monkeypatch):

def test_web_fetch_still_rejects_non_http_schemes():
assert "http" in make_web_fetch_tool()("file:///etc/passwd")["error"]


def test_browser_open_url_is_guarded_and_never_launches(monkeypatch):
"""The Playwright browser_open_url is approval gated, but the address guard still
refuses a blocked URL before the browser is touched (defense in depth)."""
from coworker.connectors.browser_automation import make_browser_automation_tools

open_url = {t.__name__: t for t in make_browser_automation_tools()}["browser_open_url"]
out = open_url("http://169.254.169.254/latest/meta-data/")
assert "link-local" in out["error"]
assert out.get("ok") is None
Loading