]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
t_chmod_secure: probe kernel RESOLVE_BENEATH at runtime; drop test skip
authorAndrew Tridgell <tridge60@gmail.com>
Wed, 20 May 2026 21:13:36 +0000 (07:13 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Wed, 20 May 2026 21:40:30 +0000 (07:40 +1000)
The chmod-symlink-race test was previously a no-op on Solaris,
OpenBSD, NetBSD, and Cygwin via a case 'uname -s' skip.  The skip
was too broad: of the four scenarios the helper exercises, only
the 'legitimate within-tree dir-symlink' one actually needs
RESOLVE_BENEATH-equivalent kernel support.  The other three
(attack rejection, plain relative path, top-level file) behave
identically on the per-component O_NOFOLLOW fallback and would
have caught the t_stub.c max_alloc=0 bug fixed in the previous
commit if the test had been allowed to run.

Make the helper probe the running kernel for either
openat2(RESOLVE_BENEATH) on Linux 5.6+ or openat(O_RESOLVE_BENEATH)
on FreeBSD 13+ / macOS 15+ by opening '.' under the requested
confinement.  Honour the result:

  - If RESOLVE_BENEATH-equivalent confinement is available, the
    within-tree symlink scenario must succeed (status quo).
  - If not, the per-component O_NOFOLLOW fallback rejects every
    symlink including legitimate ones; expect the within-tree
    symlink scenario to be rejected (rc != 0) and the file mode
    to remain unchanged.

The attack-rejection, plain-path and top-level scenarios are
unchanged: they expect the same outcome on both code paths.

Drop the case-based skip from chmod-symlink-race.test so the test
runs everywhere and the per-component fallback gets the CI
coverage that the SunOS/OpenBSD/NetBSD/Cygwin runners can
provide.  HPE NonStop -- which lacks RESOLVE_BENEATH but isn't in
the existing skip list -- is also covered by this change.

.github/workflows/cygwin-build.yml
t_chmod_secure.c
testsuite/chmod-symlink-race.test

index 781e46953cc63e5f6d41e9a854fdc9ab9acaa20d..fe5a5c422d37680d7dbaadaea2d8a7a86268eff5 100644 (file)
@@ -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
index 114dfb2de5f99134a271ba61c7a33438d1d26bcf..7c57dbbca3f5cb23c0ee83ee10d57ab00af3e33c 100644 (file)
 
 #include <sys/stat.h>
 
+#ifdef __linux__
+#include <sys/syscall.h>
+#include <linux/openat2.h>
+#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. */
index 48bbfbb4865fd008158e4b9041b3ab0b29273fc6..6453af92119140fe16e2efe222809015065d0e93 100755 (executable)
 # receiver's check and its act, and the syscall escapes the module.
 #
 # This test exercises the new do_chmod_at() wrapper via the
-# t_chmod_secure helper. The helper sets up two scenarios:
+# t_chmod_secure helper. The helper sets up four scenarios:
 #   - a parent dir-symlink that resolves WITHIN the module tree
-#     (legitimate -K-style use, must continue to work)
+#     (legitimate -K-style use)
 #   - a parent dir-symlink that escapes the module tree (the
-#     attack, must be rejected)
-# plus two regression scenarios (plain relative path, top-level
-# file) that just confirm the safe wrapper doesn't break the
-# normal case.
+#     attack, must be rejected on every platform)
+#   - plain relative path (regression check)
+#   - top-level file with no parent component (regression check)
 #
-# The kernel-enforced "stay below dirfd" path resolution is
-# only available on Linux 5.6+, FreeBSD 13+, and macOS 15+.
-# Skip on platforms that fall back to per-component O_NOFOLLOW
-# (Solaris, OpenBSD, NetBSD, Cygwin); the per-component fallback
-# would also reject the attack but the legitimate dir-symlink
-# scenario would fail there.
+# Kernel-enforced "stay below dirfd" path resolution is available
+# on Linux 5.6+, FreeBSD 13+, and macOS 15+.  On those platforms
+# the legitimate within-tree symlink must be followed and the
+# chmod must succeed.  On platforms that fall back to the
+# per-component O_NOFOLLOW walk (Solaris, OpenBSD, NetBSD,
+# older Cygwin, HPE NonStop, pre-5.6 Linux), every symlink --
+# including the legitimate one -- is rejected; that's a real
+# platform limitation, not a security regression.  The helper
+# probes the running kernel at startup and adjusts the expected
+# outcome for the within-tree-symlink scenario accordingly, so
+# this test runs everywhere and gives the per-component fallback
+# real CI coverage (the attack-rejection, plain-path, and
+# top-level scenarios all behave identically on both code paths).
 
 . "$suitedir/rsync.fns"
 
-case "$(uname -s)" in
-    SunOS|OpenBSD|NetBSD|CYGWIN*)
-       test_skipped "do_chmod_at relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
-       ;;
-esac
-
 mod="$scratchdir/module"
 trap_outside="$scratchdir/trap"
 rm -rf "$mod" "$trap_outside"