Skip to content

Commit db4cf2b

Browse files
cliffhallclaude
andcommitted
fix(fetch): check the host name the connection uses
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: cliffhall <[email protected]>
1 parent 69b53e9 commit db4cf2b

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

‎src/fetch/src/mcp_server_fetch/server.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,13 @@ async def _refuse_private_destination(request: httpx.Request) -> None:
133133
134134
httpx runs request hooks before every request it sends, redirect hops
135135
included, so a public URL cannot bounce the fetch to an internal one.
136+
137+
The name checked is ``raw_host``, the ASCII (IDNA 2008) form the transport
138+
connects to. ``host`` is the decoded Unicode form, which getaddrinfo would
139+
re-encode with IDNA 2003 into a different name for some labels (``ß``
140+
becomes ``ss``), so checking it would vet one name and connect to another.
136141
"""
137-
host = request.url.host
142+
host = request.url.raw_host.decode("ascii")
138143
if not host:
139144
return # httpx rejects a URL with no host itself.
140145
try:

‎src/fetch/tests/test_protocol.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
serve,
3838
)
3939

40-
from .harness import FakeWeb, connect, raw_request
40+
from .harness import PUBLIC_IP, FakeWeb, connect, raw_request
4141

4242
PAGE = "https://example.com/page"
4343
ROBOTS = "https://example.com/robots.txt"
@@ -842,6 +842,34 @@ async def test_unresolvable_host_is_left_to_the_transport(web: FakeWeb) -> None:
842842
assert web.lookups == ["only-the-proxy-knows.example"]
843843

844844

845+
@pytest.mark.parametrize(
846+
"url, connected_host",
847+
[
848+
# IDNA 2003 (the codec getaddrinfo applies to a Unicode name) maps ß
849+
# and final sigma to ss and σ; IDNA 2008, which httpx connects with,
850+
# keeps them. The guard must resolve the name the connection uses.
851+
("http://faß.example.com/", "xn--fa-hia.example.com"),
852+
("http://xn--fa-hia.example.com/", "xn--fa-hia.example.com"),
853+
("http://σοφος.example.com/", "xn--0xaajbq.example.com"),
854+
],
855+
)
856+
async def test_guard_resolves_the_name_the_connection_uses(
857+
web: FakeWeb, url: str, connected_host: str
858+
) -> None:
859+
web.dns["fass.example.com"] = [PUBLIC_IP]
860+
web.dns["σοφοσ.example.com"] = [PUBLIC_IP]
861+
web.dns[connected_host] = ["127.0.0.1"]
862+
web.add(f"http://{connected_host}/", plain("internal-secret"))
863+
async with connect(ignore_robots_txt=True) as (session, _):
864+
result = await call(session, {"url": url})
865+
assert wire(result)["isError"] is True
866+
assert f": {connected_host} resolves to 127.0.0.1, which is not a public" in (
867+
text_of(result)
868+
)
869+
assert web.lookups == [connected_host]
870+
assert web.requests == []
871+
872+
845873
async def test_allow_private_ips_follows_redirect_to_private_address(
846874
web: FakeWeb,
847875
) -> None:

0 commit comments

Comments
 (0)