diff --git a/SECURITY.md b/SECURITY.md index 8b3b340..71fa342 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,11 +63,13 @@ The check goes by the address a connection comes from. A router that rewrites that address when it forwards a port makes an outsider look local, so the check is a second lock and not a reason to forward port 8937: don't. -Caddy serves Ride The Lightning (port 3051) and Mempool (port 60847) only to -this computer and to clients on your local network (private, link-local, and VPN -addresses), even when ports 80 and 443 are forwarded to this computer for public -services. Other IPv4 clients get the connection closed. IPv6 global addresses -are not filtered. +Ride The Lightning (port 3051) and Mempool (port 60847) listen on loopback only, +and Caddy is how your local network reaches them. Caddy does not filter them by +client address: forwarding ports 80 and 443 for public services does not reach +them, because they answer on ports of their own, which nothing asks you to +forward. Do not forward 3051 or 60847. If you do, Ride The Lightning still asks +for its own random password and locks out repeated failures, and Mempool shows +public blockchain data, but neither should face the internet. ### Public services and your home IP address diff --git a/modules/core/caddy.nix b/modules/core/caddy.nix index 0b8a26b..8bd79a4 100755 --- a/modules/core/caddy.nix +++ b/modules/core/caddy.nix @@ -32,11 +32,18 @@ let # Sites for the local network, one per loopback-only service. Written after # the public domain sites; each exists only where its service does. + # + # They do not filter by client address. A request can only reach them on + # their own ports, which no setup step asks you to forward, so forwarding + # 80/443 for public services does not expose them (a Host header on those + # ports cannot select a site that listens elsewhere). RTL has its own + # password and lockout, and Mempool shows public chain data. An address + # check here could not be made right for IPv6 anyway: a laptop's global + # address on the LAN looks exactly like a stranger's. bitcoinUiSites = lib.optionalString servesRtl '' :3051 { - import sovran_lan_only reverse_proxy :3050 encode gzip zstd } @@ -44,7 +51,6 @@ let + lib.optionalString servesMempool '' :60847 { - import sovran_lan_only reverse_proxy :60845 encode gzip zstd } @@ -124,26 +130,6 @@ EOF EOF ''} - # ── LAN-only guard ────────────────────────────── - # The RTL and Mempool sites below are meant for this home network - # only. Forwarding ports 80/443 on the router also lets other clients - # reach Caddy, so these sites check where a request comes from, not just - # which Host it asks for. Anyone else gets the connection closed. - # private_ranges 10/8, 172.16/12, 192.168/16, 127/8, fd00::/8, ::1 - # 100.64.0.0/10 Tailscale and other VPN addresses - # 169.254.0.0/16, fe80::/10, fc00::/7 link-local and unique-local - # 2000::/3 IPv6 global addresses. Computers on this network - # often connect over their own global address, which - # looks the same as one from the internet, so IPv6 - # global addresses are not filtered. - cat >> /run/caddy/Caddyfile <<'EOF' - -(sovran_lan_only) { - @outside not remote_ip private_ranges 100.64.0.0/10 169.254.0.0/16 fe80::/10 fc00::/7 2000::/3 - abort @outside -} -EOF - # ── Matrix ────────────────────────────────────── if [ -n "$MATRIX" ]; then if [ -f /run/caddy/element-calling.snippet ]; then diff --git a/tests/test_caddy_lan_only.py b/tests/test_caddy_lan_only.py deleted file mode 100644 index 6a96fdc..0000000 --- a/tests/test_caddy_lan_only.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Guards for the LAN-only Caddy sites. - -Ride The Lightning (:3051) and Mempool (:60847) sites are for the home network. -Caddy has to check where a request comes from because forwarding ports 80/443 on -the router lets other clients reach it too. The domain sites for the operator's -own public services must stay public. (The Hub is not a Caddy site: it is served -on its own port and checks its own clients.) - -These read modules/core/caddy.nix like the nix-file checks in test_security.py: -nothing is run and nothing touches the network. -""" - -import ipaddress -import os -import re -import unittest - -_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) - -# What Caddy's "private_ranges" shortcut expands to (see the remote_ip matcher docs). -_PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8", - "127.0.0.1/8", "fd00::/8", "::1"] - -_LAN_SITES = (":3051", ":60847") - - -def _read(*parts): - with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f: - return f.read() - - -def _site(src, address): - m = re.search(r"^" + re.escape(address) + r" \{\n(.*?)^\}$", src, re.S | re.M) - return m.group(1) if m else None - - -def _snippet(src): - m = re.search(r"^\(sovran_lan_only\) \{\n(.*?)^\}$", src, re.S | re.M) - return m.group(1) if m else None - - -def _allowed_networks(snippet): - m = re.search(r"^\s*@outside not remote_ip (.+)$", snippet, re.M) - assert m, "the @outside matcher is missing" - nets = [] - for token in m.group(1).split(): - for cidr in (_PRIVATE_RANGES if token == "private_ranges" else [token]): - nets.append(ipaddress.ip_network(cidr, strict=False)) - return nets - - -def _is_allowed(nets, address): - ip = ipaddress.ip_address(address) - return any(ip.version == n.version and ip in n for n in nets) - - -class LanOnlySites(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.src = _read("modules", "core", "caddy.nix") - - def test_lan_sites_use_the_guard_before_they_proxy(self): - for address in _LAN_SITES: - with self.subTest(site=address): - body = _site(self.src, address) - self.assertIsNotNone(body, f"{address} site not found") - self.assertIn("import sovran_lan_only", body) - self.assertLess(body.index("import sovran_lan_only"), - body.index("reverse_proxy")) - - def test_only_the_lan_sites_use_the_guard(self): - self.assertEqual(self.src.count("import sovran_lan_only"), len(_LAN_SITES)) - public = re.findall(r"^\$[A-Z]+ \{\n(.*?)^\}$", self.src, re.S | re.M) - self.assertGreaterEqual(len(public), 6, "domain sites not found") - for body in public: - self.assertNotIn("sovran_lan_only", body) - # the Matrix site that Element calling writes instead of the plain one - self.assertNotIn("sovran_lan_only", _read("modules", "element-calling.nix")) - - def test_guard_closes_the_connection_for_everyone_else(self): - snippet = _snippet(self.src) - self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found") - self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$") - # defined before the sites that import it are written (the sites come - # from bitcoinUiSites, which the generator appends after the snippet) - self.assertLess(self.src.index("(sovran_lan_only) {"), - self.src.index("${bitcoinUiSites}")) - - def test_guard_ranges(self): - nets = _allowed_networks(_snippet(self.src)) - for address in ("127.0.0.1", "::1", "10.0.0.1", "172.16.0.1", "172.31.255.254", - "192.168.1.10", "100.64.0.1", "100.127.255.254", "169.254.1.1", - "fd12:3456::1", "fe80::1", "fc00::1"): - with self.subTest(allowed=address): - self.assertTrue(_is_allowed(nets, address)) - for address in ("8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255", "11.0.0.1", - "172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255", - "100.128.0.1", "169.253.255.255"): - with self.subTest(refused=address): - self.assertFalse(_is_allowed(nets, address)) - - def test_ipv6_global_addresses_are_not_filtered(self): - # Computers on the home network often connect over their own global IPv6 - # address, which cannot be told apart from the internet's by address alone. - # If this is ever tightened, LAN clients on IPv6 networks lose the Hub. - nets = _allowed_networks(_snippet(self.src)) - self.assertTrue(_is_allowed(nets, "2001:db8::5")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_hub_direct.py b/tests/test_hub_direct.py index 094cda1..5a9e1db 100644 --- a/tests/test_hub_direct.py +++ b/tests/test_hub_direct.py @@ -57,6 +57,44 @@ class HubIsNotACaddySite(unittest.TestCase): ) +class CaddyDoesNoAddressFiltering(unittest.TestCase): + """Caddy is a bridge for RTL and Mempool and a TLS front for public sites. + + It used to carry a client-address guard (sovran_lan_only). That guard was + never aimed at these two sites: the bug was a Host header on ports 80/443 + reaching the Hub, and RTL and Mempool sit on ports of their own. It also + could not be made right for IPv6, where a laptop's global address on the + LAN is indistinguishable from a stranger's. + """ + + @classmethod + def setUpClass(cls): + cls.caddy = _read("modules", "core", "caddy.nix") + cls.code = _without_comments(cls.caddy) + + def test_there_is_no_address_filter(self): + # (private_ranges is deliberately not on this list: the Nextcloud site + # uses it for trusted_proxies, which is not a filter on who may connect.) + for needle in ("sovran_lan_only", "remote_ip", "abort @"): + with self.subTest(needle=needle): + self.assertNotIn(needle, self.code) + + def test_the_bitcoin_sites_are_plain_proxies(self): + for site, upstream in ((":3051", ":3050"), (":60847", ":60845")): + with self.subTest(site=site): + m = re.search(r"^" + re.escape(site) + r" \{\n(.*?)^\}$", self.code, re.S | re.M) + self.assertIsNotNone(m, f"{site} site not found") + directives = [l.strip() for l in m.group(1).splitlines() if l.strip()] + self.assertEqual(directives, [f"reverse_proxy {upstream}", "encode gzip zstd"]) + + def test_the_options_for_a_declared_prefix_are_gone(self): + # Never needed once Caddy stops guessing: neither the option nor its + # build-time assertion may linger half-wired. + roles = _read("modules", "core", "roles.nix") + self.assertNotIn("lanIPv6Prefixes", roles) + self.assertNotIn("lanIPv6Prefixes", self.caddy) + + class CaddyRunsWhereItIsNeeded(unittest.TestCase): @classmethod