Commit Graph
7651 Commits
Author SHA1 Message Date
Andrew Tridgell ee514432e9 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>

(cherry picked from commit 0bfcd3b0f2)
2026-08-02 21:24:12 +10:00
Filipe Casal 7e7db68be0 receiver: retain partial-dir policy across EACCES recovery
(cherry picked from commit a646ded755)
2026-08-02 21:23:59 +10:00
Codex 84cfcce27e receiver: do not acknowledge batch-only files as installed
(cherry picked from commit c0e6948d0f)
2026-08-02 21:23:59 +10:00
Codex d463466ce8 authenticate: build without O_CLOEXEC
(cherry picked from commit b471a29888)
2026-08-02 21:23:59 +10:00
Andrew Tridgell 8b506aeac9 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.

(cherry picked from commit 7b16872eff)
2026-08-02 21:23:59 +10:00
Codex 8371da580d flist: keep synthetic and legacy implied parents non-content 2026-07-24 16:40:15 +10:00
Codex b64f6972e5 flist: reject non-directory transfer-root entries 2026-07-24 16:40:15 +10:00
Andrew Tridgell f15aaf7df4 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-24 16:40:15 +10:00
Codex 64f212e051 receiver: confine peer-selected partial basis paths 2026-07-24 16:40:15 +10:00
Andrew Tridgell 9b15300efa 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-24 16:39:13 +10:00
Andrew Tridgell 7821796b58 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-24 16:39:13 +10:00
Andrew Tridgell 2989988526 io: make set_io_timeout() arithmetic overflow-safe
io_timeout can reach INT_MAX -- from an operator --timeout (options.c parses it
as a plain int, unbounded and even negative) or a peer's MSG_IO_TIMEOUT (now
also capped at 86400 in read_a_msg).  Several signed computations then misbehave:

 * allowed_lull = (io_timeout + 1) / 2 overflows to a negative allowed_lull /
   select_timeout; select() then returns EINVAL on the negative tv_sec, which
   isn't EBADF, so the read loop spins at 100% CPU forever (io_timeout ~= 68
   years never fires check_timeout), plus a keepalive flood.  Compute
   ceil(io_timeout/2) in a wider type so "+ 1" cannot overflow.

 * the generator and sender derive an int loop-check limit as allowed_lull * 5
   (generator.c, sender.c), which overflows for a large allowed_lull.  Cap
   allowed_lull so that product stays in range -- invisible to real use, as
   allowed_lull is the keep-alive half-interval and INT_MAX/5 seconds is over
   13 years.

 * a negative --timeout drove allowed_lull / select_timeout negative the same
   way; treat secs < 0 as "no timeout" up front.

The overflows are undefined behaviour, so plain -O2 gcc/clang happen to keep
select_timeout at 60, but -fwrapv / -fno-strict-overflow (common hardening) wrap
to the spin and -ftrapv aborts.

Reported by z3r0s.
2026-07-24 16:39:13 +10:00
Gogs 00e09a3eba io: cap MSG_IO_TIMEOUT value to prevent signed integer overflow
A malicious server can send MSG_IO_TIMEOUT with val near INT_MAX
(0x7FFFFFFF). The existing val <= 0 guard prevents timeout disabling,
but a large positive value passes through to set_io_timeout() where
(io_timeout + 1) / 2 overflows signed int, wrapping allowed_lull and
select_timeout negative. Every subsequent select() returns EINVAL
immediately, trapping the client in a tight CPU loop.

Cap the accepted timeout at 86400 seconds (24 hours), which is well
above any practical timeout and avoids the overflow in set_io_timeout().

Reported-by: z3r0s <https://github.com/z3r0s6>
2026-07-24 16:39:13 +10:00
Stuart Inglis bc2faf8275 match: bound the hash_search() chain walk (issue #217)
hash_search() walks the entire hash-table chain for the current rolling
checksum at every byte offset of the source file. Disk and VM images
contain large runs of identical blocks, so a single weak checksum
(get_checksum1) can collide thousands of times and pile every one of
those blocks onto one chain. When the sender then rolls across a region
whose weak checksum keeps landing on that chain without ever producing a
strong-checksum match, it re-walks the whole chain for every byte, giving
O(file_size * chain_length) behaviour. The result is rsync sitting at
100% CPU for hours with no apparent progress -- the long-standing "rsync
hangs on large files" reports.

Cap the number of same-weak-checksum candidates examined per offset at
MAX_CHAIN_LEN. Once the cap is hit we treat the offset as a non-match and
roll forward a byte; any block skipped this way is simply sent as literal
data, so the transferred result is always correct -- only the transfer
size is marginally affected. This is purely a sender-side search limit:
it changes no checksum, emitted byte, or protocol field, so a capped
sender interoperates with an unmodified receiver and vice versa.

On a synthetic 40000-block basis sharing one weak checksum, syncing a
60KB source dropped from ~18.4s to ~0.7s; the unbounded cost grows with
the square of the file size.

testsuite/hashsearch-chain_test.py reproduces the pathology with a tiny
basis of weak-checksum-colliding decoy blocks and asserts, via the
existing false_alarms counter (--debug=deltasum1), that the per-hash-hit
chain walk stays bounded. The assertion is exact and machine-independent
rather than timing-based.
2026-07-20 14:51:18 +10:00
Andrew Tridgell 45318baab1 chmod: a+s must set both setuid and setgid
parse_chmod()'s 'a' clause set the "where" bits but not topbits, so "a+s" fell
through to setuid only and dropped setgid (chmod(1) sets both).  Add
S_ISUID|S_ISGID to topbits for 'a'.

Reported-by: Leonid Bugaev
(cherry picked from commit 0d0e505902)
2026-07-20 14:51:18 +10:00
Andrew Tridgell e7a454ea9c log: escape control chars written to the log file
logit() wrote the message to the log file with a raw fprintf, so an
attacker-controlled filename could inject terminal escapes that an admin later
executes when cat'ing the log (CWE-117).  Route it through filtered_fwrite(),
keeping the trailing newline raw.  Also escape C1 controls (0x80-0x9f, incl CSI)
on filtered_fwrite's use_isprint=0 path, which previously caught only C0.

Reported-by: Leonid Bugaev

The C1 escaping is gated by an escape_c1 flag set only for the log path, so
--8-bit-output / iconv terminal output still passes 8-bit bytes (incl. UTF-8)
through unchanged.

(cherry picked from commit ad64e99590)
2026-07-20 14:51:18 +10:00
Omar ElsayedandAndrew Tridgell 27a0de79b9 syscall: confine operator paths in do_symlink_at and do_rmdir_at
do_symlink_at() and do_rmdir_at() were the only operator-path syscall
wrappers still missing the operator_path_resolve branch that
do_mknod_at()/do_open_at()/do_rename_at()/do_unlink_at() already carry:
an absolute operator path (e.g. an absolute --backup-dir) took the
'*path == "/"' arm straight to the bare do_symlink()/rmdir(), whose
libc path resolution follows a parent-component symlink.  A local
attacker who owns a parent component of the backup tree could thus
redirect a backup symlink creation or a backup-dir rmdir outside the
intended tree.

Route both through owner_walk_parent() + symlinkat()/unlinkat() when
operator_path_resolve is set, mirroring the existing wrappers, so a
foreign-owned parent component is refused while the operator's own is
followed.  symlink_optout_allowed() (--insecure-links / 'insecure
links =') restores the legacy following.

Tests: operator-path-backup-symlink and operator-path-backup-rmdir
drive a live parent-component swap (native C flipper) against a local
--backup-dir push and confirm nothing escapes the backup tree (RED
before, GREEN after).

Co-authored-by: Andrew Tridgell <andrew@tridgell.net>
2026-07-20 14:20:17 +10:00
Andrew Tridgell f96466c8fb log: don't parse the second '%' of '%%' as a new format escape
log_formatted() renders %% as a literal '%', but log_format_has() still
rescanned the literal '%' as the start of a new escape, so a format such
as --out-format='%%i' misdetected the 'i' and turned on itemizing (and
'%%b'/'%%c'/'%%C' likewise perturbed log_before_transfer and checksum
retention).  Skip the literal so both parsers agree.

Extends the ki58 test: an attribute-only change must not be logged under
--out-format='%%i %n', while a transferred file still renders the
literal '%i'.
2026-07-20 14:19:24 +10:00
Andrew Tridgell fb471a6490 testsuite: correct the ki62 comment about MSG_IO_ERROR coverage
The header claimed protocol-level crafting of MSG_IO_ERROR is covered by
msg-io-* crafted-server tests; no such test exists (msg-io-timeout-zero
crafts MSG_IO_TIMEOUT).  Say what the test actually locks down.
2026-07-20 14:19:11 +10:00
Andrew Tridgell c998481d1e util1: drop null-tests on robust_rename's from/to args
Both callers pass non-null from/to, but the 'to &&' test taught the
clang analyzer that to may be null, and it then walked a null to
through copy_file -> unlink_and_reopen -> robust_unlink into glibc's
nonnull-annotated strlcpy, failing the scan-build gate.  The args are
required non-null, so test the first byte directly.

(cherry picked from commit c53d107f97)
2026-07-20 14:19:04 +10:00
Andrew Tridgell d29344b3fe backup: fail closed when a symlink target is unreadable
The --safe-links guard on the backup hard-link fast path only skipped the
backup when do_readlink() succeeded (llen > 0) and the target escaped.  A
failed readlink (e.g. the link vanished between the lstat and the
readlink) fell through to link_or_rename(), which could hard-link the
symlink into the backup area unchecked -- the same bypass the guard was
added to close.

Fail closed: skip the backup when the target can't be read.

Extend the KI-72 test with a safe (in-tree) symlink case to confirm the
guard doesn't over-block and drop legitimate safe symlinks.
2026-07-20 14:18:55 +10:00
Andrew Tridgell 61fa7299cf flist: mask peer-supplied io_error to defined bits
recv_file_list() OR's the wire-supplied end-of-list error value straight
into the local io_error at three sites (the varint-flags path, the
XMIT_IO_ERROR_ENDLIST byte path, and the protocol < 30 int flag).  Like
the MSG_IO_ERROR path fixed in io.c, a malicious peer could set arbitrary
undefined bits, which then accumulate in io_error and can be re-forwarded
to other peers.

Mask each with IOERR_VALID_MASK so only the defined IOERR_* bits survive,
matching the io.c MSG_IO_ERROR handler.

(cherry picked from commit 9da5b5450f)
2026-07-20 14:18:38 +10:00
Andrew Tridgell ded4fb157e wildmatch: fold bracket-expression pattern chars under force_lower_case
dowild()'s literal path folds the pattern char, but the [class]/[a-z] path
compared unfolded pattern bytes against the (folded) text, so iwildmatch() was
still case-asymmetric for character classes and ranges -- e.g. a daemon
'hosts deny = [A-Z]*.EVIL.COM' failed to match a lower-case host (access-control
fail-open, same class as the literal case).  Fold p_ch for the escaped-member,
range-endpoint and plain-member comparisons; the range start (prev_ch) picks up
the folded value too.  Only active under force_lower_case (iwildmatch), so
case-sensitive wildmatch() is unchanged.

Reported-by: Leonid Bugaev
(cherry picked from commit d443e8dc93)
2026-07-20 14:18:38 +10:00
Andrew Tridgell b1958362ed util1: confine operator paths in the robust_rename EXDEV fallback
The cross-filesystem fallback copied to the dest and unlinked the source without
operator-path confinement, so an absolute --temp-dir/--partial-dir on another
filesystem was opened/unlinked via plain libc -- a raced parent symlink could
redirect the dest-write or source-unlink out of the module.  The source READ was
already confined; flip operator_path_resolve for an absolute (operator) path
around the copy_file dest-open and the do_unlink_at, leaving relative in-module
paths on the secure_relative_open arm.

This is the EXDEV-fallback backstop for the same absolute --partial-dir /
foreign-owned-parent-symlink escape that operator-path-partial-dir_test.py
already exercises at the handle_partial_dir() layer (which confines the staging
dir before this code runs).  A dedicated test for the EXDEV copy_file path itself
would need the main tmp->final rename to fail first and then hit EXDEV on a
foreign-owned raced parent -- a nested, timing-dependent trigger.

Reported-by: Leonid Bugaev
(cherry picked from commit fd4c75116b)
2026-07-20 14:18:29 +10:00
Leonid Bugaev 60671e3db2 Mask incoming MSG_IO_ERROR to defined bits only
A peer-supplied MSG_IO_ERROR value was OR'd into the local io_error
without masking, allowing a malicious peer to set arbitrary bits that
propagate to exit codes and get re-forwarded to other peers.

Fix: mask the incoming value with IOERR_VALID_MASK (IOERR_GENERAL |
IOERR_VANISHED | IOERR_DEL_LIMIT) before OR'ing.

Test: testsuite/ki62-io-error-mask_test.py
2026-07-20 14:18:19 +10:00
Leonid Bugaev 0ac17f5682 Add %% escape to --out-format and --log-file-format strings
The log_formatted() switch had no case for '%', so %% did not
produce a literal percent character.  Add case '%' to output
a single '%' character, matching the printf convention.

Test: testsuite/ki58-log-format-percent_test.py
2026-07-20 14:18:10 +10:00
Andrew Tridgell 6c4ce713a8 util1: fix clean_fname ".." collapse off-by-one
After the backward walk, s points at the first char of the prior component and
s[-1] is its leading '/', so the boundary test must read s[-1] (not *s) and t
must reset to s (not s+1).  The old off-by-one left CFN_COLLAPSE_DOT_DOT_DIRS
dead for all multi-component and absolute paths.  The peer-traversal guard
CFN_REFUSE_DOT_DOT_DIRS is checked first and is unaffected, so this is a
normalization-correctness fix, not a traversal hole.

Reported-by: Leonid Bugaev
(cherry picked from commit fbeb553b73)
2026-07-20 14:17:57 +10:00
Leonid Bugaev cc64f5a7bd Fix spurious abort when CVS .cvsignore contains '!' clear-list token
The CLEAR_LIST guard in parse_rule_tok checked rule->rflags for
FILTRULE_NO_PREFIXES, but NO_PREFIXES is a template-level flag
excluded from FILTRULES_FROM_CONTAINER inheritance.  The guard
was always true for CVS rules, causing RERR_SYNTAX abort instead
of clearing the list.

Fix: check template->rflags instead of rule->rflags.

Regression test: testsuite/ki73-cvs-clear-list_test.py
2026-07-20 14:17:49 +10:00
Leonid Bugaev 58171cf715 Fix bypass of --safe-links when --backup hardlinks a symlink
When CAN_HARDLINK_SYMLINK is defined (Linux, macOS), the backup
hardlink fast path at link_or_rename() succeeded for symlinks and
'goto success' skipped the safe_symlinks check.  An escaping symlink
(pointing outside the transfer tree) was silently preserved in the
backup area despite --safe-links.

Fix: check safe_symlinks BEFORE the hardlink path.  If the symlink
target escapes, skip the backup (same as non-hardlink-symlink systems).

Regression test: testsuite/ki72-safe-links-backup_test.py
2026-07-20 14:17:29 +10:00
Andrew Tridgell b6a388e166 github: gate PR CI on a 'run-ci' label to save CI minutes
Skip PR jobs unless the PR carries the 'run-ci' label.  Applying a
label needs triage access, so a fork PR can't enable the matrix by
itself.  The 'labeled' trigger type is added so applying the label
starts a run immediately; skipped jobs cost no runner minutes.  Push,
schedule and manual dispatch runs are unaffected.

Port of the same change on the 3.5.0 branch.
2026-07-19 18:27:34 +10:00
Andrew Tridgell 9108f520d2 wildtest: use stdbool.h so bool works from C99 through C23
The Cygwin CI runner's autoconf selects gcc -std=gnu23, where bool is
a keyword, so 'typedef char bool' fails with 'two or more data types
in declaration specifiers'.  Guarding the typedef on __STDC_VERSION__
is unreliable across the C2x transition (that compiler reports a
pre-release value below 202311L), so drop the typedef and include
stdbool.h, which works from C99 through C23.
2026-07-19 18:27:21 +10:00
Andrew Tridgell 4dc8e93c7d receiver: don't abort the transfer when a file grows during the run
The sender records each file's length when it scans the file list, then
re-stats and maps the file at its current size when it later sends the
data.  A file appended to in that window -- a live log written during a
nightly backup is the common case -- is transmitted longer than its
flist-recorded length.

The hardening checks in receive_data (offset + i > total_size and
offset + len > total_size) turned that benign, common condition into a
fatal "received more data than file length" RERR_PROTOCOL, tearing down
the whole connection so every file after the growing one is skipped.
Stock rsync has no such check: the receiver writes to a temp file, so
the extra bytes just extend it, and the whole-file checksum-verify
already contains a malicious sender.

Remove both checks to restore the stock behavior.

Backport of the same fix on the 3.5.0 branch; the python regression
test (growing-file_test.py) is not ported, the 3.5.0 suite is the
oracle for these branches.
2026-07-19 18:27:21 +10:00
Vladimir Marek 0c96e12fd6 lib/sysxattrs: make write_xattr more robust
Fix three bugs in the Solaris extended-attribute write loop and harden it:
 - the write() length stayed the full size rather than the remaining
   (size - bufpos), so a short write would re-write from the wrong offset;
 - on a failed or zero-byte write, bufpos = -1 wrapped to SIZE_MAX (bufpos is
   size_t) and the final `bufpos > 0` test then returned success;
 - an empty value (size == 0) returned failure because `bufpos > 0` was false.
Also don't let close() clobber the write error's errno, and report a close()
failure on an otherwise-successful write.
2026-06-30 08:36:22 +10:00
Vladimir Marek d903874d63 acls: implement Solaris facl(2)-based ACL helpers
Solaris has facl(2), which performs ACL operations on an already-open file
descriptor.  This adds Solaris fd-based helpers for the ACL operations that
rsync needs while keeping the existing path-based fallback for callers without
a held fd.

On Solaris, setting an ACL on a directory replaces the combined access and
default ACL set.  The path-based sys_acl_set_file() already handled this by
reading the other half of the directory ACL, merging access and default entries,
and then calling acl(..., SETACL, ...).  The new sys_acl_set_fd_type() preserves
that behavior with fd-based operations: it uses fstat() to identify directories,
reads the other ACL half with sys_acl_get_fd_type(), combines the access and
default entries, marks default entries with ACL_DEFAULT, and finally writes the
combined ACL with facl(2).  Deleting a default ACL similarly fetches the access
ACL through the fd and rewrites only that access ACL with facl(2), which removes
the default ACL without re-resolving the path.

Driving the apply off the held fd also closes a symlink-race on the Solaris ACL
apply (the path-based sys_acl_*file re-resolves the path -- the CVE-2026-53799
class, unfixed on Solaris until now).  Added on integration: for a root receiver
a missing held fd on a confined receiver means the leaf was raced to a symlink
(acl_set_file follows it), so refuse the path-based set/delete rather than apply
the ACL to a redirected inode; a plain non-root receiver keeps the path-based
fallback for a legitimately un-pinnable owned leaf (e.g. a 0300 dir), matching
the operator-path op_pin rule.
2026-06-30 08:36:22 +10:00
Andrew Tridgell 9e439251c7 rsync: never path-resolve a confined receiver's xattr/ACL write (copy-xattrs race)
set_file_attrs() pins the entry via the cached held dir fd (held_dfd_for) and
drives the xattr/ACL ops off that fd (fsetxattr).  But when the pin missed --
held_dfd_for() returns -1 (the path is deeper than the dirfd cache, or its dir
isn't the held one), or the leaf openat() loses a race -- held_fd stayed -1 and
set_stat_xattr()/set_xattr()/get_acl_fdat()/set_acl_fdat() fell through to the
path-based branch (sys_lsetxattr(fname,...)).  Unlike the chmod/chown/times path
wrappers (which secure-resolve), that raw lsetxattr re-resolves the parent, so a
concurrent flip of a dest parent component to a symlink->outside lands the xattr
OUTSIDE the destination tree (the intermittent copy-xattrs-symlink-race escape
that surfaces under -j load, which widens the open->setxattr window).

Re-pin through secure_relative_open() when the cached pin misses on a confined,
non-operator receiver path, so the xattr/ACL ops always use a confined fd -- NOT
a raw path lsetxattr; if the re-pin also fails (a genuinely raced parent/leaf
symlink) skip the path-based ops (xattr_refuse) rather than redirecting them.
The re-pin passes O_DIRECTORY for a directory leaf.  Apply the same re-pin/refuse
to gen_entry_copy_xattrs() (the dir xattr copy), whose dfd<0 path likewise fell
to copy_xattrs() with dest_fd==-1.  chmod/chown/times are unchanged (confined via
their *at wrappers); operator paths keep op_pin/op_refuse.
2026-06-28 08:57:01 +10:00
Andrew Tridgell 5d84ed23c3 util1: declare insecure_links extern for the change_dir opt-out (backport adapt) 2026-06-27 18:23:25 +10:00
Andrew Tridgell 8cbfd8a434 xattrs/backup: read source metadata through a held fd, not a path
The hardened receiver confined the destination side of an xattr/ACL copy (the
fsetxattr/acl_set_fd through a held O_NOFOLLOW fd) but still read the SOURCE side
by path: copy_xattrs() did get_xattr_names/get_xattr_data on the source path, and
make_backup() cached the backed-up file's ACL/xattr via get_acl()/get_xattr() by
path.  A local module writer could race the source/basis parent to a symlink
after the confined content/stat open and before that path-based metadata read,
so out-of-module xattrs/ACLs got copied onto an in-module destination or backup.

Thread a source fd through the read side, mirroring the existing dest-fd plumbing:
 - get_xattr_data(), get_xattr() and get_xattr_acl() gain an fd arg (get_xattr_names
   already had one) and use sys_fgetxattr/sys_flistxattr when fd >= 0; this also
   covers the --fake-super ACL-as-xattr read in get_rsync_acl().
 - copy_xattrs() gains a source_fd; copy_file() passes its held source fd (ifd)
   and keeps it open across the xattr copy (closing it on the fsync error path
   too); gen_entry_copy_xattrs() O_NOFOLLOW-opens the basis leaf under the
   confined resolver (with O_DIRECTORY for a directory basis) and passes it.
 - make_backup() pins the source leaf with a confined O_NOFOLLOW fd
   (backup_source_fd, like set_file_attrs's op_leaf_fd) and reads its ACL via
   get_acl_fdat() and its xattrs via get_xattr(fd); the in-place delta-backup in
   the generator pins fname the same way.  On a hardened receiver a raced/absent
   leaf skips the cache rather than reading through a flippable path.

Non-hardened receivers (fd < 0) keep the path-based behaviour unchanged.  The
basis COMPARE reads (the generator deciding a match) stay path-based: they never
copy out-of-module metadata onto a file, so they are not part of this sink.
2026-06-27 18:22:33 +10:00
Andrew Tridgell 8778f3f9e3 rsync: pin cross-tree fake-super metadata writes to a confined fd
set_file_attrs() pins a cross-tree operator leaf (an absolute --temp-dir /
--backup-dir / --*-dest path) with an O_NOFOLLOW fd (op_leaf_fd) and drives
chmod/chown/xattr/ACL/times off it so a flipped parent can't redirect them, but
the pin was opened only when am_root >= 0.  A daemon module with "fake super =
yes" runs with am_root < 0, so the cross-tree leaf kept op_leaf_fd/held_fd == -1
and the fake-super %stat, preserved-xattr and ACL-as-xattr writes fell back to
path-based sys_lsetxattr(): a local module writer racing the staging parent to a
symlink could redirect those metadata writes outside the module
(CVE-2026-53799 residual on the fake-super path).

Open the pin for fake-super too -- it has nothing to do with privilege: the
daemon owns the freshly-staged leaf it is about to set metadata on, so any
O_NOFOLLOW open failure is a race and is refused rather than redirected through a
re-resolvable path.  Every metadata op already routes through
held_fd/op_leaf_fd/op_refuse, so they all become fd-based; strace confirms the
cross-tree fake-super write uses fsetxattr()/fchmod(), never the l-variant.
2026-06-27 18:22:08 +10:00
Andrew Tridgell 5a15ea8fda daemon: make "insecure links = yes" fully restore 3.2.7 symlink following
The opt-out is meant to restore stock-3.2.7 "follow existing symlinks in the
module" behaviour, but several daemon symlink-resolution sites called the
confined resolver unconditionally and never consulted symlink_optout_allowed(),
so a module with "insecure links = yes" still refused to follow a symlinked
directory (change_dir ELOOP) or an alt-dest basis.  Gate every such site on the
opt-out -- follow plainly like 3.2.7 when set, confine otherwise:

  - change_dir() relative daemon branch (util1.c): plain chdir() under the
    opt-out instead of secure_relative_open(), so a peer can read a path through
    a symlinked directory again.
  - basis_link_stat() (generator.c): the chrooted-relative and non-chroot
    branches honour the opt-out.  The non-chroot ABSOLUTE branch (a basis rooted
    under the module by check_alt_basis_dirs, which can reach an in-module
    symlink) is, when NOT opted out, resolved through owner_walk_parent so a
    target landing outside the module root is refused -- closing a confirmed
    --compare-dest=/symlink out-of-module read oracle.  The leaf is taken
    O_NOFOLLOW under the confined parent so --copy-links can't follow a leaf
    symlink out; --fake-super folds its %stat via the held fd.  A relative
    sibling basis (--link-dest=../01) keeps the plain path (#915/#930).
  - secure_basis_open() (receiver.c): plain do_open() under the opt-out.
  - use_secure_symlinks (clientserver.c): cleared under the opt-out, so the
    receiver's protected-regular EACCES write fallback is legacy too.
  - secure_sender_parent_fd() (sender.c): declines (errno=0) under the opt-out
    so --remove-source-files re-stats the plain path.

Default modules (insecure links = no) are unchanged and stay fully confined;
the opt-out is per-module and a client cannot enable it.
2026-06-27 18:22:08 +10:00
Andrew Tridgell b1b3ad45e5 syscall: drop the dead name_is_dir arg from abspath_excluded_by_module
abspath_excluded_by_module() is module-ROOT confinement only by design (the
daemon name/exclude filter is name-based visibility, not a physical-path
boundary -- munge symlinks is that defense), so its name_is_dir parameter was
always discarded ((void)name_is_dir).  Remove it, and the now-redundant
absent-leaf second call in owner_walk_parent()'s leaf check that re-tested the
SAME leafabs with name_is_dir=1 for an identical result, plus the
fstatat()/isdir/absent that only fed the ignored logic.  Behavior-preserving
(the refuse decision is unchanged at every call site); drops one incidental
lstat.

(Leonid Bugaev May-2026 re-audit, KI-49 -- dead defense-in-depth arm, no escape.)
2026-06-27 18:18:53 +10:00
Andrew Tridgell b3e558d1ba io, main: make the SIGUSR2 handler async-signal-safe
sigusr2_handler called output_summary() (rprintf/vsnprintf/fwrite/iconv/malloc)
and close_all() (fstat/shutdown/close) directly from signal context before
_exit().  SIGUSR2 is sent by the generator/parent to tell the receiver child to
print its summary and exit; if it interrupted the receiver while it was inside
malloc/stdio, the handler re-entered those and could deadlock or corrupt.

The handler now only sets a flag (got_sigusr2, a volatile sig_atomic_t); the
actual summary + shutdown moves to receive_sigusr2(), run from a safe point in
the receiver's post-transfer wait paths:
 - the perform_io() flag checks (next to got_kill_signal);
 - the safe_read()/safe_write() loops (which a --read-batch / --write-batch fd
   uses without going through perform_io);
 - the whine_about_eof() kluge loop, where the receiver waits out the race of
   the sender dying before the kill-signal arrives -- this loop polls for the
   signal, so without the flag check it slept the full 10s and then errored with
   RERR_STREAMIO instead of exiting cleanly;
 - the trailing `while (!got_sigusr2) msleep()` loop in do_recv().

(Leonid Bugaev May-2026 re-audit, KI-14.)
2026-06-27 18:18:53 +10:00
Andrew Tridgell 13d023e617 hashtable, flist: fix integer overflows in size computations
hashtable_create() and the grow path computed the slot-array byte count as
new_array0(char, size * node_size) in 32-bit int arithmetic; for a large
peer/data-driven size the product wrapped to a tiny value, bypassing my_alloc's
--max-alloc guard (which only saw the already-wrapped count), so the table was
under-allocated while tbl->size kept the huge size -- a later node access then
ran out of bounds (heap overflow; ASan-confirmed).  Pass size and node_size as
SEPARATE factors so my_alloc checks both before multiplying; guard each *2
doubling against int overflow BEFORE it happens; make HASH_LOAD_LIMIT divide
before multiplying; promote the HT_NODE index multiply to size_t; and test
size < 16 first so a negative req short-circuits the size-1 (INT_MIN UB).

flist_expand()'s int growth math (used+extra and *=4 / *=2 / += FLIST_LINEAR)
could overflow past INT_MAX on a very large file list; guard each operation
before it overflows and refuse rather than under-size the realloc.

(Leonid Bugaev May-2026 re-audit, KI-11/12/13.)
2026-06-27 18:18:11 +10:00
Andrew Tridgell f66eb4ca9a copy_file: confine an absolute operator source via the ownership walk
copy_file() routed a RELATIVE source through secure_relative_open (parents
confined) but opened an ABSOLUTE source -- an operator basis such as an absolute
--copy-dest -- with bare do_open_nofollow, which refuses only a leaf symlink and
follows every parent.  basis_link_stat() refuses a foreign-owned basis at stat
time, but a parent flipped to a foreign symlink between that stat and this open
redirects the basis read out of tree (an out-of-tree content read-leak into the
destination; RED on 3.4.x, GREEN here).

Resolve an absolute source's parents through owner_walk_parent (foreign-owned
parent symlink refused, operator's own dirs/uid0/euid symlinks followed).
operator_path_resolve is set only across the walk -- so module-exclude is
enforced -- and restored, leaving the caller's value for the dest side; that is
why confining the source here does not re-open the copy_xattrs dest race that
wrapping the whole copy_altdest_file would (copy-xattrs-symlink-race stays green).

(Leonid Bugaev May-2026 re-audit, KI-46.)
2026-06-27 18:18:10 +10:00
Andrew Tridgell 63fbf31890 io: reject a non-positive MSG_IO_TIMEOUT from the peer
A peer may use MSG_IO_TIMEOUT only to ask us to adopt a SHORTER I/O timeout
(a stricter cap).  A crafted server sending val <= 0 would instead zero the
client's --timeout via set_io_timeout(0), disabling it entirely and letting the
server hang the client indefinitely.  Ignore a non-positive value.

(Leonid Bugaev May-2026 re-audit, KI-47; pre-existing since 2009.)
2026-06-27 18:17:30 +10:00
Andrew Tridgell cf4cffcccb confine the remaining cross-tree operator-path syscalls
The backup-dir fix pinned chmod/chown for an operator leaf; extend the same
confinement to every other sink that re-resolves a cross-tree operator path
(an absolute --temp-dir/--partial-dir/--*-dest), each confirmed by a cross-uid
race PoC that is RED on stock 3.2.7 and GREEN here:

- set_file_attrs (rsync.c): route times (do_futimens), and -- by aliasing
  held_fd to the pinned op_leaf_fd -- the xattr/ACL ops through the same
  O_NOFOLLOW leaf fd, not a re-resolvable path.  A raced/refused pin skips them
  (op_refuse) rather than redirecting.  finish_transfer now wraps the pre-rename
  set_file_attrs in operator_path_resolve so an absolute --temp-dir temp file's
  metadata is pinned too (in-tree temps keep their held dirfd, so op_pin is off).

- do_rename_at / do_link_at (syscall.c): an ABSOLUTE side was left at AT_FDCWD
  and followed a flipped parent symlink, letting a name-disclosed --temp-dir /
  predictable --partial-dir rename pull an attacker file into the destination
  (content injection).  Resolve an absolute (operator) side via the ownership
  walk -- with module-exclude enforced -- while a relative (transfer) side stays
  on secure_relative_open.  --insecure-links keeps the legacy path.

- secure_basis_open (receiver.c): an alt-dest basis read
  (--copy-dest/--compare-dest/--link-dest) on a non-daemon receiver used a bare
  do_open; route it through the ownership walk (refuses a foreign-owned basis
  symlink, still allows the "../sibling" basis of #915).  Daemons keep their
  existing confinement branch.

- configure.ac: probe futimens (do_futimens is gated on HAVE_FUTIMENS).

New fd wrappers: do_fchmod, do_fchown, do_futimens.  Documented residuals left
as-is: copy_altdest_file's basis copy (routing it re-opens copy-xattrs-symlink-
race; basis_link_stat already refuses the foreign symlink), crtimes
(do_setattrlist_crtime/do_SetFileTime, path-based on macOS/Cygwin), and
device/socket leaf metadata (pinning a device via open has side effects).
2026-06-27 18:17:30 +10:00
Andrew Tridgell a802ffa58b backup: confine cross-tree operator-path metadata via a pinned fd
A symlink race on a non-daemon --backup-dir let a local attacker redirect
rsync's backup writes and chmods outside the backup tree when rsync runs as
root.  make_backup() sets operator_path_resolve, but set_file_attrs() had no
held parent dir fd for an absolute backup path (held_dfd_for() returns -1), so
its chmod/chown fell through to the path-based wrappers and, for an absolute
path, to raw chmod()/lchown().  copy_valid_path() mirrors the source dir's
attrs onto each backup subdir; when an attacker flips a backup component to a
symlink in that window the raw lchown retags the planted symlink as root-owned
-- laundering it into a "trusted" (uid 0) symlink that the owner-walk then
follows, so the backup rename/chmod escapes the tree.

Pin the leaf inode of a cross-tree operator path with an O_NOFOLLOW open via
the operator owner-walk resolver and drive fchmod/fchown off that fd; a raced
symlink leaf makes the open fail and the op is refused, never redirected.  Gate
on the INTENDED type (new_mode), not the attacker-controlled on-disk type.  As
root any open failure is the race (a real owned leaf never fails); a non-root
operator, which cannot launder a uid-0 symlink, falls back to the legacy path
op on a benign EACCES.  --insecure-links opts back out.

Adds do_fchmod()/do_fchown() fd wrappers.  Residual cross-tree metadata sinks
(times, ACLs, xattrs, and --temp-dir's finish_transfer set_file_attrs) are not
covered here and are tracked for a follow-up.
2026-06-27 18:17:30 +10:00
Andrew Tridgell b98c09a7b5 daemon: insecure links opt-out restores legacy symlink following on the receiver
secure_relpath_active() (the gate that routes receiver-side filesystem ops --
get_dir_fd/dpc, do_*_at, open_tmpfile, make_path, link_stat -- through the
symlink-race-safe resolver) checked only am_daemon/am_chrooted/am_sender, not
the symlink_optout_allowed() opt-out.  So `insecure links = yes` (or a non-daemon
--insecure-links) restored the legacy follow only on the SENDER enumeration
(which checks the opt-out directly), while the receiver still confined writes,
mkdirs, renames, unlinks and stats through a pre-existing in-module symlink --
i.e. the admin opt-out did not actually reproduce the pre-3.4.3 behaviour it
documents (rsyncd.conf(5) "munge symlinks"/"insecure links").

Have secure_relpath_active() return 0 when symlink_optout_allowed(), so the
opt-out uniformly disables the secure resolver on both sides.  No effect on the
default (opt-out off): confinement is unchanged.
2026-06-27 18:16:18 +10:00
Andrew Tridgell cf0c361988 flist/xattrs/generator/uidlist/clientserver: plug audit-reported leaks
Five error-path/cleanup memory leaks found by an external audit:

- flist.c send_file_name: free the ACL loaded by get_acl() when a later
  get_xattr() fails (and on the get_acl error path).
- xattrs.c copy_xattrs: free the xattr datum buffer when the setxattr fails.
- generator.c recv_generator: free real_sx at the cleanup label (the
  directory branch loaded its ACL via set_file_attrs but only the
  regular-file path freed it); zero-init real_sx so the early gotos are safe.
- uidlist.c send_one_list: free the strdup'd id-0 name after send_one_name.
- clientserver.c start_inband_exchange: free modname on the early error
  returns (it was freed only on the success path).

ASan/LSan regression tests cover the generator, uidlist and clientserver
leaks; the flist and xattrs leaks need a forced syscall failure and are
covered by the audit's standalone harnesses.

Reported-by: Leonid Bugaev <leonsbox@gmail.com>
(cherry picked from commit 078f3b99f4004510d418ee9d97d9b775dc8587bc)
2026-06-27 18:15:18 +10:00
Andrew Tridgell bca78c12f8 access: fail closed when a hosts-deny token can't be resolved
match_hostname() did a forward-DNS lookup of a config-specified hostname
token and, on gethostbyname() failure, returned "no match" -- which
allow_access() cannot distinguish from a real non-match, so a daemon with
"hosts deny = <hostname>" silently admitted the host whenever the token
could not resolve (a resolver-less chroot, or a transient DNS failure).
No attacker DNS control required.

Thread a deny flag through access_match()/match_hostname() and, on a
forward-DNS failure, treat an unresolvable DENY-list token as a match so
the connection is refused (fail closed); allow-list tokens still fail as a
non-match. Sibling of CVE-2026-43617, which fixed only the reverse path.

Reported-by: Leonid Bugaev <leonsbox@gmail.com>
(cherry picked from commit 84e469ea03a98ef31326d3d861336d7e7d582ce1)
2026-06-27 18:14:25 +10:00
Andrew Tridgell ea7fc12b9c syscall: do_mknod_at must mkfifoat for an operator-path FIFO on non-Linux
The operator_path_resolve branch of do_mknod_at() called mknodat() and
returned its result directly, without the FIFO/socket fallback that the
bare-path do_mknod() and the secure-relpath branch below it both have.
mknodat() can make a FIFO only on Linux; on the BSDs/macOS/Solaris it fails
with EINVAL, so creating a special file under an operator-supplied path --
e.g. backing up a FIFO into a --backup-dir -- failed there.  Retry race-safely
with mkfifoat() on the held parent dirfd, and fail a nested socket closed
(EOPNOTSUPP) exactly as the secure-relpath path does.
2026-06-23 11:56:51 +10:00