From d73e19e32adcb285bc8d61eb68ef74c9563bf0fd Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Sat, 29 Aug 2026 10:56:12 +0200 Subject: [PATCH] fix: preserve explicit local route opt-in in client --- src/iicp_client/client.py | 11 ++++++++++- tests/test_client.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/iicp_client/client.py b/src/iicp_client/client.py index 8f8479c..38a53f0 100644 --- a/src/iicp_client/client.py +++ b/src/iicp_client/client.py @@ -18,6 +18,7 @@ from iicp_client._http import _traceparent, get_json, post_json from iicp_client.dispatch_ticket import policy_manifest_binding_matches, verify_dispatch_route_ticket +from iicp_client.endpoint_security import private_endpoints_allowed from iicp_client.errors import IicpError from iicp_client.policy import ensure_intent_allowed from iicp_client.request_projection import project_execution_constraints, project_route_options @@ -69,7 +70,15 @@ def _is_ssrf_safe(url: str) -> bool: if parsed.scheme not in ("http", "https"): return False host = (parsed.hostname or "").lower() - if not host or host in {"localhost", "0.0.0.0", "::1", "::"}: + if not host: + return False + # Keep route filtering aligned with the address-pinned transport. The + # explicit local-only opt-in is needed by hermetic tests and private + # deployments; without this check discovery discarded the route before the + # transport's independently guarded resolver could evaluate it. + if private_endpoints_allowed(): + return True + if host in {"localhost", "0.0.0.0", "::1", "::"}: return False if any(host.endswith(s) for s in (".local", ".internal", ".lan", ".test", ".invalid", ".localhost")): return False diff --git a/tests/test_client.py b/tests/test_client.py index a165c22..72f0369 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -147,6 +147,29 @@ def test_discover_health_fields_default_none_against_old_directory(): assert result.nodes[0].exposure_mode is None +@respx.mock +def test_discover_loopback_route_requires_explicit_local_only_opt_in(monkeypatch): + loopback = { + "nodes": [ + { + "node_id": "local-fixture", + "endpoint": "http://127.0.0.1:9484", + "score": 1.0, + "available": True, + "region": "test-local", + } + ] + } + respx.get(DISCOVER_URL).mock(return_value=httpx.Response(200, json=loopback)) + monkeypatch.delenv("IICP_PROXY_ALLOW_LOOPBACK_NODES", raising=False) + client = IicpClient(ClientConfig(directory_url=DIRECTORY, route_discovery_mode="legacy")) + assert client.discover("urn:iicp:intent:llm:chat:v1").nodes == [] + + monkeypatch.setenv("IICP_PROXY_ALLOW_LOOPBACK_NODES", "1") + allowed = client.discover("urn:iicp:intent:llm:chat:v1") + assert [node.node_id for node in allowed.nodes] == ["local-fixture"] + + @respx.mock def test_discover_browser_usable_only_filters_http_ipv6_nodes(): respx.get(DISCOVER_URL).mock(return_value=httpx.Response(200, json={