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.
This commit is contained in:
imSp4rky
2026-10-27 10:07:23 -06:00
parent 06f87dafa3
commit 8454f40929
3 changed files with 176 additions and 37 deletions
+11 -8
View File
@@ -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. 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 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 encrypted bytes. The client refuses to connect to `localhost`, and to a host name that resolves
that resolves only to a loopback, private or link-local address, so the server cannot reach only to an address on your machine or local network: loopback, private, link-local, CGNAT
your local network. (`100.64.0.0/10`) or multicast. An IP address that the server sends must also be public, and
A VPN or proxy app in TUN mode with fake-IP DNS answers a host name with an address from the client closes a connection that reaches one of its own addresses. Other devices on your
`198.18.0.0/15` or `2001:2::/48` and maps it back to the real host. The client accepts that network that have a public IPv6 address, and your router's public address, are not blocked.
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 Some VPN and proxy apps answer a host name with a synthetic address, for example fake-IP DNS in
real address of a host name, so it cannot stop a host name that points into your local network. 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" !!! 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
+96 -16
View File
@@ -28,7 +28,8 @@ from unshackle.core.tunnel import (
TunnelClient, TunnelClient,
connect_public, connect_public,
frame, frame,
is_fake_ip, is_ip_literal,
is_local_ip,
is_public_ip, is_public_ip,
tunnel_proxy, tunnel_proxy,
) )
@@ -78,6 +79,7 @@ def test_requests_through_the_tunnel_reach_the_client_side_target(
server: str, target: int, monkeypatch: pytest.MonkeyPatch server: str, target: int, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) # the test target is on loopback 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"}) client = TunnelClient(server, {"X-Secret-Key": "alice"})
tunnel_id = client.start() 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), ("10.0.0.1", False),
("::1", False), ("::1", False),
("198.18.2.36", False), ("198.18.2.36", False),
("64:ff9b::c0a8:101", False),
], ],
) )
def test_is_public_ip(address: str, public: bool) -> None: 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( @pytest.mark.parametrize(
("address", "fake"), ("address", "local"),
[ [
("198.18.2.36", True), ("8.8.8.8", False),
("198.19.255.1", True), ("198.18.2.36", False),
("198.20.0.1", False), ("2001:2::1", False),
("::ffff:198.18.0.1", False), ("192.0.0.69", False),
("2001:2::1", True), ("192.0.0.88", False),
("2001:2:1::1", False), ("127.0.0.1", True),
("fdfe:dcba:9876::1", False), ("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: def test_is_local_ip(address: str, local: bool) -> None:
assert is_fake_ip(address) is fake 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: 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] = [] dialled: list[str] = []
async def open_connection(address: str, port: int) -> tuple: async def open_connection(address: str, port: int) -> tuple:
dialled.append(address) 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.BaseEventLoop, "getaddrinfo", resolve)
monkeypatch.setattr(asyncio, "open_connection", open_connection) monkeypatch.setattr(asyncio, "open_connection", open_connection)
asyncio.run(connect_public("api.example", 443)) 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: def test_localhost_is_refused() -> None:
@@ -157,7 +224,18 @@ def test_localhost_is_refused() -> None:
asyncio.run(connect_public("localhost", 443)) 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( def test_a_target_that_resolves_to_a_local_address_is_refused(
monkeypatch: pytest.MonkeyPatch, host: str, address: str monkeypatch: pytest.MonkeyPatch, host: str, address: str
) -> None: ) -> 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 server: str, target: int, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True)
monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False)
client = TunnelClient(server, {"X-Secret-Key": "alice"}) client = TunnelClient(server, {"X-Secret-Key": "alice"})
tunnel_id = client.start() tunnel_id = client.start()
port = TUNNELS[tunnel_id].port port = TUNNELS[tunnel_id].port
@@ -371,6 +450,7 @@ def test_a_dial_the_server_gave_up_on_is_dropped_by_the_client(
) -> None: ) -> None:
"""When the server times out an OPEN, the client must not keep the connection it makes later.""" """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, "is_public_ip", lambda address: True)
monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False)
monkeypatch.setattr(tunnel, "OPEN_TIMEOUT", 0.3) monkeypatch.setattr(tunnel, "OPEN_TIMEOUT", 0.3)
real_connect = tunnel.connect_public real_connect = tunnel.connect_public
+69 -13
View File
@@ -30,7 +30,30 @@ 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")) 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_TUNNELS = 4
MAX_STREAMS = 256 MAX_STREAMS = 256
CHUNK = 65536 CHUNK = 65536
@@ -290,31 +313,64 @@ def parse_ip(address: str) -> Optional[ipaddress.IPv4Address | ipaddress.IPv6Add
return None 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: 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: def is_ip_literal(host: 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.""" """True when the resolver reads ``host`` as a number, which includes short forms such as ``10.1``."""
return (ip := parse_ip(address)) is not None and any(ip in pool for pool in FAKE_IP_POOLS) 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]: 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 allowed address of ``host``. The server picks the targets, so the client never dials
only to the client's own machine or LAN is refused. A fake IP passes only when it comes from a name lookup: its own machine or LAN. A name may resolve to any other address, because a VPN app's DNS can answer with a
the TUN maps it back to the real host, which the client cannot check.""" 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) infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
resolved = [str(info[4][0]) for info in infos] resolved = [str(info[4][0]) for info in infos]
from_name = parse_ip(host) is None if is_ip_literal(host):
addresses = [a for a in resolved if is_public_ip(a) or (from_name and is_fake_ip(a))] addresses = [a for a in resolved if is_public_ip(a)]
if not addresses: if not addresses:
raise PermissionError(f"{host} resolves only to local addresses ({', '.join(resolved)})") 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}") error: OSError = OSError(f"could not connect to {host}")
for address in addresses: for address in addresses:
try: 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: except (OSError, asyncio.TimeoutError) as e:
error = OSError(str(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 raise error