Thor Simon
2026-May-27 20:19 UTC
[PATCH] sender: fix open EINVAL when daemon module path is "/" (regression due to 859d44fa)
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) <noreply at anthropic.com>
---
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