mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(browser): raw CDP applies the metadata floor in both guard branches; docs state what it covers
The cloud-metadata check on raw CDP ran only with the private-address guard off, and only on a `url` param. With the guard on, an unlisted method such as Network.loadNetworkResource fetched a metadata host or a loopback URL from a public page, because only the page URL was checked. Every CDP call now screens each `url` param and every http(s) literal in its string params (function bodies, injected scripts, HTML) against the metadata floor whatever the guard says. With the guard on, unlisted methods also refuse private literals in their own params. browser_exec still follows a page's own redirect or fetch to a metadata address: the floor reads the URLs the agent writes, not the browser's later traffic. security.md and browser.md now say that instead of "refused on every route", and point at network-level IMDS blocking.
This commit is contained in:
1 parent
1fdfdecbcf
commit
f4aa77a467
5 files changed
+71
-16
No files matched your search
@@ -101,3 +101,25 @@ def test_raw_cdp_keeps_the_metadata_floor_when_the_private_guard_is_off(monkeypa
|
||||
assert "cloud metadata" in guard(method="Runtime.evaluate", params=metadata)
|
||||
lan = {"expression": template.format("http://192.168.1.5/")}
|
||||
assert guard(method="Runtime.evaluate", params=lan) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("local_backend", [False, True])
|
||||
def test_raw_cdp_url_params_are_screened_even_when_the_open_page_is_public(monkeypatch, local_backend):
|
||||
"""A public page does not vouch for a call's own URLs: unlisted resource-loading methods keep the metadata
|
||||
floor with the private guard on or off, and the private-address check whenever the guard is on."""
|
||||
from tools import browser_cdp_tool, browser_session_cloud
|
||||
monkeypatch.setattr(browser_session_cloud, "is_local_backend", lambda: local_backend)
|
||||
monkeypatch.setattr(browser_session_cloud, "allow_private_urls", lambda: False)
|
||||
monkeypatch.setattr(browser_cdp_tool, "_current_page_private_url", lambda endpoint, target_id: None)
|
||||
guard = browser_cdp_tool._browser_cdp_private_guard
|
||||
metadata = "http://metadata.google.internal/computeMetadata/v1/"
|
||||
calls = (("Network.loadNetworkResource", {"frameId": "F", "url": metadata, "options": {}}),
|
||||
("Runtime.callFunctionOn", {"functionDeclaration": f"() => fetch('{metadata}')"}),
|
||||
("Page.addScriptToEvaluateOnNewDocument", {"source": f"fetch(`{metadata}`)"}))
|
||||
for method, params in calls:
|
||||
assert "cloud metadata" in guard(method=method, params=params, endpoint="ws://x", target_id="T")
|
||||
loopback = {"frameId": "F", "url": "http://127.0.0.1:8080/", "options": {}}
|
||||
verdict = guard(method="Network.loadNetworkResource", params=loopback, endpoint="ws://x", target_id="T")
|
||||
assert (verdict is None) == local_backend
|
||||
public = {"frameId": "F", "url": "https://93.184.216.34/", "options": {}}
|
||||
assert guard(method="Network.loadNetworkResource", params=public, endpoint="ws://x", target_id="T") is None
|
||||
+32
-10
@@ -197,6 +197,20 @@ def _current_page_private_url(endpoint: str, target_id: Optional[str]) -> Option
|
||||
return next((u for u in page_urls if u and not u.startswith(_ORIGINLESS_PAGE_PREFIXES) and url_blocked(u)), None)
|
||||
|
||||
|
||||
def _param_url_literals(value: Any, key: str = "") -> list[str]:
|
||||
"""Every URL a CDP call's params name: each ``url`` value whole, plus the ``http(s)://`` literals in
|
||||
every string at any depth (``Network.loadNetworkResource``, ``Runtime.callFunctionOn`` bodies,
|
||||
``Page.addScriptToEvaluateOnNewDocument`` sources, injected HTML)."""
|
||||
from tools.browser_url_policy import url_literals
|
||||
if isinstance(value, str):
|
||||
return ([value.strip()] if key == "url" and value.strip() else []) + url_literals(value)
|
||||
if isinstance(value, dict):
|
||||
return [u for k, v in value.items() for u in _param_url_literals(v, str(k))]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [u for v in value for u in _param_url_literals(v, key)]
|
||||
return []
|
||||
|
||||
|
||||
def _browser_cdp_private_guard(*, method: str, params: dict[str, Any], endpoint: str = "",
|
||||
target_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Apply the browser SSRF/private-page guard to raw CDP calls.
|
||||
@@ -205,21 +219,29 @@ def _browser_cdp_private_guard(*, method: str, params: dict[str, Any], endpoint:
|
||||
its bypass, so a page whose URL cannot be verified as public refuses page-reading methods.
|
||||
"""
|
||||
from tools.browser_url_policy import eval_ssrf_guard_active, url_blocked
|
||||
from tools.url_safety import is_always_blocked_url
|
||||
guard_active = eval_ssrf_guard_active()
|
||||
guard = _METHOD_PARAM_GUARDS.get(method)
|
||||
if not eval_ssrf_guard_active():
|
||||
# The cloud-metadata floor stays unconditional, as in browser_exec's _url_policy_error (#16234):
|
||||
# every guarded method's probe, plus the ``url`` param any other method takes
|
||||
# (Network.loadNetworkResource, Fetch.continueRequest).
|
||||
from tools.url_safety import is_always_blocked_url
|
||||
probe = guard[0] if guard is not None else _navigate_private_target
|
||||
literal = probe(params or {}, is_always_blocked_url)
|
||||
return _blocked(f"Blocked: CDP {method} targets a cloud metadata endpoint ({literal}).",
|
||||
method) if literal else None
|
||||
if guard is not None:
|
||||
if guard_active and guard is not None:
|
||||
probe, template = guard
|
||||
literal = probe(params or {}, url_blocked)
|
||||
if literal:
|
||||
return _blocked(template.format(literal), method)
|
||||
# The cloud-metadata floor is unconditional for every method, as in browser_exec's _url_policy_error
|
||||
# (#16234): with the private guard on or off, sidecar and local Chromium included.
|
||||
literals = _param_url_literals(params or {})
|
||||
metadata = next((u for u in literals if is_always_blocked_url(u)), None)
|
||||
if metadata:
|
||||
return _blocked(f"Blocked: CDP {method} targets a cloud metadata endpoint ({metadata}).", method)
|
||||
if not guard_active:
|
||||
return None
|
||||
if guard is None:
|
||||
# A public page does not make an unlisted method's own URL params safe: Network.loadNetworkResource
|
||||
# fetches its ``url`` from the browser's network position whatever page is open.
|
||||
literal = next((u for u in literals if u.lower().startswith(("http://", "https://")) and url_blocked(u)), None)
|
||||
if literal:
|
||||
return _blocked(f"Blocked: CDP {method} params target a private or internal address ({literal}).",
|
||||
method)
|
||||
if method not in _CDP_PRIVATE_PAGE_ALLOWED_METHODS:
|
||||
blocked_url = _current_page_private_url(endpoint, target_id)
|
||||
if blocked_url == _PAGE_URL_UNVERIFIED:
|
||||
|
||||
@@ -136,7 +136,11 @@ def url_blocked(url: str) -> bool:
|
||||
return is_always_blocked_url(url) or not is_safe_url(url)
|
||||
|
||||
|
||||
def url_literals(text: str) -> list[str]:
|
||||
"""Every ``http(s)://`` literal in free text (a JS expression, an HTML snippet, a CDP param)."""
|
||||
return [m.rstrip(".,;") for m in _JS_URL_LITERAL_RE.findall(text)] if isinstance(text, str) else []
|
||||
|
||||
|
||||
def expression_targets_private_url(expression: str, *, blocked: Callable[[str], bool] = url_blocked) -> Optional[str]:
|
||||
"""The first ``http(s)://`` literal in a JS expression that ``blocked`` refuses (best-effort), else None."""
|
||||
literals = _JS_URL_LITERAL_RE.findall(expression) if isinstance(expression, str) else []
|
||||
return next((c for c in (m.rstrip(".,;") for m in literals) if blocked(c)), None)
|
||||
return next((c for c in url_literals(expression) if blocked(c)), None)
|
||||
@@ -113,7 +113,8 @@ and it works on headless servers with no desktop browser at all.
|
||||
`security.allow_private_urls` says: it reaches nothing the terminal can't.
|
||||
The private-address guard applies when the browser can reach networks the
|
||||
terminal can't (a cloud provider, a `/browser connect` endpoint, or a
|
||||
Docker/SSH/Modal terminal backend). Cloud-metadata endpoints are always refused.
|
||||
Docker/SSH/Modal terminal backend). Cloud-metadata URLs the agent writes are
|
||||
always refused.
|
||||
|
||||
**Which Chromium.** Hermes picks the first that exists:
|
||||
|
||||
@@ -282,8 +283,10 @@ With auto-routing disabled, private URLs are rejected with
|
||||
`"Blocked: URL targets a private or internal address"` unless you also set
|
||||
`security.allow_private_urls: true` (or the legacy `browser.allow_private_urls: true`),
|
||||
which hands them to the cloud provider — usually useless, since Browserbase etc.
|
||||
can't reach your LAN. Cloud-metadata endpoints (`169.254.169.254`,
|
||||
`metadata.google.internal`) are refused on every route, sidecar included.
|
||||
can't reach your LAN. Cloud-metadata URLs (`169.254.169.254`,
|
||||
`metadata.google.internal`) written in `browser_exec` code or raw CDP params are refused on
|
||||
every route, sidecar included; a page that redirects there on its own is not (see
|
||||
[Security](../security.md#what-allow_private_urls-covers-and-what-it-doesnt)).
|
||||
|
||||
The sidecar is the same local Chromium described [above](#local-chromium-default),
|
||||
so it needs that Chromium installed. Routing is decided from the URLs written in
|
||||
|
||||
@@ -809,7 +809,11 @@ not stop every route to a private host. With it `false`:
|
||||
| `terminal` (`curl http://localhost`, ...) | **Opens**: no URL guard |
|
||||
|
||||
Cloud-metadata endpoints are refused on every row except `terminal`, sidecar and
|
||||
local Chromium included. To keep the agent's browser off private hosts while a cloud provider is
|
||||
local Chromium included: a metadata URL written in `browser_exec` code, in a raw CDP call's
|
||||
params, or in a script it injects is refused before the browser sees it. The check reads the URLs
|
||||
the agent writes; it does not watch the browser afterwards, so a public page that redirects to, or
|
||||
fetches from, a metadata address is not stopped by it. On a cloud VM, block the metadata service at
|
||||
the network too (IMDSv2 with hop limit 1, a firewall rule). To keep the agent's browser off private hosts while a cloud provider is
|
||||
configured, set `browser.auto_local_for_private_urls: false`; that matters most on
|
||||
hosted installs (Docker, a VPS), where the sidecar sees the host's own loopback
|
||||
services and its LAN.
|
||||
|
||||
Reference in new issue
Block a user