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
18 changes: 11 additions & 7 deletions gateway/browser_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,19 @@ def _suffix_match(host: str, patterns: list[str]) -> bool:
return False


def host_policy(host: str, allow: list[str], deny: list[str]) -> str | None:
"""Why this host may not be reached, or None. Name rules only; the address rule follows."""
def host_policy(host: str, allow: list[str], deny: list[str], private_ok: bool = False) -> str | None:
"""Why this host may not be reached, or None. Name rules only; the address rule follows.
`private_ok` lifts the private-address names and literals: the operator's own browser sits on
the operator's own network (HR_BROWSER_CDP_URL), and a loopback address was still refused here
by name after the plane had opened that browser (hr-test, 2026-09-30)."""
h = (host or "").strip().lower().rstrip(".")
if not h:
return "no host"
if h.startswith("[") and h.endswith("]"):
h = h[1:-1]
if _ip_private(h):
if not private_ok and _ip_private(h):
return "a private or local address"
if h == "localhost" or any(h.endswith(s) for s in _PRIVATE_NAMES[1:]):
if not private_ok and (h == "localhost" or any(h.endswith(s) for s in _PRIVATE_NAMES[1:])):
return "a private or local address"
if deny and _suffix_match(h, deny):
return "a site this agent is not allowed to visit"
Expand Down Expand Up @@ -363,11 +366,12 @@ async def _allowed(s: Session, url: str) -> str | None:
if u.scheme not in ("http", "https"):
return f"a {u.scheme or 'non-web'} address" if u.scheme not in ("about", "data", "blob") else None
host = u.hostname or ""
why = host_policy(host, s.allow, s.deny)
own = not s.vendor_id # the operator's own browser, on the operator's own network
why = host_policy(host, s.allow, s.deny, private_ok=own)
if why:
return why
if not s.vendor_id:
return None # the operator's own browser, on the operator's own network
if own:
return None
if host not in s.host_cache:
s.host_cache[host] = await resolves_private(host)
return "a private or local address" if s.host_cache[host] else None
Expand Down
10 changes: 7 additions & 3 deletions gateway/tests/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,9 +914,13 @@ def test_the_operators_own_browser_takes_the_vendors_place_whole(client, world,
assert ven.created == [] and ven.calls == [] # the vendor was never asked
own = browsers[-1]
assert own.cdp == "ws://chrome.lan:9222/devtools/browser/own" and len(own.contexts) == 1
# a private address is the operator's own network, not a page's trick
out = _call(client, tok1, "navigate", url="http://intranet.example/")
assert out["isError"] is False, out
# a private address is the operator's own network, not a page's trick: a resolved one, a
# loopback literal and a .local name alike (the name rules refused the last two first)
for url in ("http://intranet.example/", "http://127.0.0.1:3000/", "http://console.local/"):
out = _call(client, tok1, "navigate", url=url)
assert out["isError"] is False, (url, out)
assert browser_plane.host_policy("127.0.0.1", [], ["127.0.0.1"], private_ok=True) == "a site this agent is not allowed to visit"
assert browser_plane.host_policy("127.0.0.1", [], []) == "a private or local address"
# the second session gets its own context on the same browser
out = _call(client, tok2, "navigate", url="https://example.com/")
assert out["isError"] is False and len(own.contexts) == 2
Expand Down
Loading