From 1030929cbaa19dc04176c07321ed6bb7c2bb2ff9 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 13:11:03 +1000 Subject: [PATCH] vfs: adapt the merged-in base changes to the VFS layer The base gained 13 commits. Three needed hand-porting because the change lands on code the VFS split moved or renamed: - 31130112 (--confine-root) is almost entirely in syscall.c, which does not exist here. abspath_excluded_by_module() becomes abspath_outside_confinement() in vfs/dirstack.c, taking its root from vfs.module_dir when we are a daemon and from confine_root otherwise, plus the fd-pin helpers; ona_open() in vfs/owner_walk.c gains the getcwd() seed and the pin_transit exemption. The VFS passes is_operator as an argument where the base reads operator_path_resolve, so the refusal takes it from there rather than the deleted global. - d09edb85 (--link-dest hard-link fallback) keeps the VFS call vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) and adopts the base's cannot_hardlink/match_level fallback around it. - t_stub.c gains the confine_root/confine_rootlen stubs beside the VFS curr_dir note. Tree is byte-identical to the merge oracle (tag merge-reference-11). --- generator.c | 29 +++++++++--- options.c | 2 +- receiver.c | 2 +- t_stub.c | 2 + vfs/dirstack.c | 110 ++++++++++++++++++++++++++++++++++++++------- vfs/owner_walk.c | 46 ++++++++++++++----- vfs/secure_open.c | 2 +- vfs/vfs_internal.h | 7 ++- 8 files changed, 163 insertions(+), 37 deletions(-) diff --git a/generator.c b/generator.c index 3f232155..8b25d370 100644 --- a/generator.c +++ b/generator.c @@ -1266,12 +1266,29 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, * hard_link_one() path above and basis_link_stat's !am_daemon gate). * fname is the transfer destination (secure receiver resolve). */ if (vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) < 0) { - rsyserr(FERROR_XFER, errno, - "failed to hard-link %s with %s", - cmpbuf, fname); - return j; - } - if (preserve_hard_links && F_IS_HLINKED(file)) + /* CAN_HARDLINK_SYMLINK/_SPECIAL answer for whatever + * filesystem the build tree sat on; the destination is + * free to disagree, and one host can hold both (macOS + * builds on APFS, backs up to HFS+). A refusal here is + * that same answer arriving late, so fall back to a copy + * as a build without the macro does -- the caller creates + * the entry either way, so failing the transfer only cost + * the exit status. + * + * Every errno, as the regular-file path next door already + * does (try_dests_reg -> hard_link_one -> try_a_copy). + * Picking out the "cannot" errnos is not possible anyway: + * link(2) documents EPERM both for a filesystem with no + * hard-link support and for an ordinary permission + * refusal, and FUSE reports ENOSYS for the same thing. + * + * The rest report themselves: ENOSPC/EDQUOT/EROFS fail the + * copy too, EMLINK and EXDEV mean it was never linkable. + * EIO alone goes unremarked, deliberately -- a diagnostic + * here lands in --link-dest's itemised output. */ + cannot_hardlink = 1; + match_level = 2; + } else if (preserve_hard_links && F_IS_HLINKED(file)) finish_hard_link(file, fname, ndx, NULL, itemizing, code, -1); } else #endif diff --git a/options.c b/options.c index 04265e33..f4458b52 100644 --- a/options.c +++ b/options.c @@ -60,7 +60,7 @@ int preserve_executability = 0; int preserve_devices = 0; int preserve_specials = 0; int drop_devices = 0; -char *confine_root = NULL; /* --confine-root: see syscall.c */ +char *confine_root = NULL; /* --confine-root: see vfs/dirstack.c */ unsigned int confine_rootlen = 0; int preserve_uid = 0; int preserve_gid = 0; diff --git a/receiver.c b/receiver.c index c702c393..df95f95c 100644 --- a/receiver.c +++ b/receiver.c @@ -122,7 +122,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags * recv_files caller) may be absolute (module_dir-prefixed on a non-chroot * daemon) and traverse a symlink the vfs_resolve_open path can't confine: * resolve it with the ownership walk, which follows a uid0/euid-owned symlink - * but refuses a foreign one AND (via abspath_excluded_by_module) refuses a + * but refuses a foreign one AND (via abspath_outside_confinement) refuses a * target the module's exclude hides -- closing the partial-dir exclude bypass. */ if (is_operator) { char fullpath[MAXPATHLEN]; diff --git a/t_stub.c b/t_stub.c index e5c86f21..8e1aa1e2 100644 --- a/t_stub.c +++ b/t_stub.c @@ -47,6 +47,8 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; char *partial_dir; char *module_dir; int module_dirfd = -1; +char *confine_root; +unsigned int confine_rootlen = 0; /* vfs.curr_dir[]/vfs.curr_dir_len (read by vfs_resolve_open) are defined in * syscall.c, which every helper links -- no stub needed here. */ filter_rule_list daemon_filter_list; diff --git a/vfs/dirstack.c b/vfs/dirstack.c index eb5f16f8..bd0765c7 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -60,24 +60,102 @@ static int path_within(const char *root, size_t rootlen, const char *path) * name is not excluded may still resolve into an excluded IN-module subtree, * exactly as in stock rsync. The defense for a writable module is `munge * symlinks` (see rsyncd.conf(5)), not this walk. No-op unless we're a daemon. */ -int abspath_excluded_by_module(const char *abspath, int is_operator) +/* The root an operator/peer-supplied path must stay under, or NULL when nothing + * is confined. A daemon has the served module; a server launched by a wrapper + * with its own restricted directory (rrsync) gets one from --confine-root. + * + * A daemon never honours --confine-root: vfs.module_dir is the boundary there, + * and the option arrives in a peer-supplied argv, so obeying it could only + * loosen the module. */ +static const char *confinement_root(unsigned int *lenp) { - if (!am_daemon || !abspath || !vfs.module_dir) + if (am_daemon) { + *lenp = vfs.module_dirlen; + return vfs.module_dir; + } + *lenp = confine_rootlen; + return confine_root; +} + +/* Split the "/proc//fd" prefix off `p`, returning the tail -- "" for + * the pin directory itself, otherwise a string starting with '/'. NULL when `p` + * is not in the fd-pin namespace at all. */ +const char *vfs_fd_pin_tail(const char *p) +{ + const char *s; + + if (strncmp(p, "/proc/", 6) != 0) + return NULL; + s = p + 6; + if (strncmp(s, "self/", 5) == 0) /* "/proc/self/..." */ + s += 4; + else { /* "/proc//..." */ + const char *d = s; + while (*s >= '0' && *s <= '9') + s++; + if (s == d || *s != '/') + return NULL; + } + if (strncmp(s, "/fd", 3) != 0) + return NULL; + s += 3; + return (*s == '\0' || *s == '/') ? s : NULL; +} + +/* An EXACT pin entry, "/proc/self/fd/7" -- the one spelling whose target is what + * confinement must judge. rrsync also writes a pinned parent as + * ".../fd/7/", but the walk resolves the magic link itself and checks the + * components past it, so only the bare entry is resolved here. Requiring all + * digits keeps a planted name like ".../fd/outside-secret" out. */ +static int is_exact_fd_pin(const char *p) +{ + const char *tail = vfs_fd_pin_tail(p); + + if (!tail || *tail != '/') return 0; - if (vfs.module_dirlen <= 1) /* module root is "/": nothing is outside */ + for (++tail; *tail >= '0' && *tail <= '9'; tail++) {} + return *tail == '\0' && tail[-1] != '/'; +} + +int abspath_outside_confinement(const char *abspath, int is_operator) +{ + unsigned int rootlen; + const char *root = confinement_root(&rootlen); + char pinned[MAXPATHLEN]; + + if (!root || !abspath) return 0; - if (path_within(vfs.module_dir, vfs.module_dirlen, abspath)) - return 0; /* inside the module: name-based exclude is not a boundary */ - /* Not under the module root. An ABSOLUTE walk passes through the module - * root's ancestors ("/", "/home", ...) on the way down -- those are not - * "outside", just not-yet-arrived, so allow them. A path that has truly - * DIVERGED from the module tree is outside: refuse it for an operator/peer - * path that must stay in the module (is_operator); other daemon - * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. - * The --insecure-links / "insecure links = yes" opt-out short-circuits - * before we get here. */ - if (path_within(abspath, strlen(abspath), vfs.module_dir)) - return 0; /* ancestor of the module root: still descending */ + if (rootlen <= 1) /* root is "/": nothing is outside */ + return 0; + /* An fd pin (rrsync rewrites a validated option path to /proc/self/fd/N so + * no later symlink can redirect it) is spelled outside the root by + * construction. Judge it by what it points AT rather than by its spelling, + * so a pin is neither wrongly refused nor blindly trusted. A pin we cannot + * resolve to an absolute path is refused, not waved through: an unreadable + * pin is exactly the case where we cannot say where the open would land. */ + if (!am_daemon) { + const char *tail = vfs_fd_pin_tail(abspath); + if (tail && !*tail) + return 0; /* the pin directory: transit, opens nothing */ + if (is_exact_fd_pin(abspath)) { + ssize_t n = readlink(abspath, pinned, sizeof pinned - 1); + if (n <= 0 || pinned[0] != '/') + return is_operator ? 1 : 0; + pinned[n] = '\0'; + abspath = pinned; + } + } + if (path_within(root, rootlen, abspath)) + return 0; /* inside: name-based exclude is not a boundary */ + /* Not under the root. An ABSOLUTE walk passes through the root's ancestors + * ("/", "/home", ...) on the way down -- those are not "outside", just + * not-yet-arrived, so allow them. A path that has truly DIVERGED is + * outside: refuse it for an operator/peer path that must stay in the tree + * (is_operator); other opens (--log-file, --*-from, lock/motd) may + * legitimately live elsewhere. The --insecure-links / "insecure links = + * yes" opt-out short-circuits before we get here. */ + if (!*abspath || path_within(abspath, strlen(abspath), root)) + return 0; /* ancestor of the root: still descending */ return is_operator ? 1 : 0; } @@ -197,7 +275,7 @@ int ds_descend(struct dirstack *ds, const char *part, int *hops) * symlink that redirected the walk into an excluded subtree). */ /* The strict resolver stays confined beneath the anchor (within the * module), so this never actually refuses; pass is_operator=0. */ - if (abspath_excluded_by_module(ds->abspath, 0)) { + if (abspath_outside_confinement(ds->abspath, 0)) { errno = ELOOP; return -1; } diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 4fdcc45e..8be7feaf 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -24,7 +24,7 @@ /* Advance the tracked absolute path `abspath` by one resolved component, * normalizing "." and ".." exactly as openat() does so the module-confinement - * check (abspath_excluded_by_module) sees the REAL resolved target. -1/ + * check (abspath_outside_confinement) sees the REAL resolved target. -1/ * ENAMETOOLONG on overflow. */ static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp_len) { @@ -88,14 +88,36 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz int dfd = AT_FDCWD; int dfd_owns = 0; - /* Absolute module-relative path of the current dir, for the exclude-aware - * refusal (abspath_excluded_by_module). A relative operator path starts at - * the daemon's cwd == the module root; an absolute one (or a followed - * absolute symlink target) restarts at "/". */ + /* Absolute path of the current dir, for the confinement refusal + * (abspath_outside_confinement). A relative operator path starts at the + * daemon's cwd == the module root; an absolute one (or a followed absolute + * symlink target) restarts at "/". */ char abspath[MAXPATHLEN]; abspath[0] = '\0'; if (am_daemon && vfs.module_dir && vfs.module_dir[0] == '/') strlcpy(abspath, vfs.module_dir, sizeof abspath); /* "/" for a path=/ module */ + else if (confine_root) { + /* Unlike a daemon's, this cwd is not pinned to the root -- the receiver + * chdir's into the destination -- so it has to be read, not assumed. + * It must be the PHYSICAL cwd: vfs.curr_dir is the lexical name + * vfs_change_dir() was given, so after descending a trusted symlink the + * tracker sits at a different depth than the kernel, and a ".." that + * really escapes looks like it landed inside. + * + * Without it there is nothing to measure against, and an empty tracker + * does NOT deny by itself -- a leading ".." pops nothing and an empty + * path reads as an ancestor of the root -- so refuse the open instead. */ + if (!getcwd(abspath, sizeof abspath)) + return -1; + } + + /* An fd pin (rrsync rewrites an option path to /proc/self/fd/N so no + * later symlink can redirect it) is spelled outside the root by + * construction, so the walk has to be allowed through /proc/self/fd to + * reach the magic link. This only suspends the check for that prefix: + * following the link restarts the walk at its absolute target, and every + * component of THAT is checked, so a pin aimed outside is still refused. */ + int pin_transit = !am_daemon && confine_root && vfs_fd_pin_tail(path) != NULL; /* Path-walk state. `remaining` is the unconsumed tail; we splice * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a @@ -149,7 +171,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -204,6 +226,10 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz } dfd_owns = 1; abspath[0] = '\0'; /* followed an absolute target: restart from "/" */ + /* "self" resolves to "", still inside the pin; + * the magic link itself lands elsewhere and ends the + * exemption. Never turns back on. */ + pin_transit = pin_transit && vfs_fd_pin_tail(rebuilt) != NULL; char *p = rebuilt; while (*p == '/') p++; strlcpy(remaining, p, sizeof remaining); @@ -219,7 +245,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -243,7 +269,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -337,7 +363,7 @@ int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) /* owner_walk only resolved the PARENT; check the resolved leaf too, so a * symlinked operator path cannot act on a leaf that resolves OUTSIDE the * module in an otherwise-served dir. (The module exclude/filter is name- - * based and not enforced here -- see abspath_excluded_by_module.) */ + * based and not enforced here -- see abspath_outside_confinement.) */ if (pabs[0]) { char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", pabs, *bname) >= (int)sizeof leafabs) { @@ -345,7 +371,7 @@ int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) errno = ENAMETOOLONG; /* fail closed, never skip the check */ return -1; } - if (abspath_excluded_by_module(leafabs, is_operator)) { + if (abspath_outside_confinement(leafabs, is_operator)) { close(dfd); errno = ELOOP; return -1; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index c9383c87..7d18cf30 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -195,7 +195,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) < (int)sizeof leafabs - && abspath_excluded_by_module(leafabs, 0)) { + && abspath_outside_confinement(leafabs, 0)) { errno = ELOOP; goto cleanup; } diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index aa0046d7..e320c0ef 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -33,6 +33,8 @@ extern int copy_links; extern int copy_unsafe_links; extern int insecure_links; extern int module_id; +extern char *confine_root; /* --confine-root, or NULL; see confinement_root() */ +extern unsigned int confine_rootlen; /* Dry-run / read-only guard macros shared by the syscall wrappers. */ #define RETURN_ERROR_IF(x,e) \ @@ -52,7 +54,8 @@ extern int module_id; /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); -int abspath_excluded_by_module(const char *abspath, int is_operator); +int abspath_outside_confinement(const char *abspath, int is_operator); +const char *vfs_fd_pin_tail(const char *p); /* Per-operand parent resolver for the two-path ops (vfs_rename_at/vfs_link_at). */ int vfs_twopath_side(const char *path, int side_flags, const char **bname, @@ -76,7 +79,7 @@ struct dirstack { int fds[DS_MAXDEPTH]; /* fds[0] = anchor (borrowed); fds[top] = current dir */ int top; /* Absolute path of fds[top], maintained as we descend/pop, for the - * exclude-aware refusal (abspath_excluded_by_module). Empty unless the + * exclude-aware refusal (abspath_outside_confinement). Empty unless the * caller seeds it with the anchor's absolute path; then a followed symlink * that redirects the walk into a module-excluded dir is refused. */ char abspath[MAXPATHLEN];