mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user