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.
This commit is contained in:
Andrew Tridgell committed 2026-07-20 14:05:31 +10:00
1 parent 502fb584c4
commit cd22f195ef
6 files changed
+207 -9

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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.
+3 -3
View File
@@ -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
@@ -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)")