859d44fa ("sender: fix read-path TOCTOU by opening from module root",
CVE-2026-29518) routes the sender's file open through
secure_relative_open(module_dir, secure_path).  secure_relative_open()
requires a relative relpath and rejects an absolute one up-front with
EINVAL.

For any module with `use chroot = no` and `path = /`, the daemon's cwd
is "/" and F_PATHNAME holds absolute paths -- so the constructed
secure_path is absolute too and every file open fails:

  rsync: [sender] send_files failed to open "<file>" (in <mod>):
      Invalid argument (22)

User-visible symptoms:

  * single-file pulls return exit 23,
  * recursive pulls leave a tree of empty directories at the receiver
    (the dirs are mkdir'd from the file list, then every file's
    sender-side open fails).

Strip leading slashes from the constructed path before passing it to
secure_relative_open().  When module_dir is "/" this is the correct
transformation: an absolute path is by definition already relative to
"/".  For any other module_dir the daemon has chdir'd into it and
F_PATHNAME is relative, so the loop is a no-op.

The TOCTOU defense from 859d44fa is preserved: the open is still
anchored at module_dir and still uses secure_relative_open()'s
RESOLVE_BENEATH / per-component O_NOFOLLOW walk, so a directory
swapped to a symlink mid-transfer is still caught (EXDEV / ELOOP) and
a ".." in the path is still rejected (EINVAL).

testsuite/daemon-root-path_test.py covers both symptoms: builds a
small source tree, configures a module with `path = /`, pulls a
single file, then pulls recursively, and asserts both succeed with
the expected content.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---
Apologies for not submitting this via pull request or discussing by
Discord, both are difficult in my environment.

 sender.c                           | 14 ++++-
 testsuite/daemon-root-path_test.py | 84 ++++++++++++++++++++++++++++++
 2 files changed, 97 insertions(+), 1 deletion(-)
 create mode 100644 testsuite/daemon-root-path_test.py

diff --git a/sender.c b/sender.c
index 033f87e5..e95e0758 100644
--- a/sender.c
+++ b/sender.c
@@ -371,7 +371,19 @@ void send_files(int f_in, int f_out)
                                        send_msg_int(MSG_NO_SEND, ndx);
                                continue;
                        }
-                       fd = secure_relative_open(module_dir, secure_path, 
O_RDONLY, 0);
+                       /* When module_dir is "/" the daemon's cwd is "/" and
+                        * F_PATHNAME is absolute, so secure_path comes out
+                        * absolute too. secure_relative_open() requires a
+                        * relative relpath; strip leading slashes, which is
+                        * exactly the right transformation when module_dir
+                        * is "/" (an absolute path is by definition already
+                        * relative to "/"). For any other module_dir the
+                        * daemon has chdir'd into it and F_PATHNAME is
+                        * relative, so this loop is a no-op. */
+                       const char *relpath = secure_path;
+                       while (*relpath == '/')
+                               relpath++;
+                       fd = secure_relative_open(module_dir, relpath, 
O_RDONLY, 0);
                } else {
                        fd = do_open_checklinks(fname);
                }
diff --git a/testsuite/daemon-root-path_test.py 
b/testsuite/daemon-root-path_test.py
new file mode 100644
index 00000000..fdb3bc73
--- /dev/null
+++ b/testsuite/daemon-root-path_test.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Regression test for the `path = /` daemon-module sender bug.
+
+A non-chrooted daemon module with `path = /` activates use_secure_symlinks
+in the sender, which calls secure_relative_open(module_dir, secure_path).
+When module_dir is "/", the daemon's cwd is "/" and F_PATHNAME holds
+absolute paths, so the constructed secure_path is absolute too -- and
+secure_relative_open() rejects an absolute relpath up front with EINVAL.
+Result: every file open in the module fails. Single-file pulls return
+exit 23; recursive pulls leave a tree of empty directories at the
+receiver (the dirs are mkdir'd from the file list, then every file's
+sender-side open fails).
+
+Introduced by 859d44fa (sender: fix read-path TOCTOU by opening from
+module root, CVE-2026-29518). Fixed by stripping leading slashes from
+the relpath before passing it to secure_relative_open(), which is the
+correct transformation when module_dir is "/" (an absolute path is by
+definition already relative to "/").
+"""
+
+import subprocess
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    makepath, rmtree, rsync_argv, start_test_daemon, test_fail,
+    write_daemon_conf,
+)
+
+DAEMON_PORT = 12891
+
+# Build a small source tree under FROMDIR (which is an absolute path under
+# the per-test scratch dir). Files live at FROMDIR/top.txt and
+# FROMDIR/sub/inner.txt so we can exercise both a single-file pull and a
+# recursive pull that has to descend into a subdirectory.
+rmtree(FROMDIR)
+makepath(FROMDIR, FROMDIR / 'sub')
+(FROMDIR / 'top.txt').write_text("top\n")
+(FROMDIR / 'sub' / 'inner.txt').write_text("inner\n")
+
+# Module rooted at "/" -- the regression trigger. read only / list are set
+# explicitly to match the typical real-world use of an "expose everything,
+# read-only" daemon module.
+conf = write_daemon_conf([
+    ('root', {'path': '/', 'read only': 'yes', 'list': 'yes'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT).rstrip('/')
+
+rmtree(TODIR)
+makepath(TODIR)
+
+# 1. Single-file pull. Pre-fix this returns exit 23 with
+#    "failed to open ...: Invalid argument (22)" on stderr.
+dest_single = TODIR / 'top.txt'
+proc = subprocess.run(
+    rsync_argv(f'{url}/root{FROMDIR}/top.txt', str(dest_single)),
+    capture_output=True, text=True)
+if proc.returncode != 0:
+    test_fail(
+        "single-file pull via path=/ module failed: "
+        f"rc={proc.returncode}\nstderr: {proc.stderr}")
+if dest_single.read_text() != "top\n":
+    test_fail(
+        f"single-file pull produced wrong content: 
{dest_single.read_text()!r}")
+
+# 2. Recursive pull. Pre-fix this produces a tree of empty directories: the
+#    dirs are created on the receiver but every file's sender-side open
+#    fails. Assert both files are present with the right contents.
+dest_dir = TODIR / 'recursive'
+rmtree(dest_dir)
+makepath(dest_dir)
+proc = subprocess.run(
+    rsync_argv('-r', f'{url}/root{FROMDIR}/', f'{dest_dir}/'),
+    capture_output=True, text=True)
+if proc.returncode != 0:
+    test_fail(
+        "recursive pull via path=/ module failed: "
+        f"rc={proc.returncode}\nstderr: {proc.stderr}")
+if (dest_dir / 'top.txt').read_text() != "top\n":
+    test_fail("recursive pull dropped top.txt")
+if (dest_dir / 'sub' / 'inner.txt').read_text() != "inner\n":
+    test_fail(
+        "recursive pull dropped sub/inner.txt -- empty-dir-tree symptom")
+
+print("daemon-root-path: single + recursive pulls via path=/ module succeeded")
-- 
2.39.5


-- 
Please use reply-all for most replies to avoid omitting the mailing list.
To unsubscribe or change options: https://lists.samba.org/mailman/listinfo/rsync
Before posting, read: http://www.catb.org/~esr/faqs/smart-questions.html

Reply via email to