fix(receiver): confine --temp-dir scratch dir and gate setuid bits

Three receiver security fixes from the audit:

1. --temp-dir symlink escape (High): file_open_temp_dir() opened the
   client-controlled scratch dir with a bare open(), so a symlink planted
   under the receive root let a peer redirect receiver scratch files
   outside the authorized root.  The opened dir is now judged by the REAL
   path of its fd (via /proc/self/fd), and any target outside the
   authorized receive root is refused with a logged error (EACCES).  An
   in-root symlink (the EXDEV cross-filesystem fallback case) still works,
   and the no-root local batch path is unchanged.

2. setuid/setgid/sticky under SUPER_MODE_OFF (High): the special bits were
   applied under --perms (and via --chmod) even when the connection forbade
   super-user activities.  FileAttrPolicy gains super_permitted, set by
   file_attr_policy_from_config() from privilege_super_mode_permitted();
   metadata_mode_for_policy(), the symlink path, the special-node creation
   path, and the deferred directory-mode apply now strip the special bits
   when it is false.  Exact rsync semantics are preserved when permitted.

3. daemon umask (Low): daemonize() forced umask(0), so implied parent
   directories created without -p were world-writable 0777.  Set the
   conventional daemon umask 022 instead (rsync never forces 0); -p/-a mode
   preservation is unaffected because it restores modes via fchmod.

Tests: new unit tests for file_open_temp_dir confinement and the
masked/unmasked special-bit policy (incl. the --chmod path), a daemon
world-writable-dir regression test, an integration escape test, and a
root-only integration test asserting special bits are masked without
--allow-super.  The old cross-filesystem test encoded the vulnerable
behavior (symlink target outside the root) and is replaced by the escape
test; the EXDEV fallback code is retained for in-root links.
This commit is contained in:
2026-09-21 18:45:29 +02:00
parent 0fbb9de915
commit 423a62e691
11 changed files with 309 additions and 98 deletions
+49 -25
View File
@@ -2606,35 +2606,30 @@ class TestTimeoutAndAllocLimits:
mismatches, missing = verify_transfer(source, received)
assert not missing and not mismatches
def test_temp_dir_cross_filesystem_fallback(self, shared_server):
"""A confined relative --temp-dir that resolves (via a symlink under the
destination root) to another filesystem must fall back to a non-atomic
copy instead of aborting (rsync parity). Skipped when no second
filesystem is available."""
shm = "/dev/shm"
if not os.path.isdir(shm):
pytest.skip("/dev/shm not available")
if os.stat(shm).st_dev == os.stat(TEST_DATA_DIR).st_dev:
pytest.skip("/dev/shm is on the same filesystem as the test data")
scratch = os.path.join(shm, f"fastsync_tmp_{os.getpid()}")
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch)
def test_temp_dir_symlink_escape_rejected(self, shared_server):
"""A symlink planted inside the destination root pointing outside it
must not redirect receiver scratch files: --temp-dir=<that link> is
refused and nothing is written at the link target. An in-root symlink
(e.g. to a mount point that stays inside the authorized root) is still
accepted, preserving the engine's EXDEV cross-filesystem fallback."""
source, dest = self._seed("tempdir_escape_src")
outside = "/tmp/fastsync_tempdir_escape_%d" % os.getpid()
shutil.rmtree(outside, ignore_errors=True)
os.makedirs(outside)
link = os.path.join(dest, "escape_scratch")
if os.path.lexists(link):
os.unlink(link)
os.symlink(outside, link)
try:
source, dest = self._seed("tempdir_xdev_src")
# The receiver resolves a relative temp dir under the destination
# root; a symlink there points the scratch at the second filesystem.
link = os.path.join(dest, "xdev_scratch")
os.symlink(scratch, link)
result, _ = run_client(source, dest, flags=["--temp-dir", "xdev_scratch"],
result, _ = run_client(source, dest, flags=["--temp-dir", "escape_scratch"],
port=shared_server.port)
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
assert result.returncode != 0, "an escaping --temp-dir symlink must be refused"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}"
assert os.listdir(scratch) == [], "temp files left behind in the cross-fs scratch"
assert not os.path.exists(os.path.join(received, "f.txt")), \
"the receiver must not fall back to writing the file"
assert os.listdir(outside) == [], "receiver wrote outside the authorized root"
finally:
shutil.rmtree(scratch, ignore_errors=True)
shutil.rmtree(outside, ignore_errors=True)
class TestRemoteOptionTransport:
@@ -5782,6 +5777,35 @@ class TestStandaloneSuperDefault:
"standalone server accepted --copy-as without --allow-super"
)
@pytest.mark.skipif(
os.geteuid() != 0,
reason="root triggers the SUPER_MODE_OFF default and can create setuid sources",
)
def test_special_bits_masked_without_allow_super(self):
"""A root standalone server without --allow-super forces SUPER_MODE_OFF,
so client-supplied setuid/setgid/sticky bits must be stripped even under
-p (they are super-user activities just like device-node creation)."""
source = os.path.join(TEST_DATA_DIR, "super_default_mode_src")
dest = os.path.join(TEST_DATA_DIR, "super_default_mode_dst")
clean_dir(source)
clean_dir(dest)
src_file = os.path.join(source, "priv.sh")
with open(src_file, "wb") as f:
f.write(b"#!/bin/sh\necho hi\n")
os.chmod(src_file, 0o4755)
server = ServerManager()
server.start() # deliberately no --allow-super -> SUPER_MODE_OFF as root
try:
result, _ = run_client(source, dest, flags=["-p"], port=server.port)
finally:
server.stop()
assert result.returncode == 0, f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
mode = stat.S_IMODE(os.stat(os.path.join(received, "priv.sh")).st_mode)
assert (mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX)) == 0, \
f"--no-super receiver kept a privileged bit: {oct(mode)}"
assert (mode & 0o777) == 0o755, f"ordinary permission bits lost: {oct(mode)}"
@pytest.mark.skipif(os.geteuid() != 0, reason="root can create the source device node")
def test_devices_skipped_without_allow_super(self):
"""Root standalone server without --allow-super must skip device-node