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
11 changes: 10 additions & 1 deletion src/iicp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
Loading