Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile.in
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
84 changes: 74 additions & 10 deletions access.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

#include "rsync.h"
#include "ifuncs.h"
#ifdef RSYNC_TEST_NO_INET6
#undef INET6
#endif
#ifdef HAVE_NETGROUP_H
#include <netgroup.h>
#endif
Expand All @@ -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];
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
84 changes: 84 additions & 0 deletions testsuite/daemon-access-hostname_test.py
Original file line number Diff line number Diff line change
@@ -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")
32 changes: 18 additions & 14 deletions testsuite/rsyncfns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}',
]
Expand All @@ -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
Expand All @@ -844,20 +847,21 @@ 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.
"""
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'] = (
Expand Down
1 change: 1 addition & 0 deletions testsuite/skiplist/common.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading