From cd22f195ef1fbbf3a2c2dcf779dd678d0776c56b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 25 Jun 2026 19:04:09 +1000 Subject: [PATCH] testsuite: lock down the in-module symlink-escape resolution matrix daemon-symlink-escape-matrix exercises, for a writable non-chroot module, every combination of `insecure links` {no,yes} x `munge symlinks` {no,yes} x link origin {pre-existing, uploaded} x op {read pull, write push} x five symlink target types (rel-within, rel-outside, rel-transits [.. above the module root then back in], abs-outside, abs-inside). It pins the contract: the secure default follows only an in-tree (rel-within) link and NEVER reaches an out-of-module target (read or write); the `insecure links = yes` opt-out restores legacy following on sender AND receiver (so an outside target escapes, matching stock 3.2.7); and an uploaded link never escapes regardless (munge prefixes it, munge-off sanitises it). A secure-default out-of-module access is a hard failure. require_tcp + root gated; listed in the per-platform RSYNC_EXPECT_SKIPPED pipe make-check sets. --- .github/workflows/almalinux-8-build.yml | 2 +- .github/workflows/cygwin-build.yml | 2 +- .github/workflows/macos-build.yml | 2 +- .github/workflows/ubuntu-22.04-build.yml | 6 +- .github/workflows/ubuntu-build.yml | 6 +- .../daemon-symlink-escape-matrix_test.py | 198 ++++++++++++++++++ 6 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 testsuite/daemon-symlink-escape-matrix_test.py diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index c07d014a..64a8a5b6 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -62,7 +62,7 @@ jobs: # crtimes-not-supported skip matches the other Linux jobs; # daemon-chroot-acl and proxy-response-line-too-long skip because # the default (secure) transport opens no listening socket. - run: RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check + run: RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check - name: check (TCP daemon transport) # Second run exercising the real loopback-TCP daemon path. run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 9f2a6e69..9e35083e 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -44,7 +44,7 @@ jobs: # package installed above), verified on a real Cygwin host. The real # chown/devices tests still skip (need root/mknod), as do the # RESOLVE_BENEATH symlink-race tests. - run: bash -c 'RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,batch-file-symlink,checksum-zero-blocklen,chown,chroot-alt-dest-inner-module,chroot-basis-forge-inner-module,chroot-copy-dest-inner-module,chroot-link-dest-inner-module,chroot-receiver-write-inner-module,chroot-special-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-config-symlink,daemon-max-alloc-zero,daemon-module-chdir-symlink,daemon-module-private-parent,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,fake-super-acl-xattr,daemon-secrets-file-symlink,daemon-standalone-detach,devices,dir-sgid,early-input-symlink,exclude-implied-trailing-backslash,excludefrom-symlink,filter-merge-symlink,insecure-links-admin-optout,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,log-file-symlink,match-append-empty-nullmap,match-want-i-nolen,nondaemon-symlink-race,nonroot-restrictive-perms,open-noatime,operator-path-insecure-links-daemon,password-file-symlink,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rename-mixed-parent-transfer,simd-checksum,symlink-dest-backupdir,symlink-exclude-xattr,symlink-race-dest,symlink-race-relative-dest,variety-symlink-traversal,xattr-wire-cap,xrsync make check' + run: bash -c 'RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,batch-file-symlink,checksum-zero-blocklen,chown,chroot-alt-dest-inner-module,chroot-basis-forge-inner-module,chroot-copy-dest-inner-module,chroot-link-dest-inner-module,chroot-receiver-write-inner-module,chroot-special-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-config-symlink,daemon-max-alloc-zero,daemon-module-chdir-symlink,daemon-module-private-parent,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,fake-super-acl-xattr,daemon-secrets-file-symlink,daemon-standalone-detach,devices,dir-sgid,early-input-symlink,exclude-implied-trailing-backslash,excludefrom-symlink,filter-merge-symlink,insecure-links-admin-optout,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,log-file-symlink,match-append-empty-nullmap,match-want-i-nolen,nondaemon-symlink-race,nonroot-restrictive-perms,open-noatime,operator-path-insecure-links-daemon,password-file-symlink,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rename-mixed-parent-transfer,simd-checksum,symlink-dest-backupdir,symlink-exclude-xattr,symlink-race-dest,symlink-race-relative-dest,variety-symlink-traversal,xattr-wire-cap,xrsync make check' - name: check (TCP daemon transport) # Second run with daemon tests over a real loopback rsyncd; the default # 'make check' above uses the secure stdio-pipe transport. diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index c869847a..1c8ca7c0 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -44,7 +44,7 @@ jobs: # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS # (rsyncfns.py drives xattrs via the `xattr` command), verified on a # real macOS host, so they're no longer in the skip set. - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,checksum-zero-blocklen,chmod-temp-dir,chroot-basis-forge-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-max-alloc-zero,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,backup-crossdev-copy,fake-super-acl-xattr,daemon-standalone-detach,dir-sgid,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,open-noatime,preallocate,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rrsync-symlink,simd-checksum,sparse,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-default,acls-depth,acls-unpinnable,backup-acl-xattr-cache,checksum-zero-blocklen,chmod-temp-dir,chroot-basis-forge-inner-module,copy-xattrs-symlink-race,daemon-access-ip,daemon-argv-limit,daemon-auth-group,daemon-chroot-acl,daemon-max-alloc-zero,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,backup-crossdev-copy,fake-super-acl-xattr,daemon-standalone-detach,dir-sgid,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,open-noatime,preallocate,protected-regular,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,rrsync-symlink,simd-checksum,sparse,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check - name: check (TCP daemon transport) # Second run with daemon tests over a real loopback rsyncd; the default # 'make check' above uses the secure stdio-pipe transport. diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml index d4acb326..1c874b64 100644 --- a/.github/workflows/ubuntu-22.04-build.yml +++ b/.github/workflows/ubuntu-22.04-build.yml @@ -39,11 +39,11 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30 + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30 - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29 + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29 - name: check (TCP daemon transport) # Second run with daemon tests over a real loopback rsyncd; the default # 'make check' above uses the secure stdio-pipe transport. diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml index 49aea10b..059ff587 100644 --- a/.github/workflows/ubuntu-build.yml +++ b/.github/workflows/ubuntu-build.yml @@ -35,11 +35,11 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30 + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,variety-symlink-traversal,xattr-wire-cap,xrsync make check30 - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29 + run: sudo RSYNC_EXPECT_SKIPPED=daemon-deny-dns-failopen,inband-modname-leak,recv-generator-acl-leak,uidlist-id0-name-leak,acl-symlink-race,acls-unpinnable,checksum-zero-blocklen,chroot-basis-forge-inner-module,crtimes,daemon-access-ip,daemon-argv-limit,daemon-chroot-acl,daemon-namecvt-empty-response,daemon-namecvt-newline-token,daemon-chroot,daemon-http-proxy,daemon-module-options,daemon-symlink-escape-matrix,daemon-proxy-protocol,daemon-standalone-detach,exclude-implied-trailing-backslash,io-noop-flood-recursion,io-nosend-flood-recursion,io-readargs-argv-nullwrite,match-append-empty-nullmap,match-want-i-nolen,scanner-batch-flag-mismatch,proto-cleared-dirflist,proto-cleared-ndx,proto-hlink-flag-oob,proto-hlink-gnum,proto-msg-info-assert,proto-sender-selftest,proto-subflist-freed,proxy-connect-request-too-long,proxy-protocol-trusted-peer,proxy-response-header-too-long,proxy-response-line-too-long,recv-discard-nullderef,symlink-exclude-xattr,variety-symlink-traversal,xattr-wire-cap,xrsync make check29 - name: check (TCP daemon transport) # Second run with daemon tests over a real loopback rsyncd. The default # 'make check' above uses the secure stdio-pipe transport (no listening diff --git a/testsuite/daemon-symlink-escape-matrix_test.py b/testsuite/daemon-symlink-escape-matrix_test.py new file mode 100644 index 00000000..1e6102ee --- /dev/null +++ b/testsuite/daemon-symlink-escape-matrix_test.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Lock down daemon in-module-symlink resolution for a writable, non-chroot +module, across every combination of: + + insecure links {no (default secure), yes (admin opt-out)} + x munge symlinks {no, yes} + x link origin {pre-existing on disk, uploaded via rsync} + x operation {READ via --copy-dirlinks pull, WRITE via --keep-dirlinks push} + x symlink TYPE (the target the in-module link "evil" points at): + rel-within : relative, stays inside the module (legit) + rel-outside : relative, climbs OUT of the module (escape) + rel-transits : relative, climbs above the module root with ".." + then back IN -- net target inside, path transits out + abs-outside : absolute, lands OUTSIDE the module (escape) + abs-inside : absolute, lands INSIDE the module (legit) + +We measure whether the operation FOLLOWED the link to its target ("followed"). +For an *outside* target, followed == an out-of-module escape; for an *inside* +target, followed == legitimate in-module access. + +Contract pinned here for THIS branch: + - insecure links = no (default): the secure resolver follows only a + rel-within link; it refuses an absolute target (even one landing inside) and + any "../" that rises above the module root (even one landing back inside). + So no escape -- and rel-transits / abs-inside are a deliberate functionality + cost of confinement. + - insecure links = yes (admin opt-out): restores the pre-3.4.3 legacy + behaviour uniformly (sender AND receiver) -- every type is followed, so an + outside type escapes, matching stock 3.2.7. + - Uploaded links never escape regardless: munge stores them /rsyncd-munged/- + prefixed, and munge-off still sanitises an incoming link (drops a leading + "/", strips escaping ".."). + +Needs root (foreign-owned plant; served as root) + a real TCP peer. +""" + +import os +import subprocess + +from rsyncfns import ( + SCRATCHDIR, + find_attacker_uid, require_tcp, rmtree, rsync_argv, rsync_supports, + start_test_daemon, test_fail, test_skipped, write_daemon_conf, +) + +DAEMON_PORT = 12909 +SECRET = "TARGET-CONTENT\n" +PWNED = "WROTE-THROUGH-LINK\n" + +require_tcp("the daemon symlink-resolution flow needs a real TCP peer") +if os.geteuid() != 0: + test_skipped("requires root to plant a foreign-owned symlink and serve as root") +if not rsync_supports('--copy-dirlinks'): + test_skipped("rsync lacks --copy-dirlinks") +ATT = find_attacker_uid() +if ATT is None: + test_skipped("no untrusted-uid user available for the cross-uid plant") + +TYPES = ('rel-within', 'rel-outside', 'rel-transits', 'abs-outside', 'abs-inside') +OUTSIDE = {'rel-outside', 'abs-outside'} + +base = SCRATCHDIR / 'symlink-escape-matrix' +rmtree(base) +base.mkdir(parents=True) + +MODS = {} # (insecure, munge) -> (modname, moddir, outside) +mod_conf = [] +for insecure in (False, True): + for munge in (False, True): + name = f"m_{'ins' if insecure else 'safe'}_{'munge' if munge else 'nomunge'}" + moddir = base / (name + '_root') + outside = base / (name + '_outside') + moddir.mkdir(); outside.mkdir() + MODS[(insecure, munge)] = (name, moddir, outside) + mod_conf.append((name, { + 'path': str(moddir), 'use chroot': 'no', 'read only': 'no', + 'uid': '0', 'gid': '0', + 'insecure links': 'yes' if insecure else 'no', + 'munge symlinks': 'yes' if munge else 'no', + })) + +conf = write_daemon_conf(mod_conf, name='symlink-escape-matrix.conf') +url = start_test_daemon(conf, DAEMON_PORT) + + +def link_target(moddir, outside, sltype): + """(symlink value, resolved target dir, target_is_outside) for a TYPE.""" + realdir = moddir / 'realdir' + if sltype == 'rel-within': + return 'realdir', realdir, False + if sltype == 'rel-outside': + return '../' + outside.name, outside, True + if sltype == 'rel-transits': # up to the parent, then back into the module + return f'../{moddir.name}/realdir', realdir, False + if sltype == 'abs-outside': + return str(outside), outside, True + if sltype == 'abs-inside': + return str(realdir), realdir, False + raise AssertionError(sltype) + + +def attempt(insecure, munge, origin, vector, sltype): + """Set up the cell, run it, return (followed, target_outside).""" + name, moddir, outside = MODS[(insecure, munge)] + rmtree(moddir); moddir.mkdir() + rmtree(outside); outside.mkdir() + realdir = moddir / 'realdir' + realdir.mkdir() + (realdir / 'tgtfile').write_text(SECRET) # in-module target content + (outside / 'tgtfile').write_text(SECRET) # out-of-module target content + symval, resolved, t_out = link_target(moddir, outside, sltype) + evil = moddir / 'evil' + + if origin == 'preexist': + os.symlink(symval, evil) + os.lchown(evil, ATT, ATT) + else: # uploaded: transfer the link in so munge/sanitise applies on the daemon + up = base / 'srcup' + rmtree(up); up.mkdir() + os.symlink(symval, up / 'evil') + subprocess.run(rsync_argv('-al', f'{up}/', f'{url}{name}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if not evil.is_symlink(): + return False, t_out + + if vector == 'read': + dest = base / 'dest' + rmtree(dest); dest.mkdir() + subprocess.run( + rsync_argv('-r', '--copy-dirlinks', f'{url}{name}/', f'{dest}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + through = dest / 'evil' / 'tgtfile' # content reached THROUGH the link + return (through.is_file() and through.read_text() == SECRET), t_out + + # write: push "evil/pwned" with --keep-dirlinks -> writes through the link + sw = base / 'srcw' + rmtree(sw); sw.mkdir() + (sw / 'evil').mkdir() + (sw / 'evil' / 'pwned').write_text(PWNED) + subprocess.run(rsync_argv('-r', '--keep-dirlinks', f'{sw}/', f'{url}{name}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + landed = resolved / 'pwned' + return (landed.is_file() and landed.read_text() == PWNED), t_out + + +def expected_followed(insecure, munge, origin, vector, sltype): + """Contract: secure default follows only a rel-within link; the opt-out + follows everything; an uploaded link is sanitised/munged so only rel-within + (whose value survives both) is still followable.""" + if origin == 'uploaded': + # munge prefixes EVERY incoming link with /rsyncd-munged/ (none follow); + # munge-off sanitises -- only a rel-within value survives intact. + return sltype == 'rel-within' and not munge + if insecure: + return True # opt-out: legacy follow of every type + return sltype == 'rel-within' # secure default: only the in-tree link + + +# Diagnostic: REPORT_MATRIX=1 surfaces the full observed grid (via test_fail) for +# eyeballing / cross-version comparison; unset, the test just asserts the contract. +_REPORT = os.environ.get('REPORT_MATRIX') +grid, mismatches, escapes = [], [], [] +for insecure in (False, True): + for munge in (False, True): + for origin in ('preexist', 'uploaded'): + for vector in ('read', 'write'): + for sltype in TYPES: + got, t_out = attempt(insecure, munge, origin, vector, sltype) + want = expected_followed(insecure, munge, origin, vector, sltype) + esc = got and t_out + grid.append(f"ins={int(insecure)} munge={int(munge)} " + f"{origin:8} {vector:5} {sltype:11}: " + f"followed={int(got)} want={int(want)} " + f"{'ESCAPE' if esc else ''}") + # A confined-default escape is a hard failure, always. + if esc and not insecure: + escapes.append(f"DEFAULT ESCAPE: munge={int(munge)} " + f"{origin}/{vector}/{sltype}") + if got != want: + mismatches.append( + f"insecure={'yes' if insecure else 'no'} " + f"munge={'yes' if munge else 'no'} " + f"{origin}/{vector}/{sltype}: " + f"followed={got}, expected={want}") + +if _REPORT: + test_fail("REPORT-MATRIX grid:\n " + "\n ".join(grid)) +if escapes: + test_fail("secure-default confinement FAILED (out-of-module access):\n " + + "\n ".join(escapes)) +if mismatches: + test_fail("symlink-resolution matrix deviated from the pinned contract:\n " + + "\n ".join(mismatches)) + +print("daemon-symlink-escape-matrix: 5 link types x preexist/uploaded x " + "read/write x munge x insecure all match the pinned contract " + "(secure default follows only in-tree links and never escapes; the " + "insecure-links opt-out restores legacy following on sender AND receiver)")