syscall: use O_PATH for held directory traversal (#1065)

Use O_PATH on Linux for directory descriptors used only for path
traversal, fchdir(), or as *at() parents. Reopen final directory
endpoints with the caller-requested access mode and retain the existing
O_RDONLY fallback elsewhere.

Add coverage for exact sources and readable descendants beneath
search-only directories, known-file creation beneath write/search-only
destinations, and retained refusal to enumerate unreadable directories.

Fixes #1064.
This commit is contained in:
Zayd Rajab authored and GitHub committed 2026-08-28 19:37:01 +10:00
1 parent 7c20b077c9
commit 240bd9df96
8 files changed
+334 -83

No files matched your search

+34 -28
View File
@@ -146,28 +146,29 @@ following symlinks by design.
### The mechanism
Resolution of attacker-influenceable paths goes through `secure_relative_open()`
and the `do_*_at()` wrappers in `syscall.c`, never a raw `open()`/`rename()`/
`chmod()` on a full path string. The principle is: **trust the operator-named
transfer root, and confine all resolution beneath it**, rejecting escapes via
`..` above the anchor, absolute symlinks, or out-of-tree symlinks.
`secure_relative_open()` resolves the parent directory by walking it one
component at a time on a stack of held directory fds, then operates on the final
component with an at-style call on the resulting directory fd.
Resolution of attacker-influenceable paths goes through `secure_relative_open()`,
`secure_relative_dirfd()`, and the `do_*_at()` wrappers in `syscall.c`, never a
raw `open()`/`rename()`/`chmod()` on a full path string. The principle is:
**trust the operator-named transfer root, and confine all resolution beneath
it**, rejecting escapes via `..` above the anchor, absolute symlinks, or
out-of-tree symlinks. `secure_relative_open()` opens the resolved endpoint with
the caller's requested access. `secure_relative_dirfd()` instead returns
traversal authority for `fchdir()` or an at-style operation on a known child;
it does not imply permission to enumerate the directory.
For per-entry work the receiver and generator go one step further and hold the
parent directory open: `open_dir_secure()` resolves an entry's directory once
(via `secure_relative_open()`), `held_dfd_for()` caches that descriptor for the
duration of the entry, and every operation on the entry — `lstat`, the temp-file
`mkstemp`, the temp->final `rename`, `chmod`/`chown`/`utimes`, `mkdir`, special-
file and symlink creation, the delta-basis open, and the recursive delete — runs
through that one held fd via an `*at()` call (`do_*_atfd()`). Because the
descriptor is pinned to the directory inode, a parent component flipped to a
symlink *after* the open cannot redirect any of those operations. The alternate-
destination lookups are confined the same way (`basis_link_stat()` in
`generator.c` and `secure_basis_open()` in `receiver.c`), so a peer-chosen
`--link-dest`/`--compare-dest`/`--copy-dest` basis index cannot reach an
out-of-module file through a symlinked parent.
as traversal authority, `held_dfd_for()` caches that descriptor for the
duration of the entry, and every operation on the entry — `lstat`, the
temp-file `mkstemp`, the temp->final `rename`, `chmod`/`chown`/`utimes`,
`mkdir`, special-file and symlink creation, the delta-basis open, and the
recursive delete — runs through that one held fd via an `*at()` call
(`do_*_atfd()`). Because the descriptor is pinned to the directory inode, a
parent component flipped to a symlink *after* the open cannot redirect any of
those operations. The alternate-destination lookups are confined the same way
(`basis_link_stat()` in `generator.c` and `secure_basis_open()` in
`receiver.c`), so a peer-chosen `--link-dest`/`--compare-dest`/`--copy-dest`
basis index cannot reach an out-of-module file through a symlinked parent.
The sender's source-directory *enumeration* is confined the same way as its
content open. `send_directory()` opens each scanned directory through
@@ -190,13 +191,17 @@ symlink would otherwise introduce.
### Path resolution
`secure_relative_open()` resolves a path with a single portable mechanism on
every platform: a per-component walk on a stack of held directory fds. Each
component is opened relative to the held parent with `openat(parent_fd,
"component", O_NOFOLLOW)`; descending into a real subdirectory pushes its fd, a
`..` pops back to the already-held parent (a pop at the anchor is refused), and an
in-tree directory symlink is followed by reading its target and walking that off
the same stack (absolute targets refused, symlink hops bounded). The final
component is opened `O_NOFOLLOW`.
every platform: a per-component walk on a stack of held directory fds. On
Linux, anchors and traversal components use
`O_PATH|O_DIRECTORY|O_NOFOLLOW`; other platforms retain the
`O_RDONLY|O_DIRECTORY` fallback. Descending into a real subdirectory pushes
its fd, a `..` pops back to the already-held parent (a pop at the anchor is
refused), and an in-tree directory symlink is followed by reading its target
and walking that off the same stack (absolute targets refused, symlink hops
bounded). A final directory endpoint is reopened with the caller's requested
flags. Thus `secure_opendir()` still receives a readable fd, while known-name
operations beneath a searchable but unreadable directory do not require
permission to list it.
Because every component is opened relative to a *pinned* fd under `O_NOFOLLOW`,
and `..` is resolved by the held-fd stack rather than by the kernel, the walk is
@@ -235,8 +240,9 @@ chmod-ing through a raced leaf symlink.
influenced by the remote peer or by another local user, use a `do_*_at()`
wrapper (or `secure_relative_open()`), not a raw full-path syscall.
* When introducing a new operation, add a matching `do_<op>_at()` wrapper that
resolves the parent with `secure_relative_open()` and acts via an at-style call
on the returned dirfd.
resolves a parent used only as at-style authority with
`secure_relative_dirfd()`. Use `secure_relative_open()` when the returned fd
itself must be readable or otherwise support the caller's requested access.
* Do not assume a non-daemon transfer is safe; the question is whether rsync has
more authority than whoever controls the path components.
* On platforms whose API lacks an at-style equivalent (e.g. `setattrlist()`),
+1 -1
View File
@@ -1052,7 +1052,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp)
if (dlen >= sizeof dir) { errno = ENAMETOOLONG; return -1; }
memcpy(dir, path, dlen);
dir[dlen] = '\0';
if ((dfd = secure_relative_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0)) < 0)
if ((dfd = secure_relative_dirfd(NULL, dir)) < 0)
return -1;
r = link_stat_at(dfd, slash + 1, stp, 0);
e = errno;
+6 -9
View File
@@ -127,8 +127,7 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname,
#endif
while (*rel == '/')
rel++;
return secure_relative_open("/", rel,
O_RDONLY | O_DIRECTORY, 0);
return secure_relative_dirfd("/", rel);
}
/* held_dir_path_fd returns a cache-OWNED fd; the caller closes
* what we return, so hand back an owned dup and leave the cache's
@@ -141,7 +140,7 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname,
return dup(dfd);
if (errno != 0)
return -1;
return secure_relative_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0);
return secure_relative_dirfd(NULL, dir);
}
errno = 0; /* top-level file: no parent component to confine */
return -1;
@@ -176,9 +175,9 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname,
}
memcpy(dir, relp, dlen);
dir[dlen] = '\0';
dfd = secure_relative_open(module_dir, dir, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(module_dir, dir);
} else
dfd = secure_relative_open(module_dir, "", O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(module_dir, "");
/* The leaf is the same last component either way; take it from the caller's
* persistent fname buffer, not the local secure_path. */
@@ -292,11 +291,9 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat
* only this branch would hand it to strcmp(). */
if (am_daemon && module_dirfd >= 0 && module_dir && anchor
&& strcmp(anchor, module_dir) == 0)
pdfd = secure_relative_open_at_beneath(module_dirfd, dir,
O_RDONLY | O_DIRECTORY, 0);
pdfd = secure_relative_dirfd_at_beneath(module_dirfd, dir);
else
pdfd = secure_relative_open(anchor, dir,
O_RDONLY | O_DIRECTORY, 0);
pdfd = secure_relative_dirfd(anchor, dir);
if (pdfd < 0)
return -1;
n = do_readlink_atfd(pdfd, bname, tgt, sizeof tgt - 1);
+72 -39
View File
@@ -74,6 +74,20 @@ extern unsigned int confine_rootlen;
extern char curr_dir[MAXPATHLEN]; /* defined below; fwd-declared for the seed */
extern int operator_path_resolve; /* defined below; fwd-declared for the exclude check */
/* A directory fd used only for pathname traversal, fchdir(), or as *at()
* authority does not need read permission on Linux. Keep the portable
* O_RDONLY fallback for systems without O_PATH. */
static int directory_traverse_flags(void)
{
#if defined O_PATH && defined O_DIRECTORY
return O_PATH | O_DIRECTORY;
#elif defined O_DIRECTORY
return O_RDONLY | O_DIRECTORY;
#else
return O_RDONLY;
#endif
}
#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
/* Open a trusted absolute anchor directory as an owned dirfd. When the anchor is
* the served module root and the daemon pinned it by identity (module_dirfd), dup
@@ -86,7 +100,7 @@ static int open_anchor_dirfd(const char *path)
{
if (module_dirfd >= 0 && am_daemon && module_dir && strcmp(path, module_dir) == 0)
return dup(module_dirfd);
return openat(AT_FDCWD, path, O_RDONLY | O_DIRECTORY);
return openat(AT_FDCWD, path, directory_traverse_flags());
}
#endif
@@ -290,17 +304,13 @@ static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp
* uses it to filter-check the (otherwise unchecked) leaf basename. */
static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap)
{
#if defined AT_FDCWD && defined O_NOFOLLOW
#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
/* O_CLOEXEC predates some still-supported targets; mirror rand_bytes()'s
* fallback in syscall.c so a build without it still compiles. */
#ifndef O_CLOEXEC
#define O_CLOEXEC 0
#endif
#ifdef O_PATH
const int dir_traverse_flags = O_PATH | O_DIRECTORY | O_CLOEXEC;
#else
const int dir_traverse_flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC;
#endif
const int dir_traverse_flags = directory_traverse_flags() | O_CLOEXEC;
if (!path || !*path) {
errno = EINVAL;
return -1;
@@ -555,6 +565,14 @@ int open_no_attacker_symlinks(const char *path, int flags, mode_t mode)
return ona_open(path, flags, mode, NULL, 0);
}
/* Open a directory for traversal or as *at()/fchdir() authority. Unlike an
* O_RDONLY directory endpoint, this accepts a searchable but unreadable
* directory on Linux. */
int open_no_attacker_symlinks_dirfd(const char *path)
{
return ona_open(path, directory_traverse_flags(), 0, NULL, 0);
}
/* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied
* directory path (an absolute or relative --backup-dir/--temp-dir/--*-dest)
* using the ownership walk -- follow a symlink owned by uid 0 or our euid,
@@ -580,7 +598,7 @@ int owner_walk_parent(const char *path, const char **bname)
*bname = slash ? slash + 1 : path;
pabs[0] = '\0';
if (!slash)
dfd = ona_open(".", O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs);
dfd = ona_open(".", directory_traverse_flags(), 0, pabs, sizeof pabs);
else {
dlen = slash == path ? 1 : (size_t)(slash - path); /* "/x" -> parent "/" */
if (dlen >= sizeof dir) {
@@ -589,7 +607,7 @@ int owner_walk_parent(const char *path, const char **bname)
}
memcpy(dir, path, dlen);
dir[dlen] = '\0';
dfd = ona_open(dir, O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs);
dfd = ona_open(dir, directory_traverse_flags(), 0, pabs, sizeof pabs);
}
if (dfd < 0)
return -1;
@@ -718,7 +736,7 @@ int do_unlink_at(const char *path)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -825,7 +843,7 @@ int do_symlink_at(const char *lnk, const char *path)
memcpy(dirpath, path, dlen);
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
owns = True;
@@ -1027,7 +1045,7 @@ int do_link_at(const char *old_path, const char *new_path)
memcpy(old_dirpath, old_path, old_dlen);
old_dirpath[old_dlen] = '\0';
old_bname = old_slash + 1;
old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0);
old_dfd = secure_relative_dirfd(NULL, old_dirpath);
if (old_dfd < 0)
return -1;
old_owns = True;
@@ -1066,7 +1084,7 @@ int do_link_at(const char *old_path, const char *new_path)
&& memcmp(old_dirpath, new_dirpath, old_dlen) == 0) {
new_dfd = old_dfd;
} else {
new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0);
new_dfd = secure_relative_dirfd(NULL, new_dirpath);
if (new_dfd < 0) {
e = errno;
if (old_owns) close(old_dfd);
@@ -1169,7 +1187,7 @@ int do_lchown_at(const char *fname, uid_t owner, gid_t group)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -1340,7 +1358,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev)
memcpy(dirpath, pathname, dlen);
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
owns = True;
@@ -1462,7 +1480,7 @@ int do_rmdir_at(const char *pathname)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -1558,7 +1576,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -1840,7 +1858,7 @@ int do_chmod_at(const char *fname, mode_t mode)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -1957,7 +1975,7 @@ int do_rename_at(const char *old_path, const char *new_path)
memcpy(old_dirpath, old_path, old_dlen);
old_dirpath[old_dlen] = '\0';
old_bname = old_slash + 1;
old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0);
old_dfd = secure_relative_dirfd(NULL, old_dirpath);
if (old_dfd < 0)
return -1;
old_owns = True;
@@ -1996,7 +2014,7 @@ int do_rename_at(const char *old_path, const char *new_path)
&& memcmp(old_dirpath, new_dirpath, old_dlen) == 0) {
new_dfd = old_dfd;
} else {
new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0);
new_dfd = secure_relative_dirfd(NULL, new_dirpath);
if (new_dfd < 0) {
e = errno;
if (old_owns) close(old_dfd);
@@ -2127,7 +2145,7 @@ int do_mkdir_at(char *path, mode_t mode)
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -2253,7 +2271,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa
dirpath[dlen] = '\0';
bname = slash + 1;
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -2505,7 +2523,7 @@ int do_utimensat_at(const char *path, STRUCT_STAT *stp)
t[1].tv_nsec = 0;
#endif
dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirpath);
if (dfd < 0)
return -1;
@@ -2886,13 +2904,13 @@ static int ds_push(struct dirstack *ds, int fd)
return 0;
}
/* Detach the current dir as an owned fd the caller must close. At the anchor
* (top 0) the anchor is borrowed, so return a fresh dup of it instead. */
/* Detach the current traversal dirfd as an owned fd the caller must close. At
* the anchor (top 0) the anchor is borrowed, so open a fresh traversal fd. */
static int ds_take(struct dirstack *ds)
{
if (ds->top > 0)
return ds->fds[ds->top--];
return openat(ds->fds[0], ".", O_RDONLY | O_DIRECTORY);
return openat(ds->fds[0], ".", directory_traverse_flags());
}
static int ds_walk_path(struct dirstack *ds, char *path, int *hops);
@@ -2917,7 +2935,7 @@ static int ds_descend(struct dirstack *ds, const char *part, int *hops)
return 0;
}
int fd = openat(ds_cur(ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
int fd = openat(ds_cur(ds), part, directory_traverse_flags() | O_NOFOLLOW);
if (fd != -1) { /* a real subdirectory */
if (ds_push(ds, fd) < 0)
return -1;
@@ -3040,7 +3058,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath,
goto cleanup;
if (is_last) {
if (flags & O_DIRECTORY)
retfd = ds_take(&ds);
retfd = openat(ds_cur(&ds), ".", flags | O_NOFOLLOW, mode);
else
errno = EISDIR;
goto cleanup;
@@ -3060,7 +3078,8 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath,
goto cleanup;
}
}
int next_fd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
int next_fd = openat(ds_cur(&ds), part,
directory_traverse_flags() | O_NOFOLLOW);
if (next_fd == -1 && (errno == ENOTDIR || errno == ENOENT)) {
retfd = openat(ds_cur(&ds), part, flags | O_NOFOLLOW, mode);
goto cleanup;
@@ -3074,7 +3093,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath,
/* O_DIRECTORY|O_NOFOLLOW leaf: the caller's O_NOFOLLOW governs the leaf. */
if (is_last && (flags & O_NOFOLLOW)) {
retfd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
retfd = openat(ds_cur(&ds), part, flags | O_NOFOLLOW, mode);
goto cleanup;
}
@@ -3086,17 +3105,17 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath,
goto cleanup;
}
if (is_last) {
retfd = ds_take(&ds);
retfd = openat(ds_cur(&ds), ".", flags | O_NOFOLLOW, mode);
goto cleanup;
}
}
/* Empty relpath: hand back a real anchor for an O_DIRECTORY caller (ds_take
* dups the borrowed anchor), else EISDIR. An AT_FDCWD anchor is not a
* resolvable target, so it fails rather than silently returning the cwd. */
/* Empty relpath: reopen the anchor with the caller's requested directory
* access, else EISDIR. An AT_FDCWD anchor is not a resolvable target, so it
* fails rather than silently returning the cwd. */
if (!saw_component) {
if ((flags & O_DIRECTORY) && anchor_fd != AT_FDCWD)
retfd = ds_take(&ds);
retfd = openat(anchor_fd, ".", flags | O_NOFOLLOW, mode);
else
errno = EISDIR;
}
@@ -3249,6 +3268,14 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo
#endif // O_NOFOLLOW, O_DIRECTORY
}
/* Resolve a directory for traversal or as *at()/fchdir() authority. Callers
* that read directory entries or need a read-capable fd must continue to use
* secure_relative_open(..., O_RDONLY | O_DIRECTORY, ...). */
int secure_relative_dirfd(const char *basedir, const char *relpath)
{
return secure_relative_open(basedir, relpath, directory_traverse_flags(), 0);
}
/* Common fd-anchored resolver. A caller may explicitly allow literal ".."
* components when the fd itself is the confinement boundary: secure_walk_at()
* resolves each one by popping its held-dirfd stack and refuses a pop above the
@@ -3303,6 +3330,12 @@ int secure_relative_open_at_beneath(int anchor_fd, const char *relpath,
return secure_relative_open_at_internal(anchor_fd, relpath, flags, mode, 1);
}
int secure_relative_dirfd_at_beneath(int anchor_fd, const char *relpath)
{
return secure_relative_open_at_internal(anchor_fd, relpath,
directory_traverse_flags(), 0, 1);
}
#if defined O_NOFOLLOW && defined O_DIRECTORY && defined AT_FDCWD
/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic
* quality; falls back to rand() if /dev/urandom cannot be opened or read
@@ -3439,8 +3472,8 @@ int secure_mkstemp(char *template, mode_t perms, int operator_path)
dir = dirbuf;
}
dirfd = operator_path
? open_no_attacker_symlinks(dir, O_RDONLY | O_DIRECTORY, 0)
: secure_relative_open(dir, ".", O_RDONLY | O_DIRECTORY, 0);
? open_no_attacker_symlinks_dirfd(dir)
: secure_relative_dirfd(dir, ".");
if (dirfd < 0)
return -1;
}
@@ -3509,14 +3542,14 @@ int open_dir_secure(const char *dirname)
if (!dirname || !*dirname) {
/* The transfer root itself (file->dirname == NULL): the cwd. */
dfd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY);
dfd = openat(AT_FDCWD, ".", directory_traverse_flags());
} else if (dirname[0] == '/') {
/* An absolute dirname is not expected for an in-transfer entry;
* leave it to the legacy path. */
errno = 0;
return -1;
} else {
dfd = secure_relative_open(NULL, dirname, O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(NULL, dirname);
}
if (dfd >= 0) {
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Known-name operations must not require permission to list parent dirs.
The confined resolver holds directory descriptors to prevent symlink races.
On Linux, those traversal and *at() anchor descriptors can use O_PATH: opening
a known file beneath a searchable directory, or creating one beneath a
writable/searchable directory, does not require directory read permission.
"""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from rsyncfns import (
SCRATCHDIR, forced_protocol, rmtree, rsync_argv, test_fail, test_skipped,
)
if not sys.platform.startswith('linux'):
test_skipped('search-only held-dirfd coverage is Linux-specific')
launcher = []
if os.geteuid() == 0:
setpriv = shutil.which('setpriv')
if setpriv is None:
test_skipped('setpriv is unavailable for the root-run testsuite')
launcher = [setpriv, '--reuid=65534', '--regid=65534', '--clear-groups']
external_base = os.geteuid() == 0
base = (
Path(tempfile.mkdtemp(prefix='rsync-search-only-held-dirfd-'))
if external_base
else SCRATCHDIR / 'search-only-held-dirfd'
)
rmtree(base)
src = base / 'src'
xonly = src / 'xonly'
readable = xonly / 'readable'
nested_src = src / 'nested'
exact_dest = base / 'exact-dest'
tree_dest = base / 'tree-dest'
unreadable_dest = base / 'unreadable-dest'
write_only_dest = base / 'write-only-dest'
nested_dest = base / 'nested-dest'
nested_parent = nested_dest / 'nested'
for path in (
readable,
nested_src,
exact_dest,
tree_dest,
unreadable_dest,
write_only_dest,
nested_parent,
):
path.mkdir(parents=True, exist_ok=True)
(xonly / 'exact').write_text('known file beneath search-only parent\n')
(readable / 'nested').write_text('enumerated below search-only ancestor\n')
incoming = src / 'incoming'
incoming.write_text('created beneath write-search-only destination\n')
(nested_src / 'known').write_text(
'created beneath nested write-search-only parent\n'
)
if os.geteuid() == 0:
for root, dirs, files in os.walk(base):
os.chown(root, 65534, 65534)
for name in dirs + files:
os.chown(Path(root) / name, 65534, 65534)
def permission_probe(path, flag, expected, label):
proc = subprocess.run(
launcher + ['test', flag, str(path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if proc.returncode != expected:
test_skipped(
f'filesystem does not enforce {label}: test {flag} returned '
f'{proc.returncode}, expected {expected}'
)
failures = []
try:
xonly.chmod(0o111)
write_only_dest.chmod(0o333)
nested_parent.chmod(0o333)
permission_probe(xonly, '-r', 1, 'search-only mode')
permission_probe(xonly, '-x', 0, 'search-only mode')
permission_probe(write_only_dest, '-r', 1, 'write-search-only mode')
permission_probe(write_only_dest, '-w', 0, 'write-search-only mode')
permission_probe(write_only_dest, '-x', 0, 'write-search-only mode')
permission_probe(nested_parent, '-r', 1, 'nested write-search-only mode')
permission_probe(nested_parent, '-w', 0, 'nested write-search-only mode')
permission_probe(nested_parent, '-x', 0, 'nested write-search-only mode')
# Keep received implied dirs usable on systems without a safe fchmodat2.
# The source remains mode 0111, so sender traversal coverage is unchanged.
exact = subprocess.run(
launcher + rsync_argv(
'-aR', '--chmod=Du+rw', 'xonly/exact', f'{exact_dest}/',
),
cwd=src,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
exact_path = exact_dest / 'xonly' / 'exact'
exact_content = exact_path.read_text() if exact_path.is_file() else None
if exact.returncode != 0 or exact_content != (
'known file beneath search-only parent\n'
):
failures.append(
'exact -R source beneath mode 0111 failed: '
f'rc={exact.returncode}, stderr={exact.stderr.strip()!r}, '
f'content={exact_content!r}'
)
tree = subprocess.run(
launcher + rsync_argv(
'-aR', '--chmod=Du+rw', 'xonly/readable/', f'{tree_dest}/',
),
cwd=src,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
tree_path = tree_dest / 'xonly' / 'readable' / 'nested'
tree_content = tree_path.read_text() if tree_path.is_file() else None
if tree.returncode != 0 or tree_content != (
'enumerated below search-only ancestor\n'
):
failures.append(
'readable directory beneath mode 0111 ancestor failed: '
f'rc={tree.returncode}, stderr={tree.stderr.strip()!r}, '
f'content={tree_content!r}'
)
unreadable = subprocess.run(
launcher + rsync_argv(
'-a', 'xonly/', f'{unreadable_dest}/',
),
cwd=src,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if unreadable.returncode == 0:
failures.append(
'mode 0111 source directory was enumerable without read permission'
)
receiver = subprocess.run(
launcher + rsync_argv(
'-t', str(incoming), f'{write_only_dest}/',
),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
received = write_only_dest / 'incoming'
received_content = received.read_text() if received.is_file() else None
if receiver.returncode != 0 or received_content != (
'created beneath write-search-only destination\n'
):
failures.append(
'known-file creation beneath mode 0333 destination failed: '
f'rc={receiver.returncode}, stderr={receiver.stderr.strip()!r}, '
f'content={received_content!r}'
)
# Protocol 29 rejects this nested -R shape before the resolver is reached.
proto = forced_protocol()
if proto is None or proto >= 30:
nested_receiver = subprocess.run(
launcher + rsync_argv(
'-tR', '--no-implied-dirs', 'nested/known', f'{nested_dest}/',
),
cwd=src,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
nested_received = nested_parent / 'known'
nested_content = (
nested_received.read_text() if nested_received.is_file() else None
)
if nested_receiver.returncode != 0 or nested_content != (
'created beneath nested write-search-only parent\n'
):
failures.append(
'known-file creation beneath nested mode 0333 destination '
f'failed: rc={nested_receiver.returncode}, '
f'stderr={nested_receiver.stderr.strip()!r}, '
f'content={nested_content!r}'
)
finally:
xonly.chmod(0o755)
write_only_dest.chmod(0o755)
nested_parent.chmod(0o755)
for dest in (exact_dest, tree_dest):
copied_xonly = dest / 'xonly'
if copied_xonly.is_dir():
copied_xonly.chmod(0o755)
if external_base:
rmtree(base)
if failures:
test_fail('\n'.join(failures))
+1
View File
@@ -58,6 +58,7 @@ rrsync-sender-parent-pin
rrsync-symlink
rrsync-userns-procfs
search-only-destination
search-only-held-dirfd
sender-remove-source-root-anchor
simd-checksum
source-change-size-continues
+1
View File
@@ -28,6 +28,7 @@ rrsync-sender-parent-pin
rrsync-symlink
rrsync-userns-procfs
search-only-destination
search-only-held-dirfd
sender-remove-source-root-anchor
simd-checksum
source-change-size-continues
+4 -6
View File
@@ -1349,7 +1349,7 @@ int change_dir(const char *dir, int set_path_only)
* non-daemon receiver can opt back into the legacy plain chdir with
* --insecure-links. */
if (am_daemon && !am_chrooted) {
int dfd = open_no_attacker_symlinks(dir, O_RDONLY | O_DIRECTORY, 0);
int dfd = open_no_attacker_symlinks_dirfd(dir);
if (dfd < 0)
return 0;
if (fchdir(dfd) != 0) {
@@ -1380,7 +1380,7 @@ int change_dir(const char *dir, int set_path_only)
* another uid. A real dir is opened directly. This closes the
* destination chdir TOCTOU; --insecure-links keeps the plain
* chdir for an operator whose dest is a foreign-owned symlink. */
dfd = open_no_attacker_symlinks(nf, O_RDONLY | O_DIRECTORY, 0);
dfd = open_no_attacker_symlinks_dirfd(nf);
if (dfd < 0)
return 0;
if (fchdir(dfd) != 0) {
@@ -1441,8 +1441,7 @@ int change_dir(const char *dir, int set_path_only)
prefix[save_dir_len] = '\0';
basedir = prefix;
}
dfd = secure_relative_open(basedir, dir,
O_RDONLY | O_DIRECTORY, 0);
dfd = secure_relative_dirfd(basedir, dir);
if (dfd < 0) {
chdir_failed = 1;
} else {
@@ -1460,8 +1459,7 @@ int change_dir(const char *dir, int set_path_only)
* symlink not owned by uid 0 or our euid, closing the
* relative-dest chdir TOCTOU while still following the operator's
* own symlinks. --insecure-links keeps the plain chdir. */
int dfd = open_no_attacker_symlinks(curr_dir,
O_RDONLY | O_DIRECTORY, 0);
int dfd = open_no_attacker_symlinks_dirfd(curr_dir);
if (dfd < 0)
chdir_failed = 1;
else {