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
9 changes: 9 additions & 0 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,15 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo
goto cleanup;
}
if (next_fd == -1) {
/* Final component that does not exist yet: if the caller
* wants a file (not O_DIRECTORY), open/create it here with
* O_NOFOLLOW so O_CREAT works and a pre-planted symlink at
* the name is still refused. A missing *intermediate*
* component (more path follows) stays a genuine ENOENT. */
if (errno == ENOENT && !(flags & O_DIRECTORY)
&& strtok(NULL, "/") == NULL) {
retfd = openat(dirfd, part, flags | O_NOFOLLOW, mode);
}
goto cleanup;
}
if (dirfd != AT_FDCWD) close(dirfd);
Expand Down
164 changes: 162 additions & 2 deletions t_secure_relpath.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
* post-fix, the front-door check catches every variant up front
* with a consistent EINVAL across platforms.
*
* Run with a second argument of "semantics" it instead checks the
* resolution semantics that both resolver tiers (the kernel
* RESOLVE_BENEATH fast paths and the per-component O_NOFOLLOW walk
* fallback) must agree on: creating a missing final component with
* O_CREAT, ENOENT for a missing intermediate component, opening an
* existing file, refusing an out-of-tree symlink in the final
* component, and O_DIRECTORY requests. The create case is a
* regression test for the fallback returning ENOENT instead of
* creating the file (which broke the non-chroot daemon receiver
* with --inplace); the fallback tier is exercised natively on
* NetBSD/OpenBSD/Solaris/Cygwin and by --disable-openat2 builds.
*
* Not linked into rsync itself.
*/

Expand Down Expand Up @@ -99,10 +111,155 @@ static void check_basedir(const char *basedir)
fprintf(stderr, "OK [basedir=%-12s]: rejected with EINVAL\n", basedir);
}

static void check_open_ok(const char *relpath, int flags, mode_t mode, int want_dir, const char *what)
{
struct stat sb;
int fd = secure_relative_open(NULL, relpath, flags, mode);

if (fd < 0) {
fprintf(stderr, "FAIL [%-20s]: %s: expected success, got errno=%d (%s)\n",
relpath, what, errno, strerror(errno));
errs++;
return;
}
if (fstat(fd, &sb) == 0 && want_dir != S_ISDIR(sb.st_mode)) {
fprintf(stderr, "FAIL [%-20s]: %s: opened a %s, expected a %s\n",
relpath, what, S_ISDIR(sb.st_mode) ? "directory" : "file",
want_dir ? "directory" : "file");
close(fd);
errs++;
return;
}
close(fd);
fprintf(stderr, "OK [%-20s]: %s\n", relpath, what);
}

static void check_open_fail(const char *relpath, int flags, int want_errno, const char *what)
{
/* openat2() rejects a nonzero mode without O_CREAT (EINVAL), so
* only pass one when creating -- as real callers do. */
mode_t mode = flags & O_CREAT ? 0600 : 0;
int fd = secure_relative_open(NULL, relpath, flags, mode);
int saved_errno = errno;

if (fd >= 0) {
fprintf(stderr, "FAIL [%-20s]: %s: expected failure but got fd %d\n",
relpath, what, fd);
close(fd);
errs++;
return;
}
/* want_errno 0 = any errno is acceptable (the exact code differs
* between the resolver tiers); it just must fail. */
if (want_errno && saved_errno != want_errno) {
fprintf(stderr, "FAIL [%-20s]: %s: errno=%d (%s), expected %d (%s)\n",
relpath, what, saved_errno, strerror(saved_errno),
want_errno, strerror(want_errno));
errs++;
return;
}
fprintf(stderr, "OK [%-20s]: %s (errno=%s)\n", relpath, what, strerror(saved_errno));
}

static void check_presence(const char *path, int want_present, const char *what)
{
struct stat sb;
int present = lstat(path, &sb) == 0;

if (present != want_present) {
fprintf(stderr, "FAIL [%-20s]: %s: %s\n",
path, what, present ? "unexpectedly exists" : "does not exist");
errs++;
return;
}
fprintf(stderr, "OK [%-20s]: %s\n", path, what);
}

static int run_semantics_checks(void)
{
char cwd[MAXPATHLEN], esc_target[MAXPATHLEN];
int fd, have_symlink;

if (mkdir("subdir", 0755) < 0 && errno != EEXIST) {
perror("mkdir subdir");
return 2;
}
fd = open("subdir/existing.txt", O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
perror("create subdir/existing.txt");
return 2;
}
close(fd);

/* An ABSOLUTE symlink target outside the anchor directory: refused
* by both tiers (the fast paths reject the escape in-kernel, the
* fallback refuses any symlink via O_NOFOLLOW). A within-tree
* symlink would be tier-dependent (the kernel paths follow it, the
* fallback does not), so it is deliberately not tested here. */
if (!getcwd(cwd, sizeof cwd)) {
perror("getcwd");
return 2;
}
if ((size_t)snprintf(esc_target, sizeof esc_target,
"%s/../t_secure_relpath_escape.tmp", cwd) >= sizeof esc_target) {
fprintf(stderr, "escape target path too long\n");
return 2;
}
unlink(esc_target);
have_symlink = symlink(esc_target, "subdir/esclink") == 0;

/* A missing final component with O_CREAT must be created (this is
* the non-chroot daemon receiver shape for --inplace: relpath with
* no basedir). Regression: the walk fallback returned ENOENT. */
check_open_ok("newfile.txt", O_WRONLY | O_CREAT, 0600, 0,
"create missing final component");
check_presence("newfile.txt", 1, "created file is present");
check_open_ok("subdir/newfile2.txt", O_WRONLY | O_CREAT, 0600, 0,
"create missing final component (nested)");
check_presence("subdir/newfile2.txt", 1, "created nested file is present");

/* A missing INTERMEDIATE component is a genuine ENOENT and must
* not create anything. */
check_open_fail("missingdir/new.txt", O_WRONLY | O_CREAT, ENOENT,
"missing intermediate component");
check_presence("missingdir", 0, "missing intermediate not created");

/* Existing regular file: readable, and O_CREAT without O_EXCL
* opens it (the receiver reopens existing files this way). */
check_open_ok("subdir/existing.txt", O_RDONLY, 0, 0,
"open existing file O_RDONLY");
check_open_ok("subdir/existing.txt", O_WRONLY | O_CREAT, 0600, 0,
"reopen existing file O_WRONLY|O_CREAT");

/* A symlink in the final component pointing outside the tree must
* be refused, and O_CREAT must not create the escape target. The
* errno differs by tier (ELOOP from the walk, EXDEV & co from
* RESOLVE_BENEATH), so only the refusal itself is checked. */
if (have_symlink) {
check_open_fail("subdir/esclink", O_WRONLY | O_CREAT, 0,
"refuse out-of-tree symlink final component");
check_presence(esc_target, 0, "escape target not created");
} else
fprintf(stderr, "SKIP [subdir/esclink ]: symlink() unsupported here\n");

/* O_DIRECTORY: an existing directory opens (and is a directory);
* a missing name stays ENOENT -- O_CREAT-style creation must not
* kick in for directory requests. */
check_open_ok("subdir", O_RDONLY | O_DIRECTORY, 0, 1,
"open existing dir O_DIRECTORY");
check_open_fail("missingdir2", O_RDONLY | O_DIRECTORY, ENOENT,
"missing dir with O_DIRECTORY");
check_presence("missingdir2", 0, "missing dir not created");

if (errs)
fprintf(stderr, "\n%d failure(s)\n", errs);
return errs ? 1 : 0;
}

int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s <test-dir>\n", argv[0]);
if (argc != 2 && !(argc == 3 && strcmp(argv[2], "semantics") == 0)) {
fprintf(stderr, "usage: %s <test-dir> [semantics]\n", argv[0]);
return 2;
}
if (chdir(argv[1]) < 0) {
Expand All @@ -118,6 +275,9 @@ int main(int argc, char **argv)
am_daemon = 1;
am_chrooted = 0;

if (argc == 3)
return run_semantics_checks();

mkdir("subdir", 0755);

/* Each of these relpaths must be rejected with EINVAL at the
Expand Down
36 changes: 36 additions & 0 deletions testsuite/secure-open-semantics_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
# Regression test for secure_relative_open()'s resolution semantics,
# which must be identical across the resolver tiers (the kernel
# RESOLVE_BENEATH fast paths and the per-component O_NOFOLLOW walk
# fallback used on NetBSD, OpenBSD, Solaris, Cygwin, pre-5.6 Linux
# and --disable-openat2 builds):
#
# - a missing FINAL component with O_CREAT is created (regression:
# the walk fallback returned ENOENT instead, which broke every
# new-file create through the non-chroot daemon receiver's
# --inplace path, e.g. MariaDB/Galera rsync SST);
# - a missing INTERMEDIATE component still fails with ENOENT;
# - an existing regular file opens (O_RDONLY, and O_WRONLY|O_CREAT
# without O_EXCL);
# - an out-of-tree symlink in the final component is refused and
# O_CREAT does not create the escape target;
# - O_DIRECTORY opens an existing directory and stays ENOENT for a
# missing name (no creation for directory requests).
#
# The checks live in the t_secure_relpath helper ('semantics' mode).

import subprocess

from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail


testdir = SCRATCHDIR / 'secure-open-test'
rmtree(testdir)
testdir.mkdir(parents=True)

proc = subprocess.run([str(TOOLDIR / 't_secure_relpath'), str(testdir), 'semantics'])
if proc.returncode != 0:
test_fail(
'secure_relative_open() resolution semantics check failed '
'(see stderr above for the specific case)'
)