Skip to content
Open
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
27 changes: 25 additions & 2 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
* (abspath_outside_confinement). A relative operator path starts at the
* daemon's cwd == the module root; an absolute one (or a followed absolute
* symlink target) restarts at "/". */
char abspath[MAXPATHLEN];
abspath[0] = '\0';
char abspath[MAXPATHLEN] = {0};
if (am_daemon && module_dir && module_dir[0] == '/')
strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */
else if (confine_root) {
Expand Down Expand Up @@ -434,6 +433,30 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
}
target[n] = '\0';

/* Detect Linux kernel pseudo-paths (pipes, sockets, anon_inodes).
* These are not real paths on disk and never contain slashes. */
const char *ptail = fd_pin_tail(abspath);
int is_fd_dir = (ptail != NULL && *ptail == '\0');
if (is_fd_dir && (strncmp(target, "pipe:[", 6) == 0 ||
strncmp(target, "socket:[", 8) == 0 ||
strncmp(target, "anon_inode:", 11) == 0)) {
if (confine_root) {
/* If confined to a root directory, we categorically
* refuse to resolve kernel pseudo-paths. */
saved_errno = ENOENT;
goto out;
}
/* Safely reopen the descriptor (Symlink traversal).
* Bash process substitution >(...) exposes /dev/fd/X as a symlink
* to a pipe (e.g., pipe:[12345]). If rsync attempts to open this
* with O_NOFOLLOW, the kernel will reject it with ELOOP.
* We strip O_NOFOLLOW (using & ~O_NOFOLLOW) to allow proper
* kernel symlink resolution of the pseudo-path. */
retfd = openat(dfd, comp, (flags & ~O_NOFOLLOW) | O_CLOEXEC, mode);
saved_errno = retfd < 0 ? errno : 0;
goto out;
}

/* Splice: new `remaining` = <target> + <tail-after-comp>.
* Absolute target restarts the walk from "/". */
char tail[MAXPATHLEN];
Expand Down
113 changes: 113 additions & 0 deletions testsuite/pseudo-paths_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Process substitution /dev/fd/ write pipe pseudo-paths for --log-file must not crash and must successfully write logs, but must be rejected if confined root."""

import shlex
import shutil
import subprocess
import sys
from pathlib import Path

from rsyncfns import (
SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail, test_skipped,
)
if not sys.platform.startswith('linux'):
test_skipped('Kernel pseudo-path string is a Linux-specific procfs feature')
raise SystemExit(0)

# We require bash specifically because standard POSIX /bin/sh does not
# guarantee support for >(...) process substitution syntax.
bash = shutil.which('bash')
if bash is None:
test_skipped('bash is unavailable, cannot test process substitution')

# Verify the host bash actually supports process substitution
probe = subprocess.run(
[bash, '-c', 'echo "probe" > >(cat > /dev/null)'],
capture_output=True
)
if probe.returncode != 0:
test_skipped('bash process substitution is not supported on this system')

base = Path(SCRATCHDIR / 'rsync-pseudo-path').resolve()
src = base / 'src'
dest = base / 'dest'
log_out = base / 'test_log.txt'
log_out_confined = base / 'test_log_confined.txt'
makepath(src, dest)

(src / 'transfer_me.txt').write_text('sync this\n')

rsync_base_cmd = shlex.join(rsync_argv('-a'))
src_path = shlex.quote(str(src) + '/')
dest_path = shlex.quote(str(dest) + '/')

log_path = shlex.quote(str(log_out))
log_path_confined = shlex.quote(str(log_out_confined))

# -------------------------------------------------------------------------
# TEST 1: Unconfined process substitution (Should Succeed)
# -------------------------------------------------------------------------
bash_script = f"{rsync_base_cmd} -v --log-file=>(cat > {log_path}) {src_path} {dest_path}"

try:
proc = subprocess.run(
[bash, '-c', bash_script],
capture_output=True,
text=True,
timeout=10,
)
except subprocess.TimeoutExpired:
rmtree(base)
test_fail('process substitution test timed out')

ctx = f'rc={proc.returncode}, stderr={proc.stderr.strip()!r}'

if proc.returncode != 0:
rmtree(base)
test_fail(f'rsync crashed writing to a pseudo-path log pipe ({ctx})')

if not (dest / 'transfer_me.txt').is_file():
rmtree(base)
test_fail(f'rsync failed to transfer the allowed file ({ctx})')

if not log_out.exists() or log_out.stat().st_size == 0:
rmtree(base)
test_fail(f'rsync survived, but failed to write data to the log pipe ({ctx})')

log_data = log_out.read_text()
if "transfer_me.txt" not in log_data:
rmtree(base)
test_fail(f'Log pipe received data, but is missing expected output: {log_data[:100]}')

print('Test 1 Passed: rsync successfully wrote logs to a process substitution pseudo-path')

# -------------------------------------------------------------------------
# TEST 2: Confined Root (Should Reject Pseudo-path)
# -------------------------------------------------------------------------
bash_script_confined = f"{rsync_base_cmd} --confine-root={dest_path} -v --log-file=>(cat > {log_path_confined}) {src_path} {dest_path}"

try:
proc_confined = subprocess.run(
[bash, '-c', bash_script_confined],
capture_output=True,
text=True,
timeout=10,
)
except subprocess.TimeoutExpired:
rmtree(base)
test_fail('confined process substitution test timed out')

ctx_confined = f'rc={proc_confined.returncode}, stderr={proc_confined.stderr.strip()!r}'

# Rsync considers log-file failure a warning, so it still exits 0.
stderr_lower = proc_confined.stderr.lower()
if "no such file or directory" in stderr_lower and "failed to open" in stderr_lower:
if log_out_confined.exists() and log_out_confined.stat().st_size > 0:
rmtree(base)
test_fail(f'rsync printed an error but still wrote the confined log! ({ctx_confined})')
print('Test 2 Passed: rsync correctly rejected the pseudo-path when confine_root was active')
else:
rmtree(base)
test_fail(f'rsync failed to reject the pseudo-path or had an unexpected error ({ctx_confined})')

rmtree(base)
raise SystemExit(0)
1 change: 1 addition & 0 deletions testsuite/skiplist/cygwin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ partial-protected-regular-retry-linux
partial-protected-regular-retry-policy # deterministic partial EACCES recovery uses dyld interposing
password-file-symlink
protected-regular
pseudo-paths
rename-mixed-parent-transfer
rrsync-sender-leaf-flip
rrsync-sender-parent-pin
Expand Down
1 change: 1 addition & 0 deletions testsuite/skiplist/macos.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ open-noatime
partial-protected-regular-retry-linux
preallocate
protected-regular
pseudo-paths # dynamically skips on runners lacking bash process substitution
readonly-partial-abort-mode-regression #
rrsync-sender-leaf-flip
rrsync-sender-parent-pin
Expand Down
Loading