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.
This commit is contained in:
imSp4rky
2026-10-25 12:22:28 -06:00
parent cc33600cc7
commit cb2ffdb55a
3 changed files with 78 additions and 10 deletions
+5
View File
@@ -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 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 that resolves only to a loopback, private or link-local address, so the server cannot reach
your local network. 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" !!! note "Each request crosses the tunnel twice"
A request from the server goes to your machine and then to the service, so it takes A request from the server goes to your machine and then to the service, so it takes
+47 -4
View File
@@ -28,6 +28,7 @@ from unshackle.core.tunnel import (
TunnelClient, TunnelClient,
connect_public, connect_public,
frame, frame,
is_fake_ip,
is_public_ip, is_public_ip,
tunnel_proxy, tunnel_proxy,
) )
@@ -106,24 +107,66 @@ def test_the_client_refuses_a_target_on_its_own_network(server: str, target: int
@pytest.mark.parametrize( @pytest.mark.parametrize(
("address", "public"), ("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: def test_is_public_ip(address: str, public: bool) -> None:
assert is_public_ip(address) is public 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: def test_localhost_is_refused() -> None:
with pytest.raises(PermissionError): with pytest.raises(PermissionError):
asyncio.run(connect_public("localhost", 443)) 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: 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) monkeypatch.setattr(asyncio.BaseEventLoop, "getaddrinfo", resolve)
with pytest.raises(PermissionError): 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: def local_proxy_ctx(params: dict, typed: set) -> click.Context:
+26 -6
View File
@@ -30,6 +30,7 @@ HEARTBEAT = 20
GRACE = 60 GRACE = 60
RELINK_WAIT = 10 RELINK_WAIT = 10
OPEN_TIMEOUT = 15 OPEN_TIMEOUT = 15
FAKE_IP_POOLS = (ipaddress.ip_network("198.18.0.0/15"), ipaddress.ip_network("2001:2::/48"))
MAX_TUNNELS = 4 MAX_TUNNELS = 4
MAX_STREAMS = 256 MAX_STREAMS = 256
CHUNK = 65536 CHUNK = 65536
@@ -282,20 +283,32 @@ async def close_tunnels(_: web.Application) -> None:
await tunnel.close() await tunnel.close()
def is_public_ip(address: str) -> bool: def parse_ip(address: str) -> Optional[ipaddress.IPv4Address | ipaddress.IPv6Address]:
try: try:
return ipaddress.ip_address(address.partition("%")[0]).is_global return ipaddress.ip_address(address.partition("%")[0])
except ValueError: 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]: 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 """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) 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: 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}") error: OSError = OSError(f"could not connect to {host}")
for address in addresses: for address in addresses:
try: try:
@@ -316,6 +329,7 @@ class TunnelClient:
self.lost = False self.lost = False
self.ready = threading.Event() self.ready = threading.Event()
self.relays: set[asyncio.Task[None]] = set() 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.thread = threading.Thread(target=lambda: asyncio.run(self.run()), name="tunnel", daemon=True)
self.loop: Optional[asyncio.AbstractEventLoop] = None self.loop: Optional[asyncio.AbstractEventLoop] = None
self.task: Optional[asyncio.Task[None]] = 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: async def open(self, link: Link, sid: int, host: str, port: int) -> None:
try: try:
reader, writer = await connect_public(host, port) 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 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}") log.debug(f"Tunnel dial to {host}:{port} failed: {e}")
await link.close_stream(sid) await link.close_stream(sid)