From cb2ffdb55ace2aca9c2d77c45e90a0f4aa5ee9d5 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 25 Oct 2026 12:22:28 -0600 Subject: [PATCH] fix(tunnel): accept fake-ip dns answers for --local-proxy host names A TUN-mode VPN with fake-IP DNS answers names from 198.18.0.0/15 or 2001:2::/48, which the LAN guard refused, so every dial failed. Refused hosts now warn once. --- docs/guide/proxies-and-vpn.md | 5 ++++ tests/remote/unit/test_tunnel.py | 51 +++++++++++++++++++++++++++++--- unshackle/core/tunnel.py | 32 ++++++++++++++++---- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index a75180a8..e2c88150 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -318,6 +318,11 @@ The service traffic stays encrypted from the server to the service, so the clien encrypted bytes. The client refuses to connect to `localhost`, and to a host name or IP address that resolves only to a loopback, private or link-local address, so the server cannot reach your local network. +A VPN or proxy app in TUN mode with fake-IP DNS answers a host name with an address from +`198.18.0.0/15` or `2001:2::/48` and maps it back to the real host. The client accepts that +address for a host name, but not as an IP address from the server. An app set to a different +fake-IP range does not work with `--local-proxy`. With fake-IP DNS, the client cannot see the +real address of a host name, so it cannot stop a host name that points into your local network. !!! note "Each request crosses the tunnel twice" A request from the server goes to your machine and then to the service, so it takes diff --git a/tests/remote/unit/test_tunnel.py b/tests/remote/unit/test_tunnel.py index 4864d573..975d512e 100644 --- a/tests/remote/unit/test_tunnel.py +++ b/tests/remote/unit/test_tunnel.py @@ -28,6 +28,7 @@ from unshackle.core.tunnel import ( TunnelClient, connect_public, frame, + is_fake_ip, is_public_ip, tunnel_proxy, ) @@ -106,24 +107,66 @@ def test_the_client_refuses_a_target_on_its_own_network(server: str, target: int @pytest.mark.parametrize( ("address", "public"), - [("8.8.8.8", True), ("127.0.0.1", False), ("192.168.1.10", False), ("10.0.0.1", False), ("::1", False)], + [ + ("8.8.8.8", True), + ("127.0.0.1", False), + ("192.168.1.10", False), + ("10.0.0.1", False), + ("::1", False), + ("198.18.2.36", False), + ], ) def test_is_public_ip(address: str, public: bool) -> None: assert is_public_ip(address) is public +@pytest.mark.parametrize( + ("address", "fake"), + [ + ("198.18.2.36", True), + ("198.19.255.1", True), + ("198.20.0.1", False), + ("::ffff:198.18.0.1", False), + ("2001:2::1", True), + ("2001:2:1::1", False), + ("fdfe:dcba:9876::1", False), + ], +) +def test_is_fake_ip(address: str, fake: bool) -> None: + assert is_fake_ip(address) is fake + + +def test_a_name_that_resolves_to_a_fake_ip_is_dialled(monkeypatch: pytest.MonkeyPatch) -> None: + async def resolve(self: object, host: str, port: int, **_: object) -> list: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("198.18.2.36", port))] + + dialled: list[str] = [] + + async def open_connection(address: str, port: int) -> tuple: + dialled.append(address) + return None, None + + monkeypatch.setattr(asyncio.BaseEventLoop, "getaddrinfo", resolve) + monkeypatch.setattr(asyncio, "open_connection", open_connection) + asyncio.run(connect_public("api.example", 443)) + assert dialled == ["198.18.2.36"] + + def test_localhost_is_refused() -> None: with pytest.raises(PermissionError): asyncio.run(connect_public("localhost", 443)) -def test_a_name_that_resolves_to_a_local_address_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize(("host", "address"), [("rebind.example", "192.168.1.1"), ("198.18.0.1", "198.18.0.1")]) +def test_a_target_that_resolves_to_a_local_address_is_refused( + monkeypatch: pytest.MonkeyPatch, host: str, address: str +) -> None: async def resolve(self: object, host: str, port: int, **_: object) -> list: - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.1", port))] + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, port))] monkeypatch.setattr(asyncio.BaseEventLoop, "getaddrinfo", resolve) with pytest.raises(PermissionError): - asyncio.run(connect_public("rebind.example", 443)) + asyncio.run(connect_public(host, 443)) def local_proxy_ctx(params: dict, typed: set) -> click.Context: diff --git a/unshackle/core/tunnel.py b/unshackle/core/tunnel.py index 098bb376..1cc04782 100644 --- a/unshackle/core/tunnel.py +++ b/unshackle/core/tunnel.py @@ -30,6 +30,7 @@ HEARTBEAT = 20 GRACE = 60 RELINK_WAIT = 10 OPEN_TIMEOUT = 15 +FAKE_IP_POOLS = (ipaddress.ip_network("198.18.0.0/15"), ipaddress.ip_network("2001:2::/48")) MAX_TUNNELS = 4 MAX_STREAMS = 256 CHUNK = 65536 @@ -282,20 +283,32 @@ async def close_tunnels(_: web.Application) -> None: await tunnel.close() -def is_public_ip(address: str) -> bool: +def parse_ip(address: str) -> Optional[ipaddress.IPv4Address | ipaddress.IPv6Address]: try: - return ipaddress.ip_address(address.partition("%")[0]).is_global + return ipaddress.ip_address(address.partition("%")[0]) except ValueError: - return False + return None + + +def is_public_ip(address: str) -> bool: + return (ip := parse_ip(address)) is not None and ip.is_global + + +def is_fake_ip(address: str) -> bool: + """True for an address from a pool that fake-IP DNS in a TUN-mode VPN or proxy hands out for a name.""" + return (ip := parse_ip(address)) is not None and any(ip in pool for pool in FAKE_IP_POOLS) async def connect_public(host: str, port: int) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: """Connect to the first public address of ``host``. The server picks the targets, so a name that resolves - only to the client's own machine or LAN is refused.""" + only to the client's own machine or LAN is refused. A fake IP passes only when it comes from a name lookup: + the TUN maps it back to the real host, which the client cannot check.""" infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM) - addresses = [info[4][0] for info in infos if is_public_ip(str(info[4][0]))] + resolved = [str(info[4][0]) for info in infos] + from_name = parse_ip(host) is None + addresses = [a for a in resolved if is_public_ip(a) or (from_name and is_fake_ip(a))] if not addresses: - raise PermissionError(f"{host} resolves only to local addresses") + raise PermissionError(f"{host} resolves only to local addresses ({', '.join(resolved)})") error: OSError = OSError(f"could not connect to {host}") for address in addresses: try: @@ -316,6 +329,7 @@ class TunnelClient: self.lost = False self.ready = threading.Event() self.relays: set[asyncio.Task[None]] = set() + self.refused: set[str] = set() self.thread = threading.Thread(target=lambda: asyncio.run(self.run()), name="tunnel", daemon=True) self.loop: Optional[asyncio.AbstractEventLoop] = None self.task: Optional[asyncio.Task[None]] = None @@ -401,6 +415,12 @@ class TunnelClient: async def open(self, link: Link, sid: int, host: str, port: int) -> None: try: reader, writer = await connect_public(host, port) + except PermissionError as e: + if host not in self.refused: # the server retries, so warn once per host + self.refused.add(host) + log.warning(f"Tunnel refused {e}") + await link.close_stream(sid) + return except (OSError, ValueError, asyncio.TimeoutError) as e: # the idna codec raises ValueError on a bad hostname log.debug(f"Tunnel dial to {host}:{port} failed: {e}") await link.close_stream(sid)