From 8454f40929ec5fe94965267113719a203a4b9918 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 27 Oct 2026 10:07:23 -0600 Subject: [PATCH] fix(tunnel): block local networks instead of allowing public ones for --local-proxy A VPN's synthetic DNS answer (fake-IP 198.18.0.0/15, 192.0.0.69) now works for a host name. An IP from the server, in any numeric form, must be public; dials to the client itself are closed. --- docs/guide/proxies-and-vpn.md | 19 +++--- tests/remote/unit/test_tunnel.py | 112 ++++++++++++++++++++++++++----- unshackle/core/tunnel.py | 82 ++++++++++++++++++---- 3 files changed, 176 insertions(+), 37 deletions(-) diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index e2c88150..01eb3729 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -315,14 +315,17 @@ machine opens none, because the client opens the connection to the server. A NAT firewall, or a Cloudflare tunnel in front of the server does not stop it. The service traffic stays encrypted from the server to the service, so the client relays only -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. +encrypted bytes. The client refuses to connect to `localhost`, and to a host name that resolves +only to an address on your machine or local network: loopback, private, link-local, CGNAT +(`100.64.0.0/10`) or multicast. An IP address that the server sends must also be public, and +the client closes a connection that reaches one of its own addresses. Other devices on your +network that have a public IPv6 address, and your router's public address, are not blocked. + +Some VPN and proxy apps answer a host name with a synthetic address, for example fake-IP DNS in +TUN mode (`198.18.0.0/15`), and map it back to the real host. The client accepts any such +address for a host name, so `--local-proxy` works with these apps. The client cannot see the +real address behind a synthetic one, 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 975d512e..db0d2196 100644 --- a/tests/remote/unit/test_tunnel.py +++ b/tests/remote/unit/test_tunnel.py @@ -28,7 +28,8 @@ from unshackle.core.tunnel import ( TunnelClient, connect_public, frame, - is_fake_ip, + is_ip_literal, + is_local_ip, is_public_ip, tunnel_proxy, ) @@ -78,6 +79,7 @@ def test_requests_through_the_tunnel_reach_the_client_side_target( server: str, target: int, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) # the test target is on loopback + monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False) client = TunnelClient(server, {"X-Secret-Key": "alice"}) tunnel_id = client.start() @@ -114,6 +116,7 @@ def test_the_client_refuses_a_target_on_its_own_network(server: str, target: int ("10.0.0.1", False), ("::1", False), ("198.18.2.36", False), + ("64:ff9b::c0a8:101", False), ], ) def test_is_public_ip(address: str, public: bool) -> None: @@ -121,35 +124,99 @@ def test_is_public_ip(address: str, public: bool) -> None: @pytest.mark.parametrize( - ("address", "fake"), + ("address", "local"), [ - ("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), + ("8.8.8.8", False), + ("198.18.2.36", False), + ("2001:2::1", False), + ("192.0.0.69", False), + ("192.0.0.88", False), + ("127.0.0.1", True), + ("10.0.0.1", True), + ("172.16.0.1", True), + ("192.168.1.1", True), + ("169.254.169.254", True), + ("100.64.0.1", True), + ("192.0.0.1", True), + ("0.0.0.0", True), + ("224.0.0.1", True), + ("::1", True), + ("fdfe:dcba:9876::1", True), + ("fe80::1%eth0", True), + ("::ffff:192.168.1.1", True), + ("64:ff9b::c0a8:101", True), + ("64:ff9b::808:808", False), + ("::", True), + ("::7f00:1", True), + ("::ffff:0:7f00:1", True), + ("64:ff9b:1::c0a8:101", True), + ("192.0.0.9", True), + ("192.0.0.10", True), + ("not-an-address", True), ], ) -def test_is_fake_ip(address: str, fake: bool) -> None: - assert is_fake_ip(address) is fake +def test_is_local_ip(address: str, local: bool) -> None: + assert is_local_ip(address) is local -def test_a_name_that_resolves_to_a_fake_ip_is_dialled(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("address", ["198.18.2.36", "192.0.0.69", "2001:2::1", "8.8.8.8"]) +def test_a_name_that_resolves_to_a_non_local_address_is_dialled(monkeypatch: pytest.MonkeyPatch, address: str) -> 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))] + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, port))] dialled: list[str] = [] async def open_connection(address: str, port: int) -> tuple: dialled.append(address) - return None, None + return None, FakeWriter(("10.0.0.2", 50000), (address, port)) 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"] + assert dialled == [address] + + +class FakeWriter: + def __init__(self, sockname: tuple, peername: tuple) -> None: + self.extra = {"sockname": sockname, "peername": peername} + self.closed = False + + def get_extra_info(self, name: str) -> tuple: + return self.extra[name] + + def close(self) -> None: + self.closed = True + + +@pytest.mark.parametrize( + ("host", "literal"), + [ + ("8.8.8.8", True), + ("::1", True), + ("198.18.1", True), + ("3323068417", True), + ("0x7f.1", True), + ("api.example", False), + ], +) +def test_is_ip_literal(host: str, literal: bool) -> None: + assert is_ip_literal(host) is literal + + +def test_a_dial_that_reaches_the_client_itself_is_refused(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.0.1", port))] + + writer = FakeWriter(("198.18.0.1", 50000), ("198.18.0.1", 443)) + + async def open_connection(address: str, port: int) -> tuple: + return None, writer + + monkeypatch.setattr(asyncio.BaseEventLoop, "getaddrinfo", resolve) + monkeypatch.setattr(asyncio, "open_connection", open_connection) + with pytest.raises(PermissionError, match="this machine"): + asyncio.run(connect_public("tun.example", 443)) + assert writer.closed def test_localhost_is_refused() -> None: @@ -157,7 +224,18 @@ def test_localhost_is_refused() -> None: asyncio.run(connect_public("localhost", 443)) -@pytest.mark.parametrize(("host", "address"), [("rebind.example", "192.168.1.1"), ("198.18.0.1", "198.18.0.1")]) +@pytest.mark.parametrize( + ("host", "address"), + [ + ("rebind.example", "192.168.1.1"), + ("metadata.example", "169.254.169.254"), + ("nat64.example", "64:ff9b::c0a8:101"), + ("198.18.0.1", "198.18.0.1"), + ("192.0.0.69", "192.0.0.69"), + ("198.18.1", "198.18.0.1"), + ("3221225541", "192.0.0.69"), + ], +) def test_a_target_that_resolves_to_a_local_address_is_refused( monkeypatch: pytest.MonkeyPatch, host: str, address: str ) -> None: @@ -202,6 +280,7 @@ def test_the_tunnel_port_refuses_a_proxy_uri_without_the_tunnel_credentials( server: str, target: int, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) + monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False) client = TunnelClient(server, {"X-Secret-Key": "alice"}) tunnel_id = client.start() port = TUNNELS[tunnel_id].port @@ -371,6 +450,7 @@ def test_a_dial_the_server_gave_up_on_is_dropped_by_the_client( ) -> None: """When the server times out an OPEN, the client must not keep the connection it makes later.""" monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) + monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False) monkeypatch.setattr(tunnel, "OPEN_TIMEOUT", 0.3) real_connect = tunnel.connect_public diff --git a/unshackle/core/tunnel.py b/unshackle/core/tunnel.py index 1cc04782..05b3a747 100644 --- a/unshackle/core/tunnel.py +++ b/unshackle/core/tunnel.py @@ -30,7 +30,30 @@ 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")) +LOCAL_NETWORKS = tuple( + ipaddress.ip_network(n) + for n in ( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", # CGNAT, also tailnets + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/29", # DS-Lite endpoints on the user's router + "192.0.0.9/32", # port-control and TURN anycast, answered by the user's ISP or router + "192.0.0.10/32", + "192.168.0.0/16", + "224.0.0.0/4", + "255.255.255.255/32", + "::/96", # also IPv4-compatible addresses, which some stacks route to the embedded IPv4 + "::ffff:0:0:0/96", # IPv4-translated + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8", + ) +) +NAT64 = (ipaddress.ip_network("64:ff9b::/96"), ipaddress.ip_network("64:ff9b:1::/48")) MAX_TUNNELS = 4 MAX_STREAMS = 256 CHUNK = 65536 @@ -290,31 +313,64 @@ def parse_ip(address: str) -> Optional[ipaddress.IPv4Address | ipaddress.IPv6Add return None +def is_local_ip(address: str) -> bool: + """True for an address on the client's own machine or LAN, or one that is not an address. An IPv4 address + inside an IPv4-mapped or NAT64 address counts as itself.""" + ip = parse_ip(address) + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped: + ip = ip.ipv4_mapped + elif any(ip in prefix for prefix in NAT64): + ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) + return ip is None or any(ip in network for network in LOCAL_NETWORKS) + + def is_public_ip(address: str) -> bool: - return (ip := parse_ip(address)) is not None and ip.is_global + return (ip := parse_ip(address)) is not None and ip.is_global and not is_local_ip(address) -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) +def is_ip_literal(host: str) -> bool: + """True when the resolver reads ``host`` as a number, which includes short forms such as ``10.1``.""" + if parse_ip(host): + return True + try: + socket.inet_aton(host) + except (OSError, ValueError): + return False + return True + + +def dialled_itself(writer: asyncio.StreamWriter) -> bool: + """True when the connection runs from one of the client's own addresses to the same address.""" + return parse_ip(str(writer.get_extra_info("sockname")[0])) == parse_ip(str(writer.get_extra_info("peername")[0])) 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. 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.""" + """Connect to the first allowed address of ``host``. The server picks the targets, so the client never dials + its own machine or LAN. A name may resolve to any other address, because a VPN app's DNS can answer with a + synthetic one (such as fake-IP DNS in a TUN) and map it back to the real host, which the client cannot + check. An IP address from the server must be public.""" infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM) 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 ({', '.join(resolved)})") + if is_ip_literal(host): + addresses = [a for a in resolved if is_public_ip(a)] + if not addresses: + raise PermissionError(f"{host} is not a public address") + else: + addresses = [a for a in resolved if not is_local_ip(a)] + if not 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: - return await asyncio.wait_for(asyncio.open_connection(address, port), 10) + reader, writer = await asyncio.wait_for(asyncio.open_connection(address, port), 10) except (OSError, asyncio.TimeoutError) as e: error = OSError(str(e)) + continue + if not dialled_itself(writer): + return reader, writer + writer.close() + error = PermissionError(f"{host} resolves to this machine ({address})") raise error