diff --git a/Makefile.in b/Makefile.in index 716171db1..fedf2f44a 100644 --- a/Makefile.in +++ b/Makefile.in @@ -68,7 +68,7 @@ CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ # Objects for CHECK_PROGS to clean CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o # Compile-only feature-shape checks. -CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o +CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o access-no-inet6.o # note that the -I. is needed to handle config.h when using VPATH .c.o: @@ -85,6 +85,10 @@ syscall-no-at-fdcwd.o: syscall.c $(HEADERS) $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/syscall.c -o $@ +access-no-inet6.o: access.c $(HEADERS) + $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ + -DRSYNC_TEST_NO_INET6 -c $(srcdir)/access.c -o $@ + .PHONY: install install: all -$(MKDIR_P) $(DESTDIR)$(bindir) diff --git a/access.c b/access.c index a9b6149c8..8031feb72 100644 --- a/access.c +++ b/access.c @@ -20,6 +20,9 @@ #include "rsync.h" #include "ifuncs.h" +#ifdef RSYNC_TEST_NO_INET6 +#undef INET6 +#endif #ifdef HAVE_NETGROUP_H #include #endif @@ -30,8 +33,9 @@ extern const char undetermined_hostname[]; static int match_hostname(const char **host_ptr, const char *addr, const char *tok, int deny) { - struct hostent *hp; - unsigned int i; + struct addrinfo hints, *res, *res0, *resa = NULL; + const char *a = NULL; + int addrlen = 0; const char *host = *host_ptr; #ifdef SUPPORT_IDN char idn_tok[1024]; @@ -65,7 +69,10 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return 0; /* Now try forward-DNS on the token (config-specified hostname) and see if the IP matches. */ - if (!(hp = gethostbyname(tok))) { + memset(&hints, 0, sizeof hints); + hints.ai_family = PF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(tok, NULL, &hints, &res0) != 0) { /* A deny-list hostname token we cannot resolve must fail CLOSED: * we can't prove the peer isn't the denied host, so treat the * unresolvable token as a match (deny). Allow-list tokens keep @@ -74,16 +81,73 @@ static int match_hostname(const char **host_ptr, const char *addr, const char *t return deny; } - for (i = 0; hp->h_addr_list[i] != NULL; i++) { - if (strcmp(addr, inet_ntoa(*(struct in_addr*)(hp->h_addr_list[i]))) == 0) { - /* If reverse lookups are off, we'll use the conf-specified - * hostname in preference to UNDETERMINED. */ - if (host == undetermined_hostname) - *host_ptr = strdup(tok); - return 1; + /* Parse the peer address once so the resolved addresses below can be + * compared against it in binary form by family, as match_address() + * does, rather than by comparing formatted strings. */ + memset(&hints, 0, sizeof hints); + hints.ai_family = PF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; +#ifdef AI_NUMERICHOST + hints.ai_flags = AI_NUMERICHOST; +#endif + if (getaddrinfo(addr, NULL, &hints, &resa) == 0) { + switch (resa->ai_family) { + case PF_INET: + a = (char *)&((struct sockaddr_in *)resa->ai_addr)->sin_addr; + addrlen = 4; + break; +#ifdef INET6 + case PF_INET6: + a = (char *)&((struct sockaddr_in6 *)resa->ai_addr)->sin6_addr; + addrlen = 16; + break; +#endif + default: + break; } } + if (a) { + for (res = res0; res; res = res->ai_next) { + const char *t = NULL; + + if (res->ai_family != resa->ai_family) + continue; + switch (res->ai_family) { + case PF_INET: + t = (char *)&((struct sockaddr_in *)res->ai_addr)->sin_addr; + break; +#ifdef INET6 + case PF_INET6: { + const struct sockaddr_in6 *peer6 = (const struct sockaddr_in6 *)resa->ai_addr; + const struct sockaddr_in6 *resolved6 = (const struct sockaddr_in6 *)res->ai_addr; + +#ifdef HAVE_SOCKADDR_IN6_SCOPE_ID + if (resolved6->sin6_scope_id && peer6->sin6_scope_id != resolved6->sin6_scope_id) + continue; +#endif + t = (char *)&resolved6->sin6_addr; + break; + } +#endif + default: + continue; + } + if (memcmp(a, t, addrlen) == 0) { + /* If reverse lookups are off, we'll use the conf-specified + * hostname in preference to UNDETERMINED. */ + if (host == undetermined_hostname) + *host_ptr = strdup(tok); + freeaddrinfo(resa); + freeaddrinfo(res0); + return 1; + } + } + } + + if (resa) + freeaddrinfo(resa); + freeaddrinfo(res0); return 0; } diff --git a/testsuite/daemon-access-hostname_test.py b/testsuite/daemon-access-hostname_test.py new file mode 100755 index 000000000..6c15da1bc --- /dev/null +++ b/testsuite/daemon-access-hostname_test.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Daemon forward-DNS hostname rules against IPv4 and IPv6 peers.""" + +import socket +import subprocess + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, RSYNC, + make_tree, require_tcp, rmtree, rsync_argv, start_test_daemon, + test_fail, test_skipped, +) + +IPV4_PORT = 12895 +IPV6_PORT = 12897 +require_tcp("hostname access matching needs a real TCP peer") + +try: + for family, address in ((socket.AF_INET, '127.0.0.1'), (socket.AF_INET6, '::1')): + with socket.socket(family, socket.SOCK_STREAM) as probe: + probe.bind((address, 0)) +except OSError: + test_skipped("IPv4 or IPv6 loopback is unavailable") + + +def loopback_name(family, address): + for candidate in ('localhost', 'ip6-localhost', 'ip6-loopback', socket.gethostname()): + try: + answers = socket.getaddrinfo(candidate, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror: + continue + if any(item[0] == family and item[4][0] == address for item in answers): + return candidate + test_skipped(f"no local hostname resolves to {address}") + + +host4 = loopback_name(socket.AF_INET, '127.0.0.1') +host6 = loopback_name(socket.AF_INET6, '::1') + +src = FROMDIR +rmtree(src) +make_tree(src, depth=2) + + +def start(family, address, host, port): + conf = SCRATCHDIR / f'access-hostname-{family}.conf' + conf.write_text( + f"pid file = {SCRATCHDIR}/rsyncd-{family}.pid\n" + "use chroot = no\n" + "reverse lookup = no\n" + "forward lookup = yes\n" + f"log file = {SCRATCHDIR}/{'rsyncd.log' if family == 'ipv6' else 'rsyncd-ipv4.log'}\n" + f"\n[plain]\n\tpath = {src}\n\tread only = yes\n" + f"\n[allow-host]\n\tpath = {src}\n\tread only = yes\n\thosts allow = {host}\n" + f"\n[deny-host]\n\tpath = {src}\n\tread only = yes\n\thosts deny = {host}\n" + f"\n[allow-unresolved]\n\tpath = {src}\n\tread only = yes\n\thosts allow = nope.nonexistent.invalid\n" + f"\n[deny-unresolved]\n\tpath = {src}\n\tread only = yes\n\thosts deny = nope.nonexistent.invalid\n" + f"\n[forward-off]\n\tpath = {src}\n\tread only = yes\n\tforward lookup = no\n\thosts allow = {host}\n" + ) + return start_test_daemon(conf, port, rsync_cmd=RSYNC, address=address) + + +urls = ( + ('IPv4', start('ipv4', '127.0.0.1', host4, IPV4_PORT)), + ('IPv6', start('ipv6', '::1', host6, IPV6_PORT)), +) + + +def connect(url, module): + return subprocess.run( + rsync_argv('-r', f'{url}{module}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, + ).returncode + + +for family, url in urls: + for module in ('plain', 'allow-host'): + if connect(url, module) != 0: + test_fail(f"{family} connection to {module} should be allowed") + + for module in ('deny-host', 'allow-unresolved', 'deny-unresolved', 'forward-off'): + if connect(url, module) == 0: + test_fail(f"{family} connection to {module} should be denied") + +print("daemon-access-hostname: IPv4 and IPv6 hostname rules verified") diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 7524cfff0..0cb473e59 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -768,12 +768,12 @@ def _cleanup_rsyncd(proc, port: int) -> 'None': def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, - stdin=subprocess.DEVNULL) -> 'subprocess.Popen': - """Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N + stdin=subprocess.DEVNULL, address: str = '127.0.0.1') -> 'subprocess.Popen': + """Spawn `rsync --daemon --no-detach --address=ADDRESS --port=N --config=conf` and return the Popen handle after the port is accepting connections. - The daemon is bound to LOOPBACK ONLY (--address=127.0.0.1): without it, + The daemon is bound to LOOPBACK ONLY: without an explicit address, rsync --daemon binds 0.0.0.0 and the test modules would be reachable from the whole LAN. The daemon is killed automatically when this Python process exits (atexit). On Linux, the kernel also signals SIGTERM to the @@ -791,9 +791,11 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, This is only ever reached from start_test_daemon() in --use-tcp mode; the default (pipe) mode never starts a listening daemon. """ + if address not in ('127.0.0.1', '::1'): + test_fail(f"test daemon address is not loopback: {address}") argv = shlex.split(rsync_cmd or RSYNC_PEER) + [ '--daemon', '--no-detach', - '--address=127.0.0.1', + f'--address={address}', f'--port={port}', f'--config={conf_path}', ] @@ -820,17 +822,18 @@ def start_rsyncd(conf_path, port: int, rsync_cmd: str = None, f"(status={proc.returncode})" ) try: - with _socket.create_connection(('127.0.0.1', port), timeout=0.5): + with _socket.create_connection((address, port), timeout=0.5): return proc except OSError as e: last_err = e time.sleep(0.05) _stop_rsyncd(proc) - test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}") + test_fail(f"rsyncd never listened on {address}:{port}: {last_err}") -def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: +def start_test_daemon(conf_path, port: int, rsync_cmd: str = None, + address: str = '127.0.0.1') -> str: """Bring up the test daemon and return a URL prefix for client commands. rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass @@ -844,11 +847,11 @@ def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: 'rsync://localhost/'. Another local user can't reach it; nothing is listening. - * --use-tcp -- starts a real rsyncd bound to 127.0.0.1 on the given - claim_ports()-reserved port. Returns 'rsync://localhost:PORT/'. Bound - to loopback so off-host/LAN access is impossible; a same-host user - could still connect during the test window, which is the documented, - accepted cost of explicitly opting into TCP. + * --use-tcp -- starts a real rsyncd bound to IPv4 loopback by default, + or to the selected loopback address, on the claim_ports()-reserved + port. Off-host/LAN access is impossible; a same-host user could still + connect during the test window, which is the documented, accepted cost + of explicitly opting into TCP. Build URLs as f"{prefix}module/path". `port` is only used (and claimed) in --use-tcp mode. @@ -856,8 +859,9 @@ def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str: daemon_cmd = rsync_cmd or RSYNC_PEER if USE_TCP: port = claim_free_port(port) - start_rsyncd(conf_path, port, daemon_cmd) - return f'rsync://localhost:{port}/' + start_rsyncd(conf_path, port, daemon_cmd, address=address) + host = 'localhost' if address == '127.0.0.1' else f'[{address}]' + return f'rsync://{host}:{port}/' # RSYNC_CONNECT_PROG is run by a shell, so every word has to survive # re-parsing: a build path with a space would otherwise exec its prefix. os.environ['RSYNC_CONNECT_PROG'] = ( diff --git a/testsuite/skiplist/common.txt b/testsuite/skiplist/common.txt index cb09e33e8..9517cf18b 100644 --- a/testsuite/skiplist/common.txt +++ b/testsuite/skiplist/common.txt @@ -11,6 +11,7 @@ checksum-zero-blocklen # the pure-Python receiver needs a real TCP socket; run with --use-tcp chroot-basis-forge-inner-module # the pure-Python sender needs a real TCP daemon; run with --use-tcp +daemon-access-hostname # IPv6 hostname access matching needs a real TCP peer daemon-access-idn # hosts allow/deny hostname matching needs a real TCP peer daemon-access-ip # hosts allow/deny address matching needs a real TCP peer daemon-argv-limit # raw malicious daemon client needs a real TCP daemon; run with --use-tcp