Commit Graph
7545 Commits
Author SHA1 Message Date
Andrew Tridgell e86c51a241 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:21:55 +10:00
Andrew Tridgell c988360e88 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:21:47 +10:00
Andrew Tridgell 5b0fd12291 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:21:47 +10:00
Andrew Tridgell e959de9740 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:21:40 +10:00
Andrew Tridgell ab493f117c 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:21:40 +10:00
Andrew Tridgell bf79a2a01e 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:21:40 +10:00
Leonid Bugaev f87dbdfed2 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:21:30 +10:00
Leonid Bugaev a7b9af72e6 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:21:16 +10:00
Andrew Tridgell 8d1ebde529 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:21:09 +10:00
Leonid Bugaev 3b3406a47c 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:21:09 +10:00
Leonid Bugaev 5b96ceeac2 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:20:53 +10:00
Andrew Tridgell 30a0f674b5 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:10 +10:00
Andrew Tridgell 23c1c716fd 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:26:31 +10:00
Andrew Tridgell bb731517a0 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:26:31 +10:00
Vladimir Marek 2ad7444d33 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:50:42 +10:00
Vladimir Marek 9726ac29fd 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:50:42 +10:00
Andrew Tridgell 2c20cf31dd 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:14 +10:00
Andrew Tridgell 9559460084 util1: declare insecure_links extern for the change_dir opt-out (backport adapt) 2026-06-27 18:24:53 +10:00
Andrew Tridgell 1d913c6453 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:24:32 +10:00
Andrew Tridgell 6802f5cb87 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:24:31 +10:00
Andrew Tridgell d75b493638 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:24:31 +10:00
Andrew Tridgell 5e74081604 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:24:31 +10:00
Andrew Tridgell 34166963ab 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:24:31 +10:00
Andrew Tridgell 399c5041a4 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:24:31 +10:00
Andrew Tridgell ec256152b7 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:24:31 +10:00
Andrew Tridgell 1d775c8c89 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:24:31 +10:00
Andrew Tridgell 433ed0e1db 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:24:31 +10:00
Andrew Tridgell ea9e707b46 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:24:31 +10:00
Andrew Tridgell 4e9285b084 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:24:31 +10:00
Andrew Tridgell 08d2d7b425 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:24:31 +10:00
Andrew Tridgell cb7373c952 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:24:31 +10:00
Andrew Tridgell 78655544c5 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
Andrew TridgellandGreg Kroah-Hartman 8bd74c5fd2 io/xattrs/rsync-ssl: EOF-sentinel guard, xattr ndx guard, gnutls CA refusal
io.c only treats a short read as the EOF sentinel when the fd is still
open; xattrs.c never stores a -1 from find_matching_xattr() and guards
ndx < 0 in set_xattr; rsync-ssl refuses the gnutls backend without
RSYNC_SSL_CA_CERT.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew TridgellandGreg Kroah-Hartman b3119ce493 options/token: bound the -v repetition, output level, and suffix token
Cap -v repetition so the argstr[64] global can't overflow, clamp a
negative --info/--debug level out of counts[], and cap a --skip-compress
suffix token at 32 bytes.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew TridgellandGreg Kroah-Hartman 21c000f64d compat: assign metadata ndx after batch flags; pick own preferred name
Move the uid/gid/acls/xattrs *_ndx assignments past check_batch_flags():
a mismatched-flag batch otherwise wrote F_XATTR(file) at offset 0 of
every file_struct, clobbering file->dirname. parse_negotiate_str() no
longer short-circuits on am_server, so each side picks its own #1 mutual
digest/checksum/compress choice rather than deferring to the peer's
order; man pages updated to match.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew TridgellandGreg Kroah-Hartman 8e03f94076 generator: fix off-by-one length in read_delay_line()
A '!'-prefixed delete-delay entry computed one byte short, dropping the
final character of the name.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew TridgellandGreg Kroah-Hartman 0e45e015e4 hlink/main: guard the F_SUM checksum-slot lifetime
hlink.c must confirm S_ISREG before quick_check_ok(FT_REG,...) reads
F_SUM, and start_server() must set sender_keeps_checksum when a daemon
sender runs -c with a %C log format so make_file() allocates
SUM_EXTRA_CNT. Without these, F_SUM() reads past the pool slot and (for
%C) hex-encodes adjacent heap into the transfer log.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew TridgellandGreg Kroah-Hartman bb62874163 flist/receiver: zero/empty the device & symlink extra slots
recv_file_entry (normal and XMIT_HLINKED abbrev branches) and make_file
left F_RDEV_P / the symlink-name slot uninitialized when the matching
preserve option was off, so a later read walked into adjacent pool
memory. Empty the symlink name when !preserve_links, zero F_RDEV_P when
!preserve_devices, and mirror both in the abbreviated branch. receiver.c
saves/restores the --write-devices S_IFBLK mode flip around receive_data
so dest_mode() never sees the mutated mode.

Co-authored-by: Greg Kroah-Hartman <gregkh@linuxfoundation.org>
2026-06-22 09:22:14 +10:00
Andrew Tridgell 5832599916 main: raise the open-file soft limit so the resolver can walk deep paths
The race-safe path resolver holds one open fd per directory component, so a deep
tree needs more descriptors than the typically-low default soft limit (e.g. 128
on OpenBSD), where it otherwise fails with EMFILE.  Raise RLIMIT_NOFILE's soft
limit toward the hard limit at startup (best-effort, never above the admin hard
cap, never lowering an inherited limit), matching the 3.5.0 branch.  Adds the
getrlimit/setrlimit + sys/resource.h autoconf probes the guard needs.
2026-06-21 19:02:11 +10:00
Andrew Tridgell 17576944f4 docs: NEWS, README and man pages for the 3.5.0 security backport
Document --insecure-links, clarify in rsyncd.conf(5) that exclude=/filter is a
name-based filter and not a symlink boundary, and record the backported fixes.
2026-06-21 18:25:45 +10:00
Andrew Tridgell 2a2ccaa1bf batch/rsync-ssl: quote replay-script args and harden rsync-ssl
Quote and newline-escape the arguments written into the --write-batch replay
script so a crafted filename cannot inject shell, and tighten rsync-ssl's option
handling.
2026-06-21 18:25:45 +10:00
Andrew Tridgell 6d6699ed7f socket: reject control bytes in the proxy CONNECT host
Refuse control characters in the host before building the HTTP CONNECT request
line, so they cannot be smuggled in to inject extra request/header lines.
2026-06-21 18:25:45 +10:00
Andrew Tridgell 3cc137c16c daemon: treat exclude=/filter as a name filter, not a symlink boundary
The daemon exclude/filter chain is a name-based visibility filter, exactly as in
stock rsync: a symlink whose own name is not excluded is still followed to an
excluded target (the documented defense for a writable module is "munge symlinks").
Drop the in-module physical-path refusal from abspath_excluded_by_module() (it now
only confines operator/peer paths to the module ROOT) and restore stock rsync's
NAME-based ".." collapse for the daemon-filter checks on the destination,
basis/partial dirs and --temp-dir/--backup-dir via sanitize_path().
2026-06-21 18:25:45 +10:00
Andrew Tridgell 3ee00e0087 confine operator- and peer-supplied paths to the served module
Route operator-supplied paths (--partial-dir, --backup-dir, alt/copy/link-dest,
--temp-dir, --files-from, --log-file, config and secrets files) and the sender's
own enumeration through the race-safe resolver and an ownership walk that refuses a
target redirected outside the module root.  open_tmpfile() now creates the temp via
the cached held dir fd (do_mkstemp_atfd) so an in-tree dir symlink is followed
safely instead of refused.  Adds --insecure-links to opt back into legacy following;
the daemon hard-refuses the client flag (use the "insecure links" module param).
2026-06-21 18:25:45 +10:00
Andrew Tridgell 3dddbde5ff syscall/util1: race-safe path resolution via a held dirfd-stack resolver
Resolve every operator/peer-reachable filesystem path one component at a time
through a stack of O_NOFOLLOW-held directory fds, so a symlink swapped in mid-walk
cannot redirect the operation (TOCTOU).  Adds the do_*_atfd() wrappers, the held
dirfd cache (held_dfd_for) and do_mkstemp_atfd() for race-safe temp-file creation,
plus secure change_dir()/robust_rename() in util1.c.  t_stub.c gains the matching
test stubs.
2026-06-21 18:25:45 +10:00
Andrew Tridgell 4f7003edd0 io: defer in_multiplexed=1 in the other write-side read_a_msg cases
The run4 0002 fix deferred the in_multiplexed re-arm past the call-out only for
MSG_NOOP and MSG_IO_ERROR (the two fuzzed tags), but the same anti-pattern --
re-arming the re-entry guard before a write-side call-out that can reach
perform_io() -> read_a_msg() -- remained in the sibling cases (Codex review).
Defer it past the call-out in those too:

  - MSG_NO_SEND (!am_generator -> send_msg_int): exploitable at a daemon sender,
    the same stack-exhaustion recursion as MSG_NOOP.
  - MSG_DELETED (!am_generator -> log_delete -> send_msg when am_server): same.
  - MSG_DELETED (am_generator -> send_msg): not currently exploitable (the
    generator is not am_sender, so send_msg doesn't perform_io), deferred for
    uniformity.

MSG_SUCCESS/MSG_REDO are left as-is: successful_send() returns early unless
--remove-source-files and does not reach send_msg()->perform_io(), and the
generator-status path runs only in the (non-am_sender) generator.

(cherry picked from commit f4f1905f5cfd83bd10d4db3b734da34cc43d33b0)
2026-06-15 17:47:46 +10:00
Andrew Tridgell 71515fe874 io: ensure argv has room for the trailing NULL in read_args
After the '.' separator, read_args delegates argv growth to
glob_expand_module() -> glob_expand()/glob_match(), each of which reserves
glob.argc+1 slots -- room for the entry being added but not for the trailing
NULL that read_args writes after the loop.  A daemon client can send a single
post-dot request line whose " mod/" splits land argc on exactly maxargs
(initially MAX_ARGS=1000, then any ENSURE_MEMSPACE doubling boundary), so the
'argv[argc] = NULL' at the end is an 8-byte NULL write one slot past the argv
heap allocation.  Reachable from an unauthenticated client; runs after
chroot/setuid in rsync_module so the corruption is in the privilege-dropped
per-connection child.

Ensure the +1 slot before the store.  The pre-dot growth check at
'argc == maxargs-1' already leaves the slot but never fires once dot_pos is set.

(cherry picked from commit ec4f7a03c42e93a48fce20d0b7667235564f9248)
(cherry picked from commit ffc91be590)
2026-06-15 17:16:06 +10:00
Andrew Tridgell e0fae47c46 io: defer in_multiplexed=1 past MSG_NOOP/MSG_IO_ERROR call-outs
read_a_msg() sets iobuf.in_multiplexed = -1 on entry so that any perform_io()
reached while the message body is being consumed will not re-enter read_a_msg()
(IN_MULTIPLEXED_AND_READY tests > 0). The MSG_NOOP and MSG_IO_ERROR handlers
reset it to 1 *before* calling maybe_send_keepalive() / send_msg_int(), both of
which can hit perform_io(PIO_NEED_MSGROOM). With more frames already buffered
(iobuf.in.len > 512), perform_io() then loops back into read_a_msg() -- each
frame stacks a fresh BIGPATHBUFLEN local, so a hostile peer that floods MSG_NOOP
at a daemon sender (or MSG_IO_ERROR at a daemon receiver child) exhausts the
stack.

Move the reset after the call-out so the guard stays armed across the write-side
flush.  The outer caller continues draining input once we return.

(cherry picked from commit 5d2794fca309dd558f68b21bff6d3e9728e58237)
(cherry picked from commit e3986e768f)
2026-06-15 17:16:06 +10:00
Andrew Tridgell dadecc8e1e exclude: count trailing backslash in add_implied_include
Phase 1 of add_implied_include() decides whether a '\' is escaping a wildcard
via !strchr("*[?", cp[1]).  strchr() returns a pointer to the string literal's
NUL terminator when cp[1]=='\0', so a trailing backslash matches neither branch:
it is emitted into new_pat via the fall-through but backslash_cnt is not
incremented.  The phase-2 recurse/xfer_dirs '/**' rule then allocates
arg_len + backslash_cnt + 3 + 1 and doubles every '\' in new_pat, including the
uncounted trailing one, so the closing '*p = '\0'' lands one byte past the heap
buffer.

Reachable on a standard 'rsync --daemon' deployment (am_server==0 at the
per-module parse_arguments(), so trust_sender_args stays 0) by a remote
unauthenticated client that sends '-r --files-from=-' and a files-from name with
both an interior and a trailing backslash (e.g. 'a\b\').  Count the trailing
backslash so the allocation reserves the doubled slot.

(cherry picked from commit e377e7dc4f3e752723e5fbf9623a8315034e49ab)
(cherry picked from commit 355c115e80)
2026-06-15 17:16:06 +10:00
Andrew Tridgell b47530c9a8 match: clamp peer-supplied s->flength to local len in append mode
In daemon-as-sender mode, send_files() leaves mbuf NULL for an empty local file
(st.st_size == 0). receive_sums() then derives s->flength from the peer's
sum_head (count * blength), so a hostile client that sends a non-empty sum_head
against that empty file drives the append_mode==2 verify loop into
map_ptr(NULL, ...), dereferencing the NULL map. The diminished-file guard
compares against F_LENGTH(file) (the daemon's own flist size, also 0), not the
peer-supplied flength, so it does not catch this.

Clamp s->flength to len before the verify loop -- we cannot checksum bytes we do
not have, and last_match must not run past len for the trailing matched() flush.

(cherry picked from commit 43f43c92d3414f340029ddf7ddc316dc4d4e3e7b)
(cherry picked from commit 9491aafdac)
2026-06-15 17:16:06 +10:00