caddy: stop filtering the RTL and Mempool sites by client address
c33457fput an address check (sovran_lan_only) on the Hub, RTL and Mempool sites. It was written for ports 80/443 being forwarded and a Host header selecting a site, and that only ever applied to the Hub. RTL and Mempool are sites on ports of their own (:3051, :60847): a request on 80/443 cannot select them, whatever Host it carries. Checked with Caddy 2.9.1 and the Caddyfile this module generates for Server + Desktop with every domain configured, serving the public sites on a stand-in port: Host: sovransystemsos.local, localhost:8937, 127.0.0.1 and x:3051 all get an empty 200, and Host: matrix.example.org gets the Synapse stand-in. With the Hub off Caddy the guard has nothing left to guard, and it could not be made right for the two sites that remain: - IPv6. A laptop's global address on the LAN looks exactly like a stranger's. The choice was between letting all of 2000::/3 through, which is the whole IPv6 internet and is whatc33457fdoes, and refusing every LAN device that connects over a global address unless the operator copies the ISP's prefix into a Nix option. - They do not need it. RTL has a random 20-character password (pwgen -s 20, about 119 bits) and, since 0.15.12, which Sovran_Bitcoin pins, a 30-minute lockout keyed on the client address. Mempool shows public chain data. If someone forwards 3051 or 60847 that is the same exposure as any other port on the machine, and SECURITY.md says not to. Remove the snippet and its two imports, and tests/test_caddy_lan_only.py with them. What is still worth pinning moves to test_hub_direct.py: no address filter anywhere in caddy.nix, the two sites are plain proxies to their loopback ports, and no option for a declared prefix is left half-wired. Behaviour change: RTL and Mempool answer any client that can reach :3051 or :60847, as they did beforec33457f. In practice that is the local network, because nothing asks you to forward those ports. Checked with the real generator and Caddy 2.9.1: Node Only generates `auto_https off` and the two plain sites and validates. Run live next to the real Hub, a LAN client gets the Hub, RTL and Mempool; a stranger's address gets RTL and Mempool (by design) and a 403 from the Hub; port 80 is not listening on Node Only.
This commit is contained in:
+7
-5
@@ -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
|
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.
|
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
|
Ride The Lightning (port 3051) and Mempool (port 60847) listen on loopback only,
|
||||||
this computer and to clients on your local network (private, link-local, and VPN
|
and Caddy is how your local network reaches them. Caddy does not filter them by
|
||||||
addresses), even when ports 80 and 443 are forwarded to this computer for public
|
client address: forwarding ports 80 and 443 for public services does not reach
|
||||||
services. Other IPv4 clients get the connection closed. IPv6 global addresses
|
them, because they answer on ports of their own, which nothing asks you to
|
||||||
are not filtered.
|
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
|
### Public services and your home IP address
|
||||||
|
|
||||||
|
|||||||
+8
-22
@@ -32,11 +32,18 @@ let
|
|||||||
|
|
||||||
# Sites for the local network, one per loopback-only service. Written after
|
# Sites for the local network, one per loopback-only service. Written after
|
||||||
# the public domain sites; each exists only where its service does.
|
# 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 =
|
bitcoinUiSites =
|
||||||
lib.optionalString servesRtl ''
|
lib.optionalString servesRtl ''
|
||||||
|
|
||||||
:3051 {
|
:3051 {
|
||||||
import sovran_lan_only
|
|
||||||
reverse_proxy :3050
|
reverse_proxy :3050
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
}
|
}
|
||||||
@@ -44,7 +51,6 @@ let
|
|||||||
+ lib.optionalString servesMempool ''
|
+ lib.optionalString servesMempool ''
|
||||||
|
|
||||||
:60847 {
|
:60847 {
|
||||||
import sovran_lan_only
|
|
||||||
reverse_proxy :60845
|
reverse_proxy :60845
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
}
|
}
|
||||||
@@ -124,26 +130,6 @@ EOF
|
|||||||
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 ──────────────────────────────────────
|
# ── Matrix ──────────────────────────────────────
|
||||||
if [ -n "$MATRIX" ]; then
|
if [ -n "$MATRIX" ]; then
|
||||||
if [ -f /run/caddy/element-calling.snippet ]; then
|
if [ -f /run/caddy/element-calling.snippet ]; then
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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):
|
class CaddyRunsWhereItIsNeeded(unittest.TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user