Don't block a whole hostname over an unroutable fe80:: AAAA record

Sites with a misconfigured AAAA record pointing at an IPv6 link-local
address (the server's own NIC autoconf address leaked into public DNS by
a control panel) were refused entirely, even though their A record is
perfectly good. competa.com and upnano.com both do this.

is_private_hostname() refuses a hostname if ANY resolved address is
off-limits, because we cannot predict which one the fetcher will pick.
That reasoning does not hold for a bare fe80:: record: it is only
routable together with an interface scope, DNS cannot carry a zone id,
so getaddrinfo() returns it with sin6_scope_id == 0 and connect()
rejects it outright (EINVAL on Linux). Whichever address the fetcher
picks, the set of reachable hosts is the same - so refusing the name
cost legitimate sites and bought nothing.

Such records are now set aside rather than fatal, but only alongside at
least one usable address; a hostname resolving to nothing but link-local
space is still refused. IPv4 link-local (169.254.169.254 cloud metadata)
is untouched, a scoped literal (fe80::1%eth0, scope_id != 0) is
untouched, and CGNAT/private/reserved keep the any-record rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dgtlmoonandClaude Opus 5 committed 2026-09-04 16:59:31 +02:00
1 parent 0f4b556af0
commit bef38bc13e
2 files changed
+122 -4

No files matched your search

@@ -23,11 +23,30 @@ from unittest.mock import patch
from changedetectionio.browser_steps.browser_steps import steppable_browser_interface
from changedetectionio.validate_url import (
is_fetch_url_allowed,
is_private_hostname,
is_special_purpose_ip,
validate_fetch_url,
validate_fetch_url_async,
)
def _gai(*addresses):
"""Build a socket.getaddrinfo() return value from (address, family[, scope_id]) tuples.
Only family and sockaddr are read by is_private_hostname(), and the scope id is the whole
point of the link-local tests, so the sockaddr shape has to be the real one: 2-tuple for
AF_INET, 4-tuple (addr, port, flowinfo, scope_id) for AF_INET6.
"""
import socket
out = []
for addr, version, *rest in addresses:
scope_id = rest[0] if rest else 0
if version == 6:
out.append((socket.AF_INET6, socket.SOCK_STREAM, 6, '', (addr, 0, 0, scope_id)))
else:
out.append((socket.AF_INET, socket.SOCK_STREAM, 6, '', (addr, 0)))
return out
# tests/conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true for the functional suite, so the
# locked-down default has to be re-asserted explicitly rather than assumed.
LOCKED_DOWN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'false', 'ALLOW_FILE_URI': 'false'}
@@ -168,6 +187,54 @@ class TestFetchUrlGate(unittest.TestCase):
with self.subTest(url=url):
self.assertAllowed(url)
def test_junk_link_local_aaaa_record_does_not_block_a_public_site(self):
"""Real sites publish a bare 'fe80::...' AAAA by mistake - competa.com and upnano.com
both do (a Plesk/TransIP box leaking its own NIC autoconf address into DNS). Nothing can
connect to a link-local address without a zone id, and DNS cannot carry one, so refusing
the whole hostname over that record blocked legitimate sites for no security gain."""
with patch.dict('os.environ', LOCKED_DOWN):
with patch('socket.getaddrinfo', return_value=_gai(
('149.210.194.177', 4), ('fe80::5054:ff:fe3b:3cbd', 6))):
self.assertFalse(is_private_hostname('competa.example'))
self.assertAllowed('https://competa.example/')
def test_link_local_only_hostname_is_still_blocked(self):
"""The exception above is only for a junk record riding alongside a usable one. A name
that resolves to nothing but link-local space is what deliberately aiming the fetcher at
its own link segment looks like, so it stays refused."""
with patch.dict('os.environ', LOCKED_DOWN):
with patch('socket.getaddrinfo', return_value=_gai(('fe80::1', 6))):
self.assertTrue(is_private_hostname('linklocal.example'))
self.assertBlocked('https://linklocal.example/')
def test_scoped_link_local_is_still_blocked(self):
"""A zone id (fe80::1%eth0, scope_id != 0) makes the address genuinely connectable, so
the 'unreachable record' reasoning does not apply to it."""
with patch.dict('os.environ', LOCKED_DOWN):
with patch('socket.getaddrinfo', return_value=_gai(
('93.184.216.34', 4), ('fe80::1', 6, 2))):
self.assertTrue(is_private_hostname('scoped.example'))
def test_link_local_exception_does_not_extend_to_ipv4(self):
"""169.254.0.0/16 is link-local too, but it IS routable - and 169.254.169.254 is cloud
metadata. A public A record next to it must not buy it a pass."""
with patch.dict('os.environ', LOCKED_DOWN):
with patch('socket.getaddrinfo', return_value=_gai(
('93.184.216.34', 4), ('169.254.169.254', 4))):
self.assertTrue(is_private_hostname('metadata.example'))
self.assertBlocked('https://metadata.example/')
def test_link_local_exception_does_not_extend_to_other_blocked_ranges(self):
"""Only link-local is deferred; a public record alongside CGNAT/private/reserved space
still refuses the hostname (GHSA-gwph-fp79-379w)."""
with patch.dict('os.environ', LOCKED_DOWN):
for other in ('100.64.0.1', '10.0.0.1', '127.0.0.1', 'fc00::1', '::1'):
with self.subTest(other=other):
fam = 6 if ':' in other else 4
with patch('socket.getaddrinfo', return_value=_gai(
('93.184.216.34', 4), (other, fam))):
self.assertTrue(is_private_hostname('mixed.example'))
def test_validate_fetch_url_raises_with_the_reason(self):
with patch.dict('os.environ', LOCKED_DOWN):
with self.assertRaises(ValueError):
+55 -4
View File
@@ -141,6 +141,29 @@ def is_special_purpose_ip(ip):
return False, ''
def _is_unreachable_link_local(family, ip, sockaddr):
"""True for an IPv6 link-local address that arrived with no zone id, i.e. one nothing can
connect to.
fe80::/10 is only routable together with an interface scope ('fe80::1%eth0'). DNS AAAA
records cannot carry a zone id, so a link-local answer from getaddrinfo() comes back with
sin6_scope_id == 0 and connect() rejects it outright (EINVAL on Linux) - it is a dead
record, not a route into the fetcher's link segment.
Deliberately narrow:
* IPv4 link-local (169.254.0.0/16, and with it 169.254.169.254 cloud metadata) is NOT
covered - that one IS reachable and must stay blocked.
* A scoped literal such as http://[fe80::1%25eth0]/ resolves with scope_id != 0 and so
is not covered either.
"""
return (
family == socket.AF_INET6
and ip.is_link_local
and len(sockaddr) > 3
and sockaddr[3] == 0
)
def is_private_hostname(hostname):
"""Return True if hostname resolves to an IANA-restricted (private/reserved/non-global) IP address.
@@ -152,14 +175,29 @@ def is_private_hostname(hostname):
A hostname is refused if ANY of its A/AAAA records is off-limits: a name that answers
with one public and one CGNAT address is still a route to the CGNAT address.
ONE exception, and it is narrow (see _is_unreachable_link_local): a bare IPv6 link-local
AAAA record alongside at least one address we would allow. Plenty of real sites publish a
junk 'fe80::...' AAAA - it is the server's own NIC autoconf address leaked into DNS by a
misconfigured control panel - and refusing the whole hostname over a record nothing can
connect to blocks legitimate sites for no security gain.
"""
allowed_addresses = 0
deferred = [] # unreachable link-local records - only fatal if nothing else resolves
try:
for info in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(info[4][0])
family, sockaddr = info[0], info[4]
ip = ipaddress.ip_address(sockaddr[0])
blocked, why = is_special_purpose_ip(ip)
if blocked:
logger.warning(f"Hostname '{hostname}' resolves to {ip} which is {why} — refused.")
return True
if not blocked:
allowed_addresses += 1
continue
if _is_unreachable_link_local(family, ip, sockaddr):
deferred.append(ip)
continue
logger.warning(f"Hostname '{hostname}' resolves to {ip} which is {why} — refused.")
return True
except socket.gaierror as e:
logger.warning(f"{hostname} error checking {str(e)}")
return False
@@ -167,6 +205,19 @@ def is_private_hostname(hostname):
# getaddrinfo handed back something ip_address() won't parse - fail closed.
logger.warning(f"Hostname '{hostname}' produced an unparseable address ({e}) — refused.")
return True
if deferred and not allowed_addresses:
# Every answer was link-local. Nothing legitimate looks like this, and it is what
# deliberately pointing a public name at the fetcher's own link segment would look
# like, so keep refusing it.
logger.warning(f"Hostname '{hostname}' resolves only to link-local addresses "
f"({', '.join(str(i) for i in deferred)}) — refused.")
return True
if deferred:
logger.debug(f"Hostname '{hostname}' has unroutable link-local AAAA record(s) "
f"({', '.join(str(i) for i in deferred)}) - ignored, it also resolves to "
f"{allowed_addresses} usable address(es).")
logger.info(f"Hostname '{hostname}' is NOT private/IANA restricted.")
return False