diff --git a/syscall.c b/syscall.c index c7f12d2c2..a89849e33 100644 --- a/syscall.c +++ b/syscall.c @@ -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); diff --git a/t_secure_relpath.c b/t_secure_relpath.c index a0fdf0d25..16b87e3db 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -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. */ @@ -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 \n", argv[0]); + if (argc != 2 && !(argc == 3 && strcmp(argv[2], "semantics") == 0)) { + fprintf(stderr, "usage: %s [semantics]\n", argv[0]); return 2; } if (chdir(argv[1]) < 0) { @@ -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 diff --git a/testsuite/secure-open-semantics_test.py b/testsuite/secure-open-semantics_test.py new file mode 100755 index 000000000..b712d6805 --- /dev/null +++ b/testsuite/secure-open-semantics_test.py @@ -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)' + )