mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
Cloud metadata endpoints (169.254.169.254 etc.) are now always blocked
by browser_navigate regardless of hybrid routing, allow_private_urls,
or backend.
Bug: commit 42c076d3 (#16136) added hybrid routing that flips
auto_local_this_nav=True for private URLs and short-circuits
_is_safe_url(). IMDS endpoints are technically private (169.254/16
link-local), so the sidecar happily routed them to a local Chromium,
and the agent could read IAM credentials via browser_snapshot. On
EC2/GCP/Azure this is a full SSRF-to-credential-theft.
Fix: new is_always_blocked_url() in url_safety.py — a narrow floor
that checks _BLOCKED_HOSTNAMES, _ALWAYS_BLOCKED_IPS,
_ALWAYS_BLOCKED_NETWORKS only. Applied as an independent gate in
browser_navigate's pre-nav and post-redirect checks, BEFORE
auto_local_this_nav gets a chance to short-circuit. Ordinary private
URLs (localhost, 192.168.x, 10.x, .local, CGNAT) still route to the
local sidecar as the #16136 feature intends.
Secondary fix (reporter's finding): _url_is_private() now explicitly
checks 172.16.0.0/12. ipaddress.is_private only covers that range on
Python ≥3.11 (bpo-40791), so on 3.10 runtimes those URLs were routed
to cloud instead of the local sidecar. No security impact — just a
correctness fix for the hybrid-routing feature.
Closes #16234.
This commit is contained in:
parent
12289c2630
commit
0214858ef5
4 changed files with 281 additions and 1 deletions
|
|
@ -106,6 +106,62 @@ class TestPreNavigationSsrf:
|
|||
|
||||
assert result["success"] is True
|
||||
|
||||
# -- Always-blocked floor: hybrid routing bypass regression (#16234) -------
|
||||
|
||||
# Hybrid-routing feature flips auto_local_this_nav=True for private URLs,
|
||||
# which previously short-circuited _is_safe_url() entirely. An agent
|
||||
# running on EC2/GCP/Azure could navigate to 169.254.169.254 via the
|
||||
# spawned local Chromium sidecar and read IAM credentials via
|
||||
# browser_snapshot. The always-blocked floor must fire regardless of
|
||||
# routing.
|
||||
IMDS_URLS = [
|
||||
"http://169.254.169.254/latest/meta-data/", # AWS / GCP / Azure / DO / Oracle
|
||||
"http://169.254.169.253/metadata/instance", # Azure IMDS wire server
|
||||
"http://169.254.170.2/v2/credentials", # AWS ECS task metadata
|
||||
"http://100.100.100.200/latest/meta-data/", # Alibaba Cloud
|
||||
"http://metadata.google.internal/computeMetadata/v1/", # GCP hostname
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("imds_url", IMDS_URLS)
|
||||
def test_cloud_blocks_imds_even_when_routing_to_local_sidecar(
|
||||
self, monkeypatch, _common_patches, imds_url
|
||||
):
|
||||
"""Hybrid routing must not let cloud metadata endpoints through."""
|
||||
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||
# Simulate hybrid routing kicking in for this URL (what happens on
|
||||
# main pre-fix — cloud provider configured, _url_is_private → True,
|
||||
# so the session key routes to a local Chromium sidecar).
|
||||
monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True)
|
||||
# _is_safe_url would catch IMDS, but pre-fix it never ran. Force
|
||||
# it to return True here so the test is specifically pinning the
|
||||
# always-blocked floor as an independent gate.
|
||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||
|
||||
result = json.loads(browser_tool.browser_navigate(imds_url))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "cloud metadata endpoint" in result["error"]
|
||||
|
||||
def test_cloud_allows_ordinary_private_url_via_sidecar(
|
||||
self, monkeypatch, _common_patches
|
||||
):
|
||||
"""Hybrid routing still works for ordinary private URLs — floor
|
||||
must be narrow enough to not break the PR #16136 feature."""
|
||||
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True)
|
||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
||||
|
||||
for private in (
|
||||
"http://127.0.0.1:8080/dashboard",
|
||||
"http://192.168.1.1/admin",
|
||||
"http://10.0.0.5/",
|
||||
"http://myservice.local/",
|
||||
):
|
||||
result = json.loads(browser_tool.browser_navigate(private))
|
||||
assert result["success"] is True, f"Unexpected block for {private}: {result}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_local_backend() unit tests
|
||||
|
|
@ -236,6 +292,32 @@ class TestPostRedirectSsrf:
|
|||
assert result["success"] is True
|
||||
assert result["url"] == final
|
||||
|
||||
# -- Always-blocked floor: redirect to IMDS via hybrid sidecar (#16234) ----
|
||||
|
||||
def test_cloud_blocks_redirect_to_imds_even_via_sidecar(
|
||||
self, monkeypatch, _common_patches
|
||||
):
|
||||
"""Redirect to a cloud metadata endpoint is blocked regardless of
|
||||
routing — even the hybrid local sidecar path can't return IMDS
|
||||
content to the agent."""
|
||||
imds_final = "http://169.254.169.254/latest/meta-data/"
|
||||
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True)
|
||||
# _is_safe_url would catch it on main; force True to pin the
|
||||
# always-blocked floor as an independent gate.
|
||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_run_browser_command",
|
||||
lambda *a, **kw: _make_browser_result(url=imds_final),
|
||||
)
|
||||
|
||||
result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "cloud metadata endpoint" in result["error"]
|
||||
|
||||
|
||||
class TestAllowPrivateUrlsConfig:
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from unittest.mock import patch
|
|||
|
||||
from tools.url_safety import (
|
||||
is_safe_url,
|
||||
is_always_blocked_url,
|
||||
_is_blocked_ip,
|
||||
_global_allow_private_urls,
|
||||
_reset_allow_private_cache,
|
||||
|
|
@ -407,3 +408,69 @@ class TestAllowPrivateUrlsIntegration:
|
|||
"""Empty URLs are still blocked."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert is_safe_url("") is False
|
||||
|
||||
|
||||
class TestIsAlwaysBlockedUrl:
|
||||
"""The always-blocked floor — cloud metadata only, narrower than is_safe_url."""
|
||||
|
||||
# -- The sentinel set that must always block --------------------------------
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://169.254.169.254/latest/meta-data/", # AWS / GCP / Azure / DO / Oracle
|
||||
"http://169.254.169.253/metadata/instance", # Azure IMDS wire server
|
||||
"http://169.254.170.2/v2/credentials", # AWS ECS task metadata
|
||||
"http://100.100.100.200/latest/meta-data/", # Alibaba Cloud
|
||||
"http://169.254.42.1/", # Any /16 link-local
|
||||
])
|
||||
def test_literal_imds_ips_always_blocked(self, url):
|
||||
"""Literal IMDS IPs and the /16 link-local range always block."""
|
||||
assert is_always_blocked_url(url) is True
|
||||
|
||||
def test_gcp_metadata_hostname_always_blocked_even_without_dns(self):
|
||||
"""metadata.google.internal blocks by hostname, no DNS needed."""
|
||||
with patch("socket.getaddrinfo", side_effect=socket.gaierror("nope")):
|
||||
assert is_always_blocked_url("http://metadata.google.internal/") is True
|
||||
|
||||
def test_hostname_resolving_to_imds_always_blocked(self):
|
||||
"""Attacker-controlled hostname resolving to IMDS still blocks."""
|
||||
with patch("socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("169.254.169.254", 0)),
|
||||
]):
|
||||
assert is_always_blocked_url("http://attacker-controlled.example.com/") is True
|
||||
|
||||
# -- Things the floor must NOT block ----------------------------------------
|
||||
|
||||
def test_public_url_not_blocked(self):
|
||||
assert is_always_blocked_url("https://example.com/path") is False
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://127.0.0.1:8080/",
|
||||
"http://192.168.1.1/",
|
||||
"http://10.0.0.5/",
|
||||
"http://172.16.0.1/",
|
||||
"http://100.64.0.1/", # CGNAT — blocked by is_safe_url but not by the floor
|
||||
])
|
||||
def test_ordinary_private_urls_not_in_floor(self, url):
|
||||
"""Floor is narrower than is_safe_url — ordinary private URLs pass."""
|
||||
assert is_always_blocked_url(url) is False
|
||||
|
||||
def test_dns_failure_not_in_floor(self):
|
||||
"""DNS failure on a non-sentinel hostname = not always-blocked.
|
||||
|
||||
Caller's ordinary fail-closed path (is_safe_url) handles that case.
|
||||
"""
|
||||
with patch("socket.getaddrinfo", side_effect=socket.gaierror("fail")):
|
||||
assert is_always_blocked_url("http://nonexistent.example.com/") is False
|
||||
|
||||
def test_empty_url_not_in_floor(self):
|
||||
"""Empty URL falls through — caller decides what to do with a malformed URL."""
|
||||
assert is_always_blocked_url("") is False
|
||||
|
||||
def test_malformed_url_not_in_floor(self):
|
||||
"""Parse errors don't claim always-blocked status."""
|
||||
assert is_always_blocked_url("not a url at all") is False
|
||||
|
||||
def test_floor_ignores_allow_private_urls_toggle(self, monkeypatch):
|
||||
"""security.allow_private_urls can NOT unblock cloud metadata."""
|
||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||
assert is_always_blocked_url("http://169.254.169.254/") is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue