{"repo":"NousResearch/hermes-agent","public_id":"16234","display_label":"SSRF protection for cloud metadata endpoints in browser routing","summary":"All three members concern SSRF guarding of cloud metadata (IMDS) endpoints in browser hybrid/cloud routing. One is an issue describing a possible bypass of the pre-navigation guard, and two are fixes that aim to prevent that bypass; their outcomes are not stated in the titles.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21228,"merged_at":"2026-05-07T12:38:05+00:00"},"resolution":{"state":"resolved","by":"pr-21228"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:38:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16234,"pr_number":21228,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:38:05+00:00"}],"members":[{"kind":"issue","number":16234,"title":"Security: cloud browser hybrid routing bypasses pre-nav SSRF guard for IMDS endpoints","url":null,"state":"closed"},{"kind":"pr","number":16250,"title":"fix(security): prevent SSRF bypass for IMDS endpoints in browser routing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16234","number":16234}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21228,"title":"fix(browser): enforce cloud-metadata SSRF floor in hybrid routing (#16234)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_browser_ssrf_local.py","tests/tools/test_url_safety.py","tools/browser_tool.py","tools/url_safety.py"],"fix_hunks":[{"file":"tests/tools/test_browser_ssrf_local.py","hunks":["@@ -106,6 +106,62 @@ def test_local_allows_public_url(self, monkeypatch, _common_patches):\n \n         assert result[\"success\"] is True\n \n+    # -- Always-blocked floor: hybrid routing bypass regression (#16234) -------\n+\n+    # Hybrid-routing feature flips auto_local_this_nav=True for private URLs,\n+    # which previously short-circuited _is_safe_url() entirely. An agent\n+    # running on EC2/GCP/Azure could navigate to 169.254.169.254 via the\n+    # spawned local Chromium sidecar and read IAM credentials via\n+    # browser_snapshot. The always-blocked floor must fire regardless of\n+    # routing.\n+    IMDS_URLS = [\n+        \"http://169.254.169.254/latest/meta-data/\",      # AWS / GCP / Azure / DO / Oracle\n+        \"http://169.254.169.253/metadata/instance\",        # Azure IMDS wire server\n+        \"http://169.254.170.2/v2/credentials\",             # AWS ECS task metadata\n+        \"http://100.100.100.200/latest/meta-data/\",        # Alibaba Cloud\n+        \"http://metadata.google.internal/computeMetadata/v1/\",  # GCP hostname\n+    ]\n+\n+    @pytest.mark.parametrize(\"imds_url\", IMDS_URLS)\n+    def test_cloud_blocks_imds_even_when_routing_to_local_sidecar(\n+        self, monkeypatch, _common_patches, imds_url\n+    ):\n+        \"\"\"Hybrid routing must not let cloud metadata endpoints through.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        # Simulate hybrid routing kicking in for this URL (what happens on\n+        # main pre-fix — cloud provider configured, _url_is_private → True,\n+        # so the session key routes to a local Chromium sidecar).\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+        # _is_safe_url would catch IMDS, but pre-fix it never ran. Force\n+        # it to return True here so the test is specifically pinning the\n+        # always-blocked floor as an independent gate.\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+\n+        result = json.loads(browser_tool.browser_navigate(imds_url))\n+\n+        assert result[\"success\"] is False\n+        assert \"cloud metadata endpoint\" in result[\"error\"]\n+\n+    def test_cloud_allows_ordinary_private_url_via_sidecar(\n+        self, monkeypatch, _common_patches\n+    ):\n+        \"\"\"Hybrid routing still works for ordinary private URLs — floor\n+        must be narrow enough to not break the PR #16136 feature.\"\"\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: False)\n+\n+        for private in (\n+            \"http://127.0.0.1:8080/dashboard\",\n+            \"http://192.168.1.1/admin\",\n+            \"http://10.0.0.5/\",\n+            \"http://myservice.local/\",\n+        ):\n+            result = json.loads(browser_tool.browser_navigate(private))\n+            assert result[\"success\"] is True, f\"Unexpected block for {private}: {result}\"\n+\n \n # ---------------------------------------------------------------------------\n # _is_local_backend() unit tests","@@ -236,6 +292,32 @@ def test_cloud_allows_redirect_to_public(self, monkeypatch, _common_patches):\n         assert result[\"success\"] is True\n         assert result[\"url\"] == final\n \n+    # -- Always-blocked floor: redirect to IMDS via hybrid sidecar (#16234) ----\n+\n+    def test_cloud_blocks_redirect_to_imds_even_via_sidecar(\n+        self, monkeypatch, _common_patches\n+    ):\n+        \"\"\"Redirect to a cloud metadata endpoint is blocked regardless of\n+        routing — even the hybrid local sidecar path can't return IMDS\n+        content to the agent.\"\"\"\n+        imds_final = \"http://169.254.169.254/latest/meta-data/\"\n+        monkeypatch.setattr(browser_tool, \"_is_local_backend\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_allow_private_urls\", lambda: False)\n+        monkeypatch.setattr(browser_tool, \"_is_local_sidecar_key\", lambda key: True)\n+        # _is_safe_url would catch it on main; force True to pin the\n+        # always-blocked floor as an independent gate.\n+        monkeypatch.setattr(browser_tool, \"_is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(\n+            browser_tool,\n+            \"_run_browser_command\",\n+            lambda *a, **kw: _make_browser_result(url=imds_final),\n+        )\n+\n+        result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL))\n+\n+        assert result[\"success\"] is False\n+        assert \"cloud metadata endpoint\" in result[\"error\"]\n+\n \n class TestAllowPrivateUrlsConfig:\n     @pytest.fixture(autouse=True)"]},{"file":"tests/tools/test_url_safety.py","hunks":["@@ -5,6 +5,7 @@\n \n from tools.url_safety import (\n     is_safe_url,\n+    is_always_blocked_url,\n     _is_blocked_ip,\n     _global_allow_private_urls,\n     _reset_allow_private_cache,","@@ -407,3 +408,69 @@ def test_empty_url_still_blocked_with_toggle(self, monkeypatch):\n         \"\"\"Empty URLs are still blocked.\"\"\"\n         monkeypatch.setenv(\"HERMES_ALLOW_PRIVATE_URLS\", \"true\")\n         assert is_safe_url(\"\") is False\n+\n+\n+class TestIsAlwaysBlockedUrl:\n+    \"\"\"The always-blocked floor — cloud metadata only, narrower than is_safe_url.\"\"\"\n+\n+    # -- The sentinel set that must always block --------------------------------\n+\n+    @pytest.mark.parametrize(\"url\", [\n+        \"http://169.254.169.254/latest/meta-data/\",            # AWS / GCP / Azure / DO / Oracle\n+        \"http://169.254.169.253/metadata/instance\",              # Azure IMDS wire server\n+        \"http://169.254.170.2/v2/credentials\",                   # AWS ECS task metadata\n+        \"http://100.100.100.200/latest/meta-data/\",              # Alibaba Cloud\n+        \"http://169.254.42.1/\",                                  # Any /16 link-local\n+    ])\n+    def test_literal_imds_ips_always_blocked(self, url):\n+        \"\"\"Literal IMDS IPs and the /16 link-local range always block.\"\"\"\n+        assert is_always_blocked_url(url) is True\n+\n+    def test_gcp_metadata_hostname_always_blocked_even_without_dns(self):\n+        \"\"\"metadata.google.internal blocks by hostname, no DNS needed.\"\"\"\n+        with patch(\"socket.getaddrinfo\", side_effect=socket.gaierror(\"nope\")):\n+            assert is_always_blocked_url(\"http://metadata.google.internal/\") is True\n+\n+    def test_hostname_resolving_to_imds_always_blocked(self):\n+        \"\"\"Attacker-controlled hostname resolving to IMDS still blocks.\"\"\"\n+        with patch(\"socket.getaddrinfo\", return_value=[\n+            (2, 1, 6, \"\", (\"169.254.169.254\", 0)),\n+        ]):\n+            assert is_always_blocked_url(\"http://attacker-controlled.example.com/\") is True\n+\n+    # -- Things the floor must NOT block ----------------------------------------\n+\n+    def test_public_url_not_blocked(self):\n+        assert is_always_blocked_url(\"https://example.com/path\") is False\n+\n+    @pytest.mark.parametrize(\"url\", [\n+        \"http://127.0.0.1:8080/\",\n+        \"http://192.168.1.1/\",\n+        \"http://10.0.0.5/\",\n+        \"http://172.16.0.1/\",\n+        \"http://100.64.0.1/\",  # CGNAT — blocked by is_safe_url but not by the floor\n+    ])\n+    def test_ordinary_private_urls_not_in_floor(self, url):\n+        \"\"\"Floor is narrower than is_safe_url — ordinary private URLs pass.\"\"\"\n+        assert is_always_blocked_url(url) is False\n+\n+    def test_dns_failure_not_in_floor(self):\n+        \"\"\"DNS failure on a non-sentinel hostname = not always-blocked.\n+\n+        Caller's ordinary fail-closed path (is_safe_url) handles that case.\n+        \"\"\"\n+        with patch(\"socket.getaddrinfo\", side_effect=socket.gaierror(\"fail\")):\n+            assert is_always_blocked_url(\"http://nonexistent.example.com/\") is False\n+\n+    def test_empty_url_not_in_floor(self):\n+        \"\"\"Empty URL falls through — caller decides what to do with a malformed URL.\"\"\"\n+        assert is_always_blocked_url(\"\") is False\n+\n+    def test_malformed_url_not_in_floor(self):\n+        \"\"\"Parse errors don't claim always-blocked status.\"\"\"\n+        assert is_always_blocked_url(\"not a url at all\") is False\n+\n+    def test_floor_ignores_allow_private_urls_toggle(self, monkeypatch):\n+        \"\"\"security.allow_private_urls can NOT unblock cloud metadata.\"\"\"\n+        monkeypatch.setenv(\"HERMES_ALLOW_PRIVATE_URLS\", \"true\")\n+        assert is_always_blocked_url(\"http://169.254.169.254/\") is True"]},{"file":"tools/browser_tool.py","hunks":["@@ -76,9 +76,13 @@\n     check_website_access = lambda url: None  # noqa: E731 — fail-open if policy module unavailable\n \n try:\n-    from tools.url_safety import is_safe_url as _is_safe_url\n+    from tools.url_safety import (\n+        is_safe_url as _is_safe_url,\n+        is_always_blocked_url as _is_always_blocked_url,\n+    )\n except Exception:\n     _is_safe_url = lambda url: False  # noqa: E731 — fail-closed: block all if safety module unavailable\n+    _is_always_blocked_url = lambda url: True  # noqa: E731 — fail-closed on the floor too\n from tools.browser_providers.base import CloudBrowserProvider\n from tools.browser_providers.browserbase import BrowserbaseProvider\n from tools.browser_providers.browser_use import BrowserUseProvider","@@ -837,6 +841,10 @@ def _url_is_private(url: str) -> bool:\n                 ip.is_private\n                 or ip.is_loopback\n                 or ip.is_link_local\n+                # 172.16.0.0/12: only covered by ip.is_private on Python\n+                # ≥3.11 (bpo-40791).  Explicit check keeps 3.10 runtimes\n+                # routing these to the local sidecar correctly.\n+                or ip in ipaddress.ip_network(\"172.16.0.0/12\")\n                 or ip in ipaddress.ip_network(\"100.64.0.0/10\")\n             )\n         except ValueError:","@@ -2081,6 +2089,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:\n     nav_session_key = _navigation_session_key(effective_task_id, url)\n     auto_local_this_nav = _is_local_sidecar_key(nav_session_key)\n \n+    # Always-blocked floor: cloud metadata / IMDS endpoints are denied\n+    # regardless of backend, hybrid routing, or allow_private_urls.\n+    # There's no legitimate agent use case for navigating to\n+    # 169.254.169.254 / metadata.google.internal / ECS task metadata\n+    # via a browser, and routing those to a local Chromium sidecar\n+    # on an EC2/GCP/Azure host exfiltrates IAM credentials (#16234).\n+    if not _is_local_backend() and _is_always_blocked_url(url):\n+        return json.dumps({\n+            \"success\": False,\n+            \"error\": \"Blocked: URL targets a cloud metadata endpoint\",\n+        })\n+\n     if (\n         not _is_local_backend()\n         and not auto_local_this_nav","@@ -2143,6 +2163,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:\n         # Skipped for local backends (same rationale as the pre-nav check),\n         # and for the hybrid local sidecar (we're already on a local browser\n         # hitting a private URL by design).\n+        # Always-blocked floor (cloud metadata / IMDS) is enforced even\n+        # when auto_local_this_nav is true — see pre-nav check for\n+        # rationale (#16234).\n+        if (\n+            not _is_local_backend()\n+            and final_url\n+            and final_url != url\n+            and _is_always_blocked_url(final_url)\n+        ):\n+            _run_browser_command(nav_session_key, \"open\", [\"about:blank\"], timeout=10)\n+            return json.dumps({\n+                \"success\": False,\n+                \"error\": \"Blocked: redirect landed on a cloud metadata endpoint\",\n+            })\n+\n         if (\n             not _is_local_backend()\n             and not auto_local_this_nav"]},{"file":"tools/url_safety.py","hunks":["@@ -147,6 +147,102 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:\n     return False\n \n \n+def is_always_blocked_url(url: str) -> bool:\n+    \"\"\"Return True when the URL targets an always-blocked endpoint.\n+\n+    This is the security floor — cloud metadata IPs / hostnames\n+    (169.254.169.254, metadata.google.internal, ECS task metadata, etc.)\n+    that have no legitimate agent use regardless of backend, routing, or\n+    the ``allow_private_urls`` toggle.  Used by callers that bypass the\n+    full ``is_safe_url`` check for their own reasons (e.g. hybrid cloud\n+    browser routing to a local Chromium sidecar for private URLs) and\n+    still need to enforce the non-negotiable floor before letting the\n+    request proceed.\n+\n+    Returns True (= blocked) on:\n+      - Hostnames in ``_BLOCKED_HOSTNAMES``\n+      - IPs / networks in ``_ALWAYS_BLOCKED_IPS`` / ``_ALWAYS_BLOCKED_NETWORKS``\n+      - URLs whose hostname resolves to any of the above\n+\n+    Returns False (= not in the always-blocked floor) on:\n+      - Benign public / private / loopback URLs (whether or not they'd\n+        be blocked by the ordinary SSRF check)\n+      - DNS-resolution failures for non-sentinel hostnames (these are\n+        someone else's problem — the caller's ordinary fail-closed path\n+        will catch them if applicable)\n+      - Parse errors (caller decides fail-open vs fail-closed)\n+\n+    Intentionally narrower than ``is_safe_url``: only blocks the sentinel\n+    set, not ordinary private addresses.  Callers that want the full\n+    SSRF check should still use ``is_safe_url``.\n+    \"\"\"\n+    try:\n+        parsed = urlparse(url)\n+        hostname = (parsed.hostname or \"\").strip().lower().rstrip(\".\")\n+        if not hostname:\n+            return False\n+\n+        # Blocked-hostname check fires regardless of DNS resolution\n+        if hostname in _BLOCKED_HOSTNAMES:\n+            logger.warning(\n+                \"Blocked request to internal hostname (always-blocked floor): %s\",\n+                hostname,\n+            )\n+            return True\n+\n+        # Literal IP → check directly against the always-blocked set\n+        try:\n+            ip = ipaddress.ip_address(hostname)\n+        except ValueError:\n+            ip = None\n+\n+        if ip is not None:\n+            if ip in _ALWAYS_BLOCKED_IPS or any(\n+                ip in net for net in _ALWAYS_BLOCKED_NETWORKS\n+            ):\n+                logger.warning(\n+                    \"Blocked request to cloud metadata address \"\n+                    \"(always-blocked floor): %s\",\n+                    hostname,\n+                )\n+                return True\n+            return False\n+\n+        # Hostname → resolve and check every answer.  DNS failure is NOT\n+        # always-blocked (caller's ordinary path handles that).\n+        try:\n+            addr_info = socket.getaddrinfo(\n+                hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM\n+            )\n+        except socket.gaierror:\n+            return False\n+\n+        for _family, _, _, _, sockaddr in addr_info:\n+            ip_str = sockaddr[0]\n+            try:\n+                resolved = ipaddress.ip_address(ip_str)\n+            except ValueError:\n+                continue\n+            if resolved in _ALWAYS_BLOCKED_IPS or any(\n+                resolved in net for net in _ALWAYS_BLOCKED_NETWORKS\n+            ):\n+                logger.warning(\n+                    \"Blocked request to cloud metadata address \"\n+                    \"(always-blocked floor): %s -> %s\",\n+                    hostname,\n+                    ip_str,\n+                )\n+                return True\n+\n+        return False\n+\n+    except Exception as exc:\n+        # Parse failures or unexpected errors — don't claim the URL is\n+        # always-blocked.  Caller decides what to do with a malformed URL.\n+        logger.debug(\"is_always_blocked_url error for %s: %s\", url, exc)\n+        return False\n+\n+\n def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool:\n     \"\"\"Return True when a trusted HTTPS hostname may bypass IP-class blocking.\"\"\"\n     return scheme == \"https\" and hostname in _TRUSTED_PRIVATE_IP_HOSTS\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}