diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 781e46953..fe5a5c422 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -39,7 +39,7 @@ jobs: - name: info run: bash -c '/usr/local/bin/rsync --version' - name: check - run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chmod-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check' + run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check' - name: ssl file list run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true' - name: save artifact diff --git a/t_chmod_secure.c b/t_chmod_secure.c index 114dfb2de..7c57dbbca 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -17,6 +17,11 @@ #include +#ifdef __linux__ +#include +#include +#endif + int dry_run = 0; int am_root = 0; int am_sender = 0; @@ -30,6 +35,42 @@ short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; static int errs = 0; +/* Probe the running kernel for the RESOLVE_BENEATH-equivalent confinement + * that secure_relative_open() prefers over the per-component O_NOFOLLOW + * walk. Returns 1 if either openat2(RESOLVE_BENEATH) on Linux 5.6+ or + * openat(O_RESOLVE_BENEATH) on FreeBSD 13+ / macOS 15+ is honoured by + * the running kernel, 0 otherwise. The probe opens "." (a directory + * the helper has just chdir'd into) so it can't fail for any reason + * other than the kernel rejecting the requested confinement flag. */ +static int kernel_resolve_beneath_supported(void) +{ + int fd; +#ifdef __linux__ + { + struct open_how how; + memset(&how, 0, sizeof how); + how.flags = O_RDONLY | O_DIRECTORY; + how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; + fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how); + if (fd >= 0) { + close(fd); + return 1; + } + /* ENOSYS = kernel < 5.6. Fall through to the O_RESOLVE_BENEATH + * probe in case we're a Linux build running on a kernel that + * gained O_RESOLVE_BENEATH via some out-of-tree backport. */ + } +#endif +#ifdef O_RESOLVE_BENEATH + fd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH); + if (fd >= 0) { + close(fd); + return 1; + } +#endif + return 0; +} + static void check(const char *label, int actual_rc, int expect_ok, const char *path, mode_t expected_mode) { @@ -87,10 +128,35 @@ int main(int argc, char **argv) * files to mode 0600 so we have a clean baseline to compare. */ - /* Scenario A: legitimate parent dir-symlink, chmod must succeed. */ + /* Scenario A: legitimate parent dir-symlink. + * + * On platforms whose kernel offers RESOLVE_BENEATH-equivalent + * confinement (Linux 5.6+ openat2, FreeBSD 13+ / macOS 15+ + * O_RESOLVE_BENEATH), the within-tree symlink is followed and + * the chmod must succeed. + * + * On platforms that fall back to the per-component O_NOFOLLOW + * walk (OpenBSD, NetBSD, Solaris, older Cygwin, HPE NonStop, + * and pre-5.6 Linux), every symlink is rejected -- including + * this legitimate one. That's a real platform limitation (the + * same one that causes the #715 regression there) and the + * expected outcome is rejection. + * + * Detect at runtime and expect accordingly. The other three + * scenarios behave identically on both code paths and need no + * adjustment. */ + int kernel_has_rb = kernel_resolve_beneath_supported(); + fprintf(stderr, "INFO: kernel RESOLVE_BENEATH-equivalent confinement: %s\n", + kernel_has_rb ? "available" : "not available (per-component fallback)"); + int rc = do_chmod_at("inside_link/sentinel", 0640); - check("A: legit dir-symlink within tree", - rc, 1, "realdir/sentinel", 0640); + if (kernel_has_rb) { + check("A: legit dir-symlink within tree (kernel confined)", + rc, 1, "realdir/sentinel", 0640); + } else { + check("A: legit dir-symlink within tree (per-component fallback rejects)", + rc, 0, "realdir/sentinel", 0600); + } /* Scenario B: parent symlink escapes the tree -- chmod must be * rejected and the outside file's mode must be unchanged. */ diff --git a/t_stub.c b/t_stub.c index 63bc144c5..b15af77f5 100644 --- a/t_stub.c +++ b/t_stub.c @@ -36,7 +36,12 @@ int preserve_perms = 0; int preserve_executability = 0; int omit_link_times = 0; int open_noatime = 0; -size_t max_alloc = 0; /* max_alloc is needed when combined with util2.o */ +size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; + * 0 here makes every my_alloc()/my_strdup() in + * util2.c trip the "exceeded --max-alloc=0" + * check, which any helper exercising the + * per-component fallback of secure_relative_open() + * hits at its first my_strdup() call. */ char *partial_dir; char *module_dir; filter_rule_list daemon_filter_list; diff --git a/testsuite/chdir-symlink-race.test b/testsuite/chdir-symlink-race.test index f5d4cb3f3..c464101f2 100755 --- a/testsuite/chdir-symlink-race.test +++ b/testsuite/chdir-symlink-race.test @@ -66,8 +66,10 @@ ln -s "$outside" "$mod/subdir" # different content, mode 0666 (the perms the attacker tries to push). SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \ || stat -f %z "$outside/target.txt") -head -c "$SIZE" /dev/urandom > "$src/target.txt" -head -c "$SIZE" /dev/urandom > "$src/subdir/target.txt" +make_data_file "$src/target.txt" "$SIZE" \ + || test_fail "failed to create source file" +make_data_file "$src/subdir/target.txt" "$SIZE" \ + || test_fail "failed to create source file" chmod 0666 "$src/target.txt" "$src/subdir/target.txt" cat > "$conf" <&2 + return 2 + fi + if [ -r /dev/urandom ] && \ + dd if=/dev/urandom of="$1" bs="$2" count=1 2>/dev/null && \ + [ -s "$1" ]; then + return 0 + fi + # Fallback: a 32-bit linear congruential generator with BSD/glibc + # parameters. Seeded from PID and a POSIX cksum of the destination + # path so successive calls with different paths produce distinct + # content. Output is constrained to the printable-ASCII range + # (33..126, i.e. '!' through '~') for two portability reasons: + # - awk implementations vary on whether printf "%c", 0 emits a + # NUL byte or terminates the string; + # - gawk in UTF-8 locales encodes printf "%c", N for N > 127 + # as a 2-byte UTF-8 sequence, which would make the output + # larger than the requested sz. + # The tests using this helper don't need 8-bit binary data, only + # non-trivial content for the rsync delta algorithm. + _path_seed=$(printf '%s' "$1" | cksum 2>/dev/null | awk '{print $1}') + awk -v sz="$2" -v seed_a="$$" -v seed_b="${_path_seed:-0}" 'BEGIN { + s = (seed_a + seed_b) % 2147483648 + if (s < 0) s = -s + for (i = 0; i < sz; i++) { + s = (s * 1103515245 + 12345) % 2147483648 + b = (int(s / 65536) % 94) + 33 # 33..126 + printf "%c", b + } + }' > "$1" +} + + ########################### # Run a test (in '$1') then compare directories $2 and $3 to see if # there are any difference. If there are, explain them. diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test index a14eb5cf5..88c55d2ba 100755 --- a/testsuite/symlink-dirlink-basis.test +++ b/testsuite/symlink-dirlink-basis.test @@ -46,8 +46,10 @@ export RSYNC_RSH # Helper: create a large file suitable for delta transfers. # ~32KB is large enough for rsync's block matching to find matches. +# make_data_file lives in rsync.fns and falls back to an awk PRNG +# on platforms without /dev/urandom (e.g. HPE NonStop). make_testfile() { - dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \ + make_data_file "$1" 32768 \ || test_fail "failed to create test file $1" }