Commit Graph
7865 Commits
Author SHA1 Message Date
Andrew Tridgell 5eb99bb6b2 exclude: exempt the daemon's own filter parameters from the confinement
Confining every parse_filter_file() open to the module root also caught
"filter", "include from" and "exclude from" from rsyncd.conf.  Those name
operator-configured paths and pointing them outside the module -- at
/etc/rsync/excludes, say -- is the ordinary way to write them; rsyncd.conf(5)
puts no constraint on where the file lives.  The result was not a refused
rule but a refused connection:

    failed to open exclude file /etc/rsync/excludes:
        Too many levels of symbolic links (40)
    rsync error: error in file IO (code 11) at exclude.c(1582)

with no symlink involved anywhere -- just a regular file outside the module.

Mark the window in which the daemon loads its own parameters and skip the
confinement there.  Everything else, in particular the peer-driven dir-merge
the leak test exercises, is still confined.  Also fix the trailing whitespace
in the original hunk.
2026-07-29 11:31:57 +10:00
Omar Elsayed 4572d1743c exclude: path resolving to operator path supplied --filter file 2026-07-29 11:31:57 +10:00
Omar Elsayed 32f36fa845 filter-leak_test.py: root-owned backup filter symlink leak
This test verifies that a root-owned backup symlink does not leak out-of-tree file contents through a filter file
2026-07-29 11:31:57 +10:00
Andrew Tridgell 5c20cd157a github: expect the merged read-only-inplace tests to skip
Follow-up to 6b885e51/51618b74, which I merged without updating the
per-workflow expected-skip lists, so every fleet run since has reported
a skip mismatch on eight targets.

Both skips are legitimate:

  readonly-partial-abort-mode-regression exits 77 as root ("root
  bypasses the read-only output-file precondition"), and the fleet runs
  most targets as root -- so it only ever executes in a non-root run.

  daemon-leaf-type-race-fchmod needs Darwin and --use-tcp, so outside
  the macOS tcp cell it always skips.

Worth noting rather than burying: this means neither sec-regression test
runs in the fleet's default cells.  The read-only one is exercised only
by a non-root local run, and the leaf-type one only by macOS over TCP.
2026-07-29 10:14:48 +10:00
Andrew Tridgell 8d82b07b54 testsuite: add the macOS setgid regression, and let it find a usable group
The test only means anything when the scratch directory's group is one
the caller cannot grant, since that is what makes macOS refuse the
setgid bit.  Taking that group from the build tree is fine for a
checkout under a shared parent but not for one under a home directory --
there the group is the user's own and the test skips silently.  I only
got RED/GREEN out of it by chgrp'ing the scratch tree by hand.

So it falls back to /private/tmp, which is group wheel.  On macOS as an
ordinary user it runs and passes.

It does NOT run in our macOS CI: that workflow drives the suite with
sudo, and root can grant every group, so the condition cannot exist --
hence the entry in the macOS expected-skip list alongside the others.
Making it run there needs a separate non-root invocation, not attempted
here.  The skip message says which case it is instead of blaming the
scratch group.

The /private/tmp directory is outside SCRATCHDIR, which the harness
cleans, so it gets a mkdtemp() name and an atexit hook: a fixed name in
a sticky world-writable directory would let concurrent runs delete each
other's live fixture, and a leftover owned by another user would make
every later run skip.  Verified on macOS that a run leaves nothing
behind.
2026-07-29 10:12:24 +10:00
Filipe Casal 5f0f8f298e syscall: preserve ordinary mode when setgid is denied
macOS's fchmodat(..., AT_SYMLINK_NOFOLLOW) returns EPERM and applies
NOTHING when the requested setgid bit is ungrantable, so
"rsync -a --chmod=D2750,F0640" exits 23 and leaves the destination at
0755/0700 where 3.4.4 leaves 0750.  fchmod() on an already-open
descriptor does the right thing: it succeeds, drops the setgid bit it
cannot grant, and applies the ordinary bits.

That fd path existed but was fenced behind "#if defined __linux__".
Guard it on O_NOFOLLOW so every platform that can open a leaf without
following a symlink uses it, and leave the fchmodat/fchmodat2 fallbacks
under __linux__.  Measured on macOS with an ungrantable group:

  fchmodat(2750, NOFOLLOW)  EPERM, 0700 -> 0700   (dir and FIFO alike)
  fchmod(fd, 2750)          ok,    0700 -> 0750
  fchmodat(0750, NOFOLLOW)  ok,    0700 -> 0750

A FIFO observed by the lstat takes the pathname call instead, then one
retry without S_ISGID.  Opening a FIFO -- even O_NONBLOCK -- makes this
process a reader for as long as the descriptor lives, which wakes a
writer blocked in open(O_WRONLY) and can cost it a SIGPIPE or the bytes
it writes before we close; the third line above is why that is
avoidable.  This does not make the function FIFO-open-free: the type
comes from the lstat, so a leaf swapped to a FIFO after it is still
opened, and set_file_attrs() opens FIFOs elsewhere for ACL/xattr work.
Closing that needs the open constrained to the observed type, which is
tracked separately.

The retry is a pathname call and does not pin the inode, so a leaf
swapped for another object of the same name is chmod'd instead;
AT_SYMLINK_NOFOLLOW still keeps it off a symlink's target, and the held
parent fd still confines the ancestors, so the out-of-tree boundary is
unaffected.  Only S_ISGID is retried -- clearing S_ISUID too could
discard a bit that was grantable when only setgid caused the failure.
Linux keeps the fd-first order it has always had.

The raced-to-a-symlink refusal after openat() also accepts EMLINK and
EFTYPE.  ELOOP is not universal for O_NOFOLLOW on a symlink -- FreeBSD
documents EMLINK and NetBSD EFTYPE -- so those two silently skipped the
refusal and fell through to the (still symlink-safe) pathname call.

Verified on real macOS: the regression test FAILS on 93a67aa9 with rc=23
and modes 0755/0700, and PASSES here with 0750/0750.
2026-07-29 10:12:24 +10:00
Andrew Tridgell 93a67aa995 github: expect fake-super-backup-fifo-regression to skip on Cygwin
The fleet run for this change reported it as an unexpected skip there.
Cygwin has no real FIFO for fake-super to represent, so the test skips
by design; every other target runs it.
2026-07-28 15:14:43 +10:00
Filipe Casal 4ce54db5ef syscall: preserve fake-super backups as placeholders 2026-07-28 15:14:43 +10:00
Filipe Casal 51618b74c0 testsuite: pin the leaf type of the EACCES chmod recovery
From PR #90, whose code change is superseded by the preceding commit:
that helper already requires S_ISREG on the fd it chmods, and declines
the recovery entirely on the local/chrooted path #90 left untouched.
The test is kept because it is the only oracle for the directory-
substitution case.

Verified on macOS over TCP: FAIL on acfc94ef, PASS on the preceding
commit.  Two limits worth knowing: it needs both Darwin and --use-tcp,
so it only runs in the macOS tcp cell, and its daemon sets
"use chroot = no", so it covers the fd-based branch only.  It also
accepts a run in which the race window was never reached, so it can
report success without having exercised the check.
2026-07-28 13:50:37 +10:00
Filipe Casal 6b885e5175 receiver: restore read-only mode before in-place transfer
The EACCES recovery added by c1d7b5c6 chmods a read-only destination to
0600 so an --inplace update can proceed, and only restores the mode
after the transfer.  Any abort in between -- peer EOF, checksum failure,
a signal -- leaves the file permanently owner-writable.  3.4.4 fails the
transfer and leaves 0444, so this is new exposure in 3.5.0.

Record the existing mode, add only owner-write, open the writable
descriptor, and put the old mode back before any network data is
consumed.  The descriptor stays writable afterwards, so a complete
read-only --inplace update still works.

What this does NOT promise.  Restoration is best effort, not a
guarantee: an unprivileged fchmod() silently drops S_ISGID when the
file's group is outside the process's groups, so 02444 can come back as
0444 with both calls reporting success, and a signal inside the
chmod/open/restore window still strands the relaxed mode.  The window
goes from "the whole transfer" to a few syscalls, which is the point,
but it is not closed.

The helper also requires a regular file, which closes PR #90's finding:
O_NOFOLLOW refuses a symlink at the leaf but not a directory swapped in
after the type probe, and the recovery would otherwise fchmod that
directory from 0755 to 0600.  On the fd-based branch that check is an
fstat() of the descriptor being chmod'd, so it is genuine.  On the
local/chrooted branch it is only a type check on a stable path --
do_stat() follows a leaf symlink and every later call re-resolves the
name -- so that branch is confined by the chroot, not by this check.
Recovery is also skipped outright when the file is already
owner-writable, since adding S_IWUSR cannot be what such an EACCES is
about and each needless chmod risks a special bit.

Reworked from PR #102, which was written against a tree that already had
secure_recv_open() and deleted it: its helper resolved through
secure_relative_open(), dropping the one_inplace operator-path ownership
policy from the recovery window.  (Its initial O_CREAT open toggled
operator_path_resolve by hand and kept the policy; the Linux
protected-regular retry and the recovery did not.)  partial-protected-
regular-retry-linux catches that -- it passes on the base, fails with
the PR as submitted, and passes here.  This version keeps the recovery
on secure_recv_open(..., one_inplace) and gates it on
"use_secure_symlinks || one_inplace" like every other open in the block.
2026-07-28 13:50:37 +10:00
Andrew Tridgell acfc94ef48 testsuite: harden the LD_PRELOAD hook and stop a crash reading as a skip
Follow-ups from review of the lazy-resolution fix:

- Guard resolution against re-entry.  If dlsym() ever reaches an
  interposed function the nested wrapper would recurse; it now takes the
  raw path instead.

- Forward a mode for O_TMPFILE as well as O_CREAT.  rsync itself never
  uses it, but the hook interposes every library in the process.  The
  test is an equality one because Linux defines O_TMPFILE as
  __O_TMPFILE|O_DIRECTORY, so a plain & would also match O_DIRECTORY.

- Treat death by signal as a failure rather than a skip.  The load marker
  is written by the hook, so a crash before that point is
  indistinguishable from the hook never loading -- which is precisely how
  the AlmaLinux SIGSEGV stayed hidden.

Note the signal branch is not exercised by any current configuration:
with the raw openat(2) fallback in place the hook no longer crashes even
when resolution fails, which is why reconstructing the pre-fix behaviour
does not reproduce it.
2026-07-26 19:40:12 +10:00
Andrew Tridgell abcf37a1d3 sender: null-check the anchor before comparing it to the module root
The copy-links confinement gate null-checks module_dir but then passes
anchor to strcmp() without checking it, which the scan-build gate flags:

    sender.c:291:7: warning: Null pointer passed to 1st parameter
        expecting 'nonnull' [core.NonNullParamChecker]

Not reachable today -- the one caller passes module_dir -- but NULL is a
legitimate value for this parameter: secure_relative_open() reads it as
"relative to the cwd", which the else branch relies on.  Only this branch
would dereference it.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 9694994d5c fleettest: add an AlmaLinux 8 target
RHEL-family LTS coverage in the fleet, matching the almalinux-8-build.yml
CI job that until now was the only place this family ran.  Its container
and this VM do not agree on everything, so two box-specific skips are
recorded: no separate filesystem for a cross-device temp dir, and the
old static client the source-only push omits.

fs.protected_regular is enabled on the box (persisted in
/etc/sysctl.d/90-rsync-fleettest.conf) so protected-regular exercises the
real kernel behaviour here instead of skipping.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 5cf902f87f github: correct the AlmaLinux expected-skip list
sender-remove-source-root-anchor runs and passes there -- the job is
privileged and / is writable -- so listing it as an expected skip made
the whole run fail on the mismatch.  partial-protected-regular-retry-linux
is deliberately not added: with the hook fix it runs there too.
2026-07-26 19:40:12 +10:00
Andrew Tridgell bbcef46455 testsuite: resolve the LD_PRELOAD hook lazily, not in its constructor
A preloaded open() interposes for the whole process the moment the loader
maps the library -- including calls made from OTHER shared objects'
constructors.  The order constructors run between unrelated objects is
unspecified, so resolving real_open in our own constructor is a race we
do not always win.

On AlmaLinux 8 we lose it: OPENSSL_init_library() calls open() from its
constructor before ours runs, real_open is still NULL, and the process
dies in the loader:

    #0  0x0000000000000000
    #1  open () from hook.so
    #2  OPENSSL_init_library () from libcrypto.so.1.1
    #3  call_init ... dl-init.c

Every rsync run under the hook segfaulted, the load marker never
appeared, and the test reported "hook was not loaded" -- so a crash on a
supported platform surfaced only as a skip.  Not a glibc-version thing:
ubuntu-1804 (glibc 2.27, older than AlmaLinux 8's 2.28) wins the race and
passes.

Resolve on demand at the top of each wrapper instead, with a raw
openat(2) fallback for the case where even dlsym() is unusable that
early.  The test now runs, and passes, on AlmaLinux 8.
2026-07-26 19:40:12 +10:00
Andrew Tridgell 72f2ceaa88 testsuite: skip the stdio_daemon copy-links test below protocol 30
Its hand-rolled protocol client greets with version 30 and sends a
protocol-30 argument string.  When the run pins the daemon lower the two
sides cannot agree and the client just sits there until it times out, so
the test failed on every check29 target rather than reporting anything
about copy-links.

The behaviour under test is not protocol-specific: the sibling
daemon-copylinks-parent-escape drives the same sender paths with the real
rsync client and passes at protocol 29, so skipping here loses no
coverage.  Registered in the check29 expected-skip lists.
2026-07-25 17:28:54 +10:00
Andrew Tridgell c5bc4e3677 syscall: defer a literal ".." to the walk before the leaf fast paths
secure_walk_at() has two fast paths for the final component that call
openat(ds_cur(&ds), part, ...) directly instead of going through
ds_descend().  A final component of ".." therefore never met the anchor
floor, and what happened depended entirely on the caller's flags:

    ".." with O_DIRECTORY              -> ELOOP          (refused)
    ".." with O_DIRECTORY|O_NOFOLLOW   -> fd for the directory ABOVE
                                          the anchor
    ".." without O_DIRECTORY           -> parent opened, then closed,
                                          EISDIR returned

That was harmless while every literal ".." was rejected at the front
door, but secure_relative_open_at_beneath() now admits them and
documents the held-fd stack as refusing every climb above the anchor.
The sender's own call passes O_RDONLY|O_DIRECTORY and so was never
affected -- but the guarantee the new API advertises has to hold for
whatever flags the next caller picks.

Route a literal "." or ".." through ds_descend() before the leaf fast
paths.  All three flag combinations now refuse a bare ".." with ELOOP,
and t_secure_relpath covers the matrix.
2026-07-25 17:28:54 +10:00
Andrew Tridgell 1a69d2e20d testsuite: hold down both sides of the copy-links ".." loosening
The shipped regression test covers the in-module target that the fix
enables.  The fix is a loosening, though -- the resolver used to refuse
every literal ".." at the front door, which guarded the module boundary
by accident -- so the escape needs an end-to-end guard too, not just the
unit coverage in t_secure_relpath.

Cover a file and a directory symlink in each direction.  Both types are
needed because they take different paths through the sender: the
directory one already resolved ".." via the dirstack walk while the file
one hit the front-door EINVAL, which is exactly the asymmetry reported on
this PR (a "../dir" symlink copied, a "../file" one was skipped).

The oracle is what landed on disk, not the exit status: a refused escape
legitimately makes rsync exit 23.  The in-module assertions matter as
much as the leak ones -- without them "nothing leaked" would also be
satisfied by refusing everything, i.e. by the bug being fixed.
2026-07-25 17:28:54 +10:00
Filipe Casal 4d8cbbecac sender: allow confined parent-relative copy-links targets 2026-07-25 17:28:54 +10:00
Andrew Tridgell 2532e9c17b testsuite: make the basis-xname injection reliable on slow targets
Two races made this test report a vacuous result -- neither FIFO opened,
so no traversal was attempted and there was nothing to confine -- on the
slower fleet VMs.  It failed 9 runs in 12 on NetBSD.

Wait for each FIFO helper to reach its blocking open() before starting
the transfer, instead of assuming a freshly spawned process is already
there, and retry a run that comes back vacuous.  A vacuous run is a setup
failure, not a security signal: an ESCAPE still fails immediately and is
never retried, so the oracle keeps its strength.

With the stale-object fix as well, NetBSD is 15 passes in 15.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 5da6051243 testsuite: never reuse a stale object when building a patched peer
build_patched_rsync() copies the configured tree, including its prebuilt
objects, then rewrites one source and runs make.  copytree() preserves
mtimes, so on a target whose clock lags the host that pushed the tree the
copied sender.o is NEWER than the freshly patched sender.c: make reuses
it and the instrumentation never makes it into the binary.  The test then
drives an unmodified peer and reports a vacuous result -- basis-xname-
traversal did exactly that on NetBSD, whose clock ran ~1h behind (gmake
warned "modification time in the future" during the build).

Drop the object for each patched unit, and the prebuilt binary too, so
neither the compile nor the link can be skipped.  The function already
carried a comment about the same hazard on Cygwin's coarse mtimes.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 4694b73d75 testsuite: let a daemon test move off a port held by other software
claim_ports() fails loudly when a port is occupied, which is correct for
the 36 tests that bind the port themselves: they must not silently drift
away from the number they are about to bind.  start_test_daemon() owns
both the bind and the URL it returns, so it can move instead -- and needs
to, because a fixed test port can be permanently held by unrelated
software on a shared CI box.  An ASUS service was found sitting on 13010
on the Windows/Cygwin target, which no amount of orphan reaping frees, so
daemon-exclude-namebased failed there on every run.

Add claim_free_port(), which tries the preferred port and then a few
nearby ones, and use it at that single seam.  _probe_bindable() grows a
non-fatal mode to support it; its default behaviour is unchanged.
2026-07-25 15:20:21 +10:00
Andrew Tridgell 957ce5038d testsuite: build the O_CLOEXEC probe the way the tree was configured
The probe compiles the real authenticate.c with a hand-written include
list and ignores the CPPFLAGS configure recorded.  Where a dependency
lives outside the default search path -- openssl from brew on macOS --
that fails at <openssl/sha.h>, for reasons unrelated to O_CLOEXEC, so
the test failed permanently on the macOS fleet target.

Take CPPFLAGS (and CC, when the environment does not override it) from
the configured Makefile so the probe matches the production build.
2026-07-25 15:20:21 +10:00
Andrew Tridgell f0949ad0de sender: keep a Cygwin UNC prefix out of the "/"-anchored cleanup
The absolute-source branch strips every leading slash and resolves the
parent beneath "/".  On Cygwin clean_fname() deliberately preserves
exactly two leading slashes, because //server/share is a separate UNC
namespace -- so //server/share/f would be resolved as /server/share/f,
a different object, and the size/mtime guard would then be comparing the
wrong file before the unlink.  That is the same wrong-target removal this
branch exists to prevent.

Decline the confined open for that shape (errno 0) so the caller falls
back to the path-based cleanup, as it did before.  Exactly two slashes
matches clean_fname's own rule: three or more still collapse to one.
2026-07-25 13:38:12 +10:00
Andrew Tridgell 38bc594f87 testsuite: cover the wrong-file removal an absolute -R cleanup could cause
The sibling anchor test only covers the nested case, where re-anchoring
the cleanup at the sender's CWD merely fails with EINVAL.  For a source
that is a direct child of / the parent component is empty, so the
cwd-backed cache handed back the sender's own working directory and
--remove-source-files unlinked a same-named entry there -- the real
consequence of the defect, and silent: the requested source survived and
the exit status was 0.

Needs root and a writable /, so it skips elsewhere; registered as an
expected skip on the non-root and sealed-root platforms.

The decoy's mtime is copied at nanosecond precision on purpose: the
sender's changed-file guard compares sub-second mtime too, and a
whole-second copy makes it skip the removal for an unrelated reason,
which would leave the test passing on a vulnerable build.
2026-07-25 13:38:12 +10:00
Filipe Casal 7053485ac1 sender: anchor absolute relative-source cleanup at root 2026-07-25 13:38:12 +10:00
Andrew Tridgell 05bd16a469 daemon: refuse peer values holding shell syntax in shell hooks
Context-aware quoting is only correct for one level of shell parsing.  A
hook may re-parse the substituted word in a nested shell:

    pre-xfer exec = sh -c 'printf %s %RSYNC_USER_NAME% >out'

The level-1 quotes are removed before the inner shell sees the value, so
an authenticated peer's username still reaches it as syntax however
carefully it was escaped.  Escaping cannot fix this; refuse instead.

A %RSYNC_*% value substituted into a shell-executed hook (early exec,
name converter, pre-/post-xfer exec) is now rejected if it holds any
character that can become shell syntax in any context: quote, backtick,
dollar, backslash, semicolon, ampersand, pipe, redirection, parenthesis,
or a control character.  Word-splitting and glob characters are left
alone -- they cannot execute anything and paths legitimately contain
them.  The refusal is fail-closed and logged: a hook may be an access
check, so silently skipping it is not an option.

Also fix the quote tracker itself, which moved to SHELL_SINGLE_QUOTED on
an apostrophe even inside "...", where it is an ordinary character.  That
made a value in `printf %s "it's %RSYNC_USER_NAME%"` escape for the wrong
context.  With the refusal above this is defence in depth, and it matters
if the refused set is ever narrowed.

The two existing hook-injection tests asserted that a metacharacter value
was quoted and the transfer still succeeded; both now expect the refusal.
2026-07-25 11:48:40 +10:00
Filipe Casal 4b4c6809ed daemon: quote hook expansions for their shell context 2026-07-25 11:48:40 +10:00
Andrew Tridgell 0293df8a81 github: register the platform-gated partial-retry tests as expected skips
partial-protected-regular-retry-policy is Darwin-only and its new Linux
twin is Linux-only, so each skips on the other's platforms; the Linux one
also skips under check29, which cannot negotiate CF_INPLACE_PARTIAL_DIR.
None of that was in any RSYNC_EXPECT_SKIPPED list, which made every Linux
and Cygwin cell report a skip mismatch.
2026-07-25 10:38:42 +10:00
Andrew Tridgell b3a560061b testsuite: cover the Linux EACCES recovery arm via LD_PRELOAD
The existing partial-dir recovery test only runs under dyld interposing,
so it skips everywhere except Darwin -- and the fs.protected_regular
compatibility retry it is meant to cover sits inside "#ifdef linux",
which Darwin never compiles.  That arm therefore had no coverage on any
platform.

Add a Linux twin driven by LD_PRELOAD: hook open/openat to model the
EACCES on the O_CREAT open of the existing partial leaf and swap the
partial dir for a symlink in the recovery window, and hook fstatat (with
an __fxstatat fallback for glibc < 2.33) to model the swap as foreign-
owned so the ownership walk refuses it.  Between the two tests both
recovery arms are now covered.

The staging path needs one_inplace, i.e. the protocol-30
CF_INPLACE_PARTIAL_DIR capability, so skip below that rather than fail a
control the older protocol can never satisfy.

Order the assertions so the escape is reported before the ownership-walk
control: a vulnerable build runs no walk at all, and that must read as an
escape rather than an inconclusive result.  Apply the same ordering to
the Darwin test, whose foreign-owner marker was built but never asserted.
2026-07-25 10:38:42 +10:00
Andrew Tridgell 0bfcd3b0f2 syscall: honor operator_path_resolve in do_chmod_at/do_lchown_at
Every other mutating do_*_at() wrapper (unlink, symlink, link, mknod,
rmdir, open, mkdir, rename) resolves an operator-supplied path through
owner_walk_parent() when operator_path_resolve is set.  do_chmod_at()
and do_lchown_at() did not look at the flag at all, and both hand an
absolute name straight to the unconfined full-path do_chmod()/do_lchown().

set_file_attrs() is called with operator_path_resolve = 1 precisely so
that "a flipped temp-dir parent then can't redirect the chmod/chown"
(rsync.c).  With no held dirfd -- an absolute --temp-dir or
--partial-dir -- both fell back to these two wrappers, so that promise
did not hold.  Give them the same ownership-walk branch the others use.

S_ISLNK(mode) still takes do_chmod()'s lchmod()/setattrlist() path.

The missing branch was spotted by Omar Elsayed in review on the
partial-dir EACCES recovery PR, together with the fix approach.

Suggested-by: Omar Elsayed <omarelsayed161@gmail.com>
2026-07-25 10:38:42 +10:00
Filipe Casal a646ded755 receiver: retain partial-dir policy across EACCES recovery 2026-07-25 10:38:42 +10:00
Andrew Tridgell 470cb86bdf sender: honor the do_*() guards when removing a source file
secure_remove_source_file() called unlinkat() directly, dropping the
dry_run no-op and the read-only/list-only refusal that do_unlink()
applies on the non-fd path.  That is what let --only-write-batch (which
implies dry_run) really delete the source files once a MSG_SUCCESS
reached the sender.  Use do_unlink_atfd(), which carries both guards.
2026-07-25 09:12:57 +10:00
Codex c0e6948d0f receiver: do not acknowledge batch-only files as installed 2026-07-25 09:12:57 +10:00
Codex 8367407f98 support: retain Python 3.7 compatibility 2026-07-25 06:57:21 +10:00
Codex b471a29888 authenticate: build without O_CLOEXEC 2026-07-25 06:45:26 +10:00
Andrew Tridgell 7b16872eff syscall: silence scan-build dead-store in do_fchmodat_nofollow fallback
When neither AT_FDCWD nor AT_SYMLINK_NOFOLLOW is available, the function body is
a no-op warning that never reads mode or dfd, so the leading 'mode &= CHMOD_BITS'
became a dead store and dfd an unused parameter -- which the pinned clang-18
scan-build gate flags (deadcode.DeadStores).  Move the mask inside the
AT_SYMLINK_NOFOLLOW guard where mode is actually used, and mark dfd/mode used in
the fallback.  No behavior change on any platform that has the symlink-safe
primitive.
2026-07-24 16:01:48 +10:00
Andrew Tridgell 7aea9d5f8e github: register dot-dir delete-scope tests as expected skips
malicious-dot-dir-delete-scope and peer-legacy-implied-delete-scope both need a
real TCP socket (require_tcp), so they skip on the pipe and protocol check
passes.  Add them to RSYNC_EXPECT_SKIPPED for the check/check30/check29 steps so
the CI skip-set matches.  (The squash-merge of the dot-content-scope fix dropped
this registration.)
2026-07-24 15:22:58 +10:00
Codex db380b62ac flist: keep synthetic and legacy implied parents non-content 2026-07-24 15:14:37 +10:00
Andrew Tridgell 4ce6d097fd github: register dot-file transfer-root tests as expected skips
daemon-dot-file-force-wipe and malicious-dot-file-delete-scope both need a real
TCP socket (require_tcp), so they skip on the pipe and protocol check passes.
Add them to RSYNC_EXPECT_SKIPPED for the check/check30/check29 steps so the
fleet skip-set matches.
2026-07-23 15:39:14 +10:00
Codex 21fade1bbb flist: reject non-directory transfer-root entries 2026-07-23 15:39:14 +10:00
Andrew Tridgell ab4e81d749 github: register malicious-server-partial-basis-symlink-overwrite as an expected skip
The new test needs a real TCP socket (require_tcp), so it skips on the pipe and
protocol check passes.  Add it to RSYNC_EXPECT_SKIPPED for the check/check30/
check29 steps so the fleet skip-set matches.
2026-07-23 14:47:21 +10:00
Andrew Tridgell fd86492913 receiver: only reject unconfined partial basis when in-place partial is active
The daemon rejection for a peer-selected FNAMECMP_PARTIAL_DIR basis that the
confined open declined fired at every protocol.  In-place partial updates are
only negotiated at protocol 30+ (CF_INPLACE_PARTIAL_DIR); at protocol 29 no
partial-basis redirect is possible, and the receiver already handled such a
transfer safely by completing it with no basis.  Gate the abort on
inplace_partial so a pre-30 daemon falls back to the safe no-basis path instead
of aborting a legitimate transfer with a protocol error.

Fixes operator-path-partial-dir-daemon at protocol 29.
2026-07-23 14:47:21 +10:00
Codex cfd40f55cb receiver: confine peer-selected partial basis paths 2026-07-23 14:47:21 +10:00
Andrew Tridgell de9000eb30 testsuite: add files-from-leak module-confinement test
Differential test for the daemon files-from/backup-symlink out-of-module read.
It races a --backup-dir push against a parent-swap flipper until a root-owned
backup symlink to an out-of-module secret lands in the backup tree, then tries
--files-from=:backup/sub/<name> and fails if the secret's content is read back
as the file list.  RED before the module-root confinement, GREEN after.

Requires root plus an untrusted uid to plant the cross-uid symlink; skips
otherwise.  Registered in the Cygwin expected-skip list.

Based on a report and proof-of-concept test by seks99x.
2026-07-23 08:28:36 +10:00
Andrew Tridgell 3fe1ed512c rsync: confine the daemon files-from open to the module root
A daemon serving a writable, non-chrooted module reads a client-requested
--files-from=:LIST through open_no_attacker_symlinks(), which follows a
symlink owned by uid 0 or the euid.  The module-root confinement in that
resolver (abspath_excluded_by_module) only fires when operator_path_resolve
is set, and this open left it clear -- so a trusted-owned symlink whose
target escapes the module was followed.

An attacker can obtain such a symlink without owning it: a --backup-dir push
makes the daemon back up the old destination symlink with the daemon's own
(root) ownership, and a parent-swap race can leave that root-owned backup
symlink pointing outside the module.  A later --files-from=:backup/... then
reads out-of-module file content as the file list, bypassing the same-uid
ownership constraint that normally protects files-from.

Set operator_path_resolve around the files-from open so the ownership walk
also refuses a trusted-owned symlink that redirects the list outside the
module root.  A daemon has no rsyncd.conf "files from" of its own, so this
path is always client-requested and confining it is unconditional.  No-op off
a daemon (the module-root check only fires when am_daemon).

The same ownership-walk opener backs the daemon merge/--exclude-from reads in
exclude.c, but those also load the module's own "include from"/"exclude from"
admin files, which on a non-chrooted module may legitimately live outside the
module; confining them there needs a client-vs-admin distinction and is left
to a separate change.
2026-07-23 08:28:36 +10:00
Andrew Tridgell 3b826d6683 github: register basis-xname-traversal in the Cygwin expected-skip list
The basis-xname-traversal test builds an instrumented sender via
build_patched_rsync(), which skips on Cygwin (coarse NTFS mtimes leave the
patched unit unbuilt, and forcing the rebuild trips -fno-common relinks). Add
it to the Cygwin RSYNC_EXPECT_SKIPPED set so its clean skip there is expected
rather than a skip-mismatch.
2026-07-22 14:50:55 +10:00
Andrew Tridgell de6ed4724d rsync: sanitize the peer-supplied basis xname on the client too
read_ndx_and_attrs() sanitized the wire-supplied xname (the alternate-basis
leaf name sent with ITEM_XNAME_FOLLOWS) only when sanitize_paths was set,
which is the daemon side. A client receiver has sanitize_paths == 0, so a
malicious server could send an xname containing ".." and, joined to an
operator basedir (--link-dest / --compare-dest / --copy-dest, or the fuzzy
dir), have the client open an out-of-tree file as the delta basis -- a
client-side arbitrary-read / file-existence-oracle / FIFO-hang. The ownership
walk in secure_basis_open() does not stop this: it deliberately follows a
plain ".." to a regular file (the legitimate --link-dest=../01 sibling, #915)
and only refuses foreign-owned symlink components.

Sanitize xname unconditionally. The operator basedir may legitimately be
relative, but the leaf name that arrives over the wire never legitimately
needs ".." or a leading "/".

Reported by z3r0s.
2026-07-22 14:50:55 +10:00
Andrew Tridgell d978342145 testsuite: add basis-xname-traversal RED test
Builds a malicious daemon-sender (env-gated xname injection patched into
sender.c) and pulls with --link-dest through the production receiver. A FIFO
one level above the link-dest dir, plus a helper blocked in open(O_WRONLY),
detects whether the receiver opened the traversed "../secret" basis. RED on
an unsanitized-xname receiver, GREEN once xname is sanitized.
2026-07-22 14:50:55 +10:00
Andrew Tridgell 594ab1e194 github: register msg-io-timeout-overflow in the Cygwin expected-skip list
The test builds a -fwrapv rsync via build_patched_rsync(), which skips on
Cygwin, so mark its clean skip there as expected.
2026-07-21 15:38:37 +10:00