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
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user