mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-08 19:30:38 -04:00
rsync: add --confine-root, bounding operator path resolution
The ownership walk that resolves operator-supplied paths asks who planted a symlink, not where the path came out, so a symlink owned by uid 0 or the euid is followed wherever it points. A daemon already narrows that with the served module root; nothing else has a root to narrow it with. That leaves a wrapper serving a restricted directory over a remote shell with no way to bound the resolution. rrsync can vet the argv it is handed, but filter rules travel over the protocol instead: a dir-merge rule can name a merge file outside the restricted dir and the server reads it in as rules. Redacting the resulting diagnostics does not close it, because an exclude-only merge produces none -- every line becomes a pattern, so nothing fails to parse, and the client reads the file's contents off which of its own names went missing from the file list. --confine-root gives that wrapper the root the daemon has. The existing module check becomes a root check that takes its root from module_dir when we are a daemon and from the option otherwise, so daemon behaviour is unchanged; a daemon ignores the option outright, since it arrives in a peer-supplied argv and could only widen the module. The tracker is seeded from getcwd() rather than curr_dir, which is only the lexical name change_dir() was given: descend into a source argument through a trusted symlink and the two sit at different depths, so a ".." that really escapes looks like it landed inside. When the cwd cannot be read there is nothing to measure against and the open is refused -- an empty tracker does not deny by itself, because a leading ".." pops nothing from it and an empty path reads as an ancestor of the root. An fd pin (/proc/self/fd/N, which rrsync uses so no later symlink can redirect a validated option path) is spelled outside the root by construction, so the walk transits the pin namespace and the pin is judged by what it points at. Only a bare ".../fd/<digits>" is resolved that way, and one that will not readlink to an absolute path is refused; rrsync's ".../fd/N/<leaf>" spelling resolves through the magic link and has its remaining components checked normally. --insecure-links is refused alongside it: that opt-out returns the legacy open before the walk that enforces the root runs, so the pair would have quietly meant no confinement at all.
This commit is contained in:
1 parent
0f6f35e522
commit
3113011218
4 files changed
+204
-39
No files matched your search
@@ -61,6 +61,8 @@ 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 */
|
||||
unsigned int confine_rootlen = 0;
|
||||
int preserve_uid = 0;
|
||||
int preserve_gid = 0;
|
||||
int preserve_mtimes = 0;
|
||||
@@ -685,6 +687,7 @@ static struct poptOption long_options[] = {
|
||||
{"no-specials", 0, POPT_ARG_VAL, &preserve_specials, 0, 0, 0 },
|
||||
{"drop-D", 0, POPT_ARG_VAL, &drop_devices, 1, 0, 0 },
|
||||
{"no-drop-D", 0, POPT_ARG_VAL, &drop_devices, 0, 0, 0 },
|
||||
{"confine-root", 0, POPT_ARG_STRING, &confine_root, 0, 0, 0 },
|
||||
{"links", 'l', POPT_ARG_VAL, &preserve_links, 1, 0, 0 },
|
||||
{"no-links", 0, POPT_ARG_VAL, &preserve_links, 0, 0, 0 },
|
||||
{"no-l", 0, POPT_ARG_VAL, &preserve_links, 0, 0, 0 },
|
||||
@@ -2376,6 +2379,26 @@ int parse_arguments(int *argc_p, const char ***argv_p)
|
||||
}
|
||||
}
|
||||
|
||||
if (confine_root) {
|
||||
/* A daemon already has module_dir for this job, and honouring a
|
||||
* peer-supplied root there could only loosen the module boundary. */
|
||||
if (am_daemon)
|
||||
confine_root = NULL;
|
||||
else if (*confine_root != '/') {
|
||||
snprintf(err_buf, sizeof err_buf,
|
||||
"--confine-root must be an absolute path\n");
|
||||
return 0;
|
||||
} else if (insecure_links) {
|
||||
/* The opt-out restores the legacy open, which short-circuits the
|
||||
* walk that enforces the root -- so the pair would silently mean
|
||||
* no confinement at all. Say so instead. */
|
||||
snprintf(err_buf, sizeof err_buf,
|
||||
"--insecure-links cannot be combined with --confine-root\n");
|
||||
return 0;
|
||||
} else
|
||||
confine_root = normalize_path(confine_root, True, &confine_rootlen);
|
||||
}
|
||||
|
||||
if (sanitize_paths) {
|
||||
int i;
|
||||
for (i = argc; i-- > 0; )
|
||||
|
||||
+33
@@ -548,6 +548,7 @@ has its own detailed description later in this manpage.
|
||||
--copy-unsafe-links only "unsafe" symlinks are transformed
|
||||
--safe-links ignore symlinks that point outside the tree
|
||||
--insecure-links follow attacker-owned symlinks in operator paths
|
||||
--confine-root=DIR refuse operator paths resolving outside DIR
|
||||
--munge-links munge symlinks to make them safe & unusable
|
||||
--copy-dirlinks, -k transform symlink to directory into referent directory
|
||||
--keep-dirlinks, -K treat symlinked directory on receiver as directory
|
||||
@@ -1406,6 +1407,38 @@ sign) if you want the local shell to expand it.
|
||||
|
||||
See the [SYMBOLIC LINKS](#) section for multi-option info.
|
||||
|
||||
0. `--confine-root=DIR`
|
||||
|
||||
This bounds where the paths listed under [`--insecure-links`](#opt) are
|
||||
allowed to resolve: one that ends up outside DIR is refused, even if every
|
||||
symlink along it was owned by a trusted user. The ownership walk asks who
|
||||
planted a link; this asks where the path came out.
|
||||
|
||||
DIR must be absolute. Nothing is confined by default, and
|
||||
`--confine-root=/` is a no-op.
|
||||
|
||||
It exists for a wrapper that serves a restricted directory over a remote
|
||||
shell, `rrsync` being the one shipped here, which passes it automatically
|
||||
whenever its restricted dir is not "`/`". Such a wrapper can vet the argv
|
||||
it is handed, but filter rules travel over the protocol instead: a client
|
||||
can name a merge file outside the restricted dir in a dir-merge rule and
|
||||
have the server read it in as filter rules. On a pull that needs neither
|
||||
`--delete` nor any verbosity, because an exclude-only merge (the "`-`"
|
||||
modifier) makes every line a pattern, and the client reads the file's
|
||||
contents off which of its own names went missing. Confining the open is
|
||||
what closes that; a merge file inside DIR keeps working as before.
|
||||
|
||||
A daemon ignores this option -- its module directory is already the
|
||||
boundary, and the option arrives in a client-supplied argv, so honouring it
|
||||
could only widen the module.
|
||||
|
||||
[`--insecure-links`](#opt) is refused alongside it. That opt-out restores
|
||||
the historical open, which skips the walk that enforces the root, so the two
|
||||
together would silently mean no confinement at all.
|
||||
|
||||
Like [`--drop-D`](#opt), it is not forwarded to the remote side: it is meant
|
||||
to be applied to one end of a connection by itself.
|
||||
|
||||
0. `--munge-links`
|
||||
|
||||
This option affects just one side of the transfer and tells rsync to munge
|
||||
|
||||
@@ -69,6 +69,9 @@ extern int module_id;
|
||||
extern unsigned int module_dirlen;
|
||||
extern char *module_dir;
|
||||
extern int module_dirfd; /* daemon: served module root pinned by identity, or -1 */
|
||||
extern char *confine_root; /* --confine-root, or NULL; see confinement_root() */
|
||||
extern unsigned int confine_rootlen;
|
||||
extern char curr_dir[MAXPATHLEN]; /* defined below; fwd-declared for the seed */
|
||||
extern int operator_path_resolve; /* defined below; fwd-declared for the exclude check */
|
||||
|
||||
#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
|
||||
@@ -123,44 +126,122 @@ int symlink_optout_allowed(void)
|
||||
return insecure_links;
|
||||
}
|
||||
|
||||
/* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the
|
||||
* serving module's root, for an operator/peer-supplied path that must stay in the
|
||||
* module (--partial-dir/--backup-dir/alt-basis: operator_path_resolve). An
|
||||
* in-tree symlink owned by uid 0 / the euid is followed by design, so it can
|
||||
* redirect the resolved target outside the module; this catches that escape.
|
||||
/* 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.
|
||||
*
|
||||
* This is module-ROOT confinement only. The daemon exclude/filter list is a
|
||||
* name-based visibility filter, NOT a physical-path boundary: a symlink whose own
|
||||
* 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. */
|
||||
static int abspath_excluded_by_module(const char *abspath)
|
||||
* A daemon never honours --confine-root: 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 || !module_dir)
|
||||
if (am_daemon) {
|
||||
*lenp = module_dirlen;
|
||||
return module_dir;
|
||||
}
|
||||
*lenp = confine_rootlen;
|
||||
return confine_root;
|
||||
}
|
||||
|
||||
/* Split the "/proc/<self|pid>/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. */
|
||||
static const char *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/<pid>/..." */
|
||||
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/<leaf>", 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 = fd_pin_tail(p);
|
||||
|
||||
if (!tail || *tail != '/')
|
||||
return 0;
|
||||
if (module_dirlen <= 1) /* module root is "/": nothing is outside */
|
||||
for (++tail; *tail >= '0' && *tail <= '9'; tail++) {}
|
||||
return *tail == '\0' && tail[-1] != '/';
|
||||
}
|
||||
|
||||
/* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the
|
||||
* confinement root, for an operator/peer-supplied path that must stay inside it
|
||||
* (--partial-dir/--backup-dir/alt-basis/merge files: operator_path_resolve). An
|
||||
* in-tree symlink owned by uid 0 / the euid is followed by design, so it can
|
||||
* redirect the resolved target outside the root; this catches that escape.
|
||||
*
|
||||
* This is ROOT confinement only. The daemon exclude/filter list is a name-based
|
||||
* visibility filter, NOT a physical-path boundary: a symlink whose own name is
|
||||
* not excluded may still resolve into an excluded IN-tree subtree, exactly as in
|
||||
* stock rsync. The defense for a writable module is `munge symlinks` (see
|
||||
* rsyncd.conf(5)), not this walk. */
|
||||
static int abspath_outside_confinement(const char *abspath)
|
||||
{
|
||||
unsigned int rootlen;
|
||||
const char *root = confinement_root(&rootlen);
|
||||
char pinned[MAXPATHLEN];
|
||||
|
||||
if (!root || !abspath)
|
||||
return 0;
|
||||
if (strncmp(abspath, module_dir, module_dirlen) == 0
|
||||
&& (abspath[module_dirlen] == '\0' || abspath[module_dirlen] == '/'))
|
||||
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 (operator_path_resolve); 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 (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 = 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 operator_path_resolve ? 1 : 0;
|
||||
pinned[n] = '\0';
|
||||
abspath = pinned;
|
||||
}
|
||||
}
|
||||
if (strncmp(abspath, root, rootlen) == 0
|
||||
&& (abspath[rootlen] == '\0' || abspath[rootlen] == '/'))
|
||||
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
|
||||
* (operator_path_resolve); 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. */
|
||||
size_t alen = strlen(abspath);
|
||||
if (alen == 0
|
||||
|| (strncmp(abspath, module_dir, alen) == 0 && module_dir[alen] == '/'))
|
||||
return 0; /* ancestor of the module root: still descending */
|
||||
|| (strncmp(abspath, root, alen) == 0 && root[alen] == '/'))
|
||||
return 0; /* ancestor of the root: still descending */
|
||||
return operator_path_resolve ? 1 : 0;
|
||||
}
|
||||
|
||||
/* 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)
|
||||
{
|
||||
@@ -224,14 +305,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 && module_dir && module_dir[0] == '/')
|
||||
strlcpy(abspath, 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: curr_dir is the lexical name 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 && 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
|
||||
@@ -285,7 +388,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)) {
|
||||
if (!pin_transit && abspath_outside_confinement(abspath)) {
|
||||
saved_errno = ELOOP;
|
||||
goto out;
|
||||
}
|
||||
@@ -340,6 +443,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 "<pid>", still inside the pin;
|
||||
* the magic link itself lands elsewhere and ends the
|
||||
* exemption. Never turns back on. */
|
||||
pin_transit = pin_transit && fd_pin_tail(rebuilt) != NULL;
|
||||
char *p = rebuilt;
|
||||
while (*p == '/') p++;
|
||||
strlcpy(remaining, p, sizeof remaining);
|
||||
@@ -355,7 +462,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)) {
|
||||
if (!pin_transit && abspath_outside_confinement(abspath)) {
|
||||
saved_errno = ELOOP;
|
||||
goto out;
|
||||
}
|
||||
@@ -379,7 +486,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)) {
|
||||
if (!pin_transit && abspath_outside_confinement(abspath)) {
|
||||
saved_errno = ELOOP;
|
||||
goto out;
|
||||
}
|
||||
@@ -474,7 +581,7 @@ int owner_walk_parent(const char *path, const char **bname)
|
||||
/* 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) {
|
||||
@@ -482,7 +589,7 @@ int owner_walk_parent(const char *path, const char **bname)
|
||||
errno = ENAMETOOLONG; /* fail closed, never skip the check */
|
||||
return -1;
|
||||
}
|
||||
if (abspath_excluded_by_module(leafabs)) {
|
||||
if (abspath_outside_confinement(leafabs)) {
|
||||
close(dfd);
|
||||
errno = ELOOP;
|
||||
return -1;
|
||||
@@ -2697,7 +2804,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];
|
||||
@@ -2803,7 +2910,7 @@ static int ds_descend(struct dirstack *ds, const char *part, int *hops)
|
||||
return -1;
|
||||
/* exclude-aware: refuse descending into a module-hidden dir (catches a
|
||||
* symlink that redirected the walk into an excluded subtree). */
|
||||
if (abspath_excluded_by_module(ds->abspath)) {
|
||||
if (abspath_outside_confinement(ds->abspath)) {
|
||||
errno = ELOOP;
|
||||
return -1;
|
||||
}
|
||||
@@ -2933,7 +3040,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)) {
|
||||
&& abspath_outside_confinement(leafabs)) {
|
||||
errno = ELOOP;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in
|
||||
* syscall.c, which every helper links -- no stub needed here. */
|
||||
filter_rule_list daemon_filter_list;
|
||||
|
||||
Reference in new issue
Block a user