Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions log.c
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ static void logfile_open(void)
* attacker-writable dirs; a planted symlink could redirect root's log
* into e.g. /root/.ssh/authorized_keys. Refuse symlinks not owned by
* uid 0 or our euid. */
int fd = open_no_attacker_symlinks(logfile_name,
O_WRONLY | O_APPEND | O_CREAT, 0644);
int fd = open_log_file(logfile_name, O_WRONLY | O_APPEND | O_CREAT, 0644);
logfile_fp = fd >= 0 ? fdopen(fd, "a") : NULL;
if (!logfile_fp && fd >= 0)
close(fd);
Expand Down
71 changes: 70 additions & 1 deletion support/rrsync
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,53 @@ def safe_open_logfile():
return None
return os.fdopen(fd, 'a')

def resolve_log_path(path, dir_st):
"""Resolve symlinks before .. and reject paths through DIR."""
todo = [c for c in path.split('/') if c]
if path.endswith('/'):
todo.append('.')
done = '//' if path.startswith('//') and not path.startswith('///') else '/'
hops = 0
while todo:
comp = todo.pop(0)
if comp == '.':
continue
if comp == '..':
done = os.path.dirname(done)
continue
cand = os.path.join(done, comp)
try:
st = os.lstat(cand)
except FileNotFoundError as e:
if not todo:
return cand
die("cannot resolve the -log FILE", cand + ':', e.strerror)
except OSError as e:
die("cannot resolve the -log FILE", cand + ':', e.strerror)
if stat.S_ISLNK(st.st_mode):
if st.st_uid not in (0, os.geteuid()):
die("untrusted symlink in -log FILE:", cand)
hops += 1
if hops > 40:
die("too many symlinks in the -log FILE path")
try:
target = os.readlink(cand)
except OSError as e:
die("cannot resolve the -log FILE", cand + ':', e.strerror)
if target.startswith('/'):
done = '//' if target.startswith('//') and not target.startswith('///') else '/'
todo = [c for c in target.split('/') if c] + (['.'] if target.endswith('/') else []) + todo
continue
if os.path.samestat(st, dir_st):
die("the -log FILE must be outside the restricted directory")
if todo:
try:
os.stat(os.path.join(cand, '.'))
except OSError as e:
die("cannot resolve the -log FILE", cand + ':', e.strerror)
done = cand
return done

def main():
if not os.path.isdir(args.dir):
die("Restricted directory does not exist!")
Expand Down Expand Up @@ -527,7 +574,7 @@ def main():
for opt in long_opts:
if opt.startswith(('remove', 'delete')):
long_opts[opt] = -1
if args.ro:
if args.ro or args.log:
long_opts['log-file'] = -1

global short_disabled
Expand Down Expand Up @@ -672,6 +719,23 @@ def main():
if args.no_overwrite:
rsync_opts.append('--ignore-existing')

if args.log:
if args.dir == '/':
die("-log cannot be used when DIR is /")
resolved_log = resolve_log_path(args.log, os.stat(args.dir))
fd = None
try:
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0)
fd = os.open(resolved_log, flags, 0o644)
if not stat.S_ISREG(os.fstat(fd).st_mode):
die("-log FILE must be a regular file")
except OSError as e:
die("cannot open the -log FILE", args.log + ':', e.strerror)
finally:
if fd is not None:
os.close(fd)
rsync_opts.append('--log-file=' + args.log)

if not rsync_args:
rsync_args = [ '.' ]

Expand Down Expand Up @@ -1023,12 +1087,17 @@ if __name__ == '__main__':
arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
arg_parser.add_argument('-log', metavar='FILE', help="Log each transfer to FILE via rsync's --log-file.")
arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
args = arg_parser.parse_args()
args.dir = os.path.realpath(args.dir)
args.dir_slash = args.dir + '/'
args.dir_slash_len = len(args.dir_slash)
if args.log is not None:
if not args.log:
die("empty -log FILE")
args.log = os.path.join(os.getcwd(), args.log)
if args.ro:
args.no_del = True
elif not args.no_lock:
Expand Down
27 changes: 24 additions & 3 deletions support/rrsync.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ rrsync - a script to setup restricted rsync users via ssh logins
## SYNOPSIS

```
rrsync [-ro|-wo] [-munge] [-absolute] [-no-del] [-no-lock] [-no-overwrite] DIR
rrsync [-ro|-wo] [-munge] [-absolute] [-no-del] [-no-lock] [-no-overwrite] [-log FILE] DIR
```

The single non-option argument specifies the restricted _DIR_ to use. It can be
Expand Down Expand Up @@ -103,8 +103,29 @@ The remainder of this manpage is dedicated to using the rrsync script.
replacing whatever has appeared at that name since the check), and backup
mode (`-b`, `--backup-dir`, whose published backup replaces whatever
already occupies the backup name). Resumable uploads with an explicit
`--partial-dir`, `--delay-updates`, `--delay-symlinks`, and server-side
logging are therefore unavailable under this option.
`--partial-dir`, `--delay-updates`, `--delay-symlinks` and client-selected
log files are therefore unavailable under this option. Administrator logging
with [`-log FILE`](#opt) remains available.

0. `-log FILE`

Write server-side transfer records to _FILE_ using rsync's
[`--log-file`](rsync.1#opt). The default "%i %n%L" format records files sent,
received or deleted, with timestamps and process IDs. The client's own
`--log-file` is refused. This works with `-ro`, `-wo` and `-no-overwrite`.

_FILE_ must be a regular file outside _DIR_. It can be absolute or relative
to the user's home directory. rrsync resolves symlinks before `..` and
refuses paths that pass through _DIR_. `-log` is refused when _DIR_ is `/`.
Symlinks must be owned by root or the user running rrsync. Keep the log and
its parent directories out of untrusted users' reach, including
through hard links or bind mounts; rrsync cannot detect every such alias.

rrsync checks that _FILE_ can be opened before starting the transfer.
Rsync then opens the file itself. Logging is best-effort: a later open or
write failure can leave a successful transfer with incomplete or missing
records. Use rsync's exit status to determine whether a transfer succeeded.
This option does not provide a tamper-resistant audit trail.

0. `-help`, `-h`

Expand Down
29 changes: 18 additions & 11 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ static int ona_open_absolute_root(char *remaining, char *abspath, size_t abspath
/* Core walk. When out_abs is non-NULL and the path resolves to a directory
* (O_DIRECTORY), the resolved absolute path is copied there -- owner_walk_parent
* uses it to filter-check the (otherwise unchecked) leaf basename. */
static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap)
static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap, int enforce_root)
{
#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
const int dir_traverse_flags = directory_traverse_flags() | O_CLOEXEC;
Expand All @@ -446,7 +446,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
char abspath[MAXPATHLEN] = {0};
if (am_daemon && module_dir && module_dir[0] == '/')
strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */
else if (confine_root) {
else if (enforce_root && 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()
Expand All @@ -472,7 +472,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
* 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. */
const char *ptail = fd_pin_tail(path);
int pin_transit = !am_daemon && confine_root && ptail != NULL;
int pin_transit = enforce_root && !am_daemon && confine_root && ptail != NULL;

/* Path-walk state. `remaining` is the unconsumed tail; we splice
* symlink targets back into it as we go. Sized 2x MAXPATHLEN so a
Expand Down Expand Up @@ -534,7 +534,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
saved_errno = errno;
goto out;
}
if (!pin_transit && abspath_outside_confinement(abspath, 1)) {
if (enforce_root && !pin_transit && abspath_outside_confinement(abspath, 1)) {
saved_errno = ELOOP;
goto out;
}
Expand Down Expand Up @@ -664,7 +664,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
saved_errno = errno;
goto out;
}
if (!pin_transit && abspath_outside_confinement(abspath, 1)) {
if (enforce_root && !pin_transit && abspath_outside_confinement(abspath, 1)) {
saved_errno = ELOOP;
goto out;
}
Expand All @@ -688,7 +688,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
saved_errno = errno;
goto out;
}
if (!pin_transit && abspath_outside_confinement(abspath, 0)) {
if (enforce_root && !pin_transit && abspath_outside_confinement(abspath, 0)) {
saved_errno = ELOOP;
goto out;
}
Expand Down Expand Up @@ -716,7 +716,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
* an O_PATH fd is sufficient for traversal and fchdir but not operations
* such as fchmod. */
if (flags & O_DIRECTORY) {
if (!pin_transit && abspath_outside_confinement(abspath, 1)) {
if (enforce_root && !pin_transit && abspath_outside_confinement(abspath, 1)) {
saved_errno = ELOOP;
goto out;
}
Expand All @@ -737,21 +737,28 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz
#else
/* Pre-AT_FDCWD / no O_NOFOLLOW systems: best-effort fallback. */
(void)out_abs; (void)out_cap;
(void)enforce_root;
return open(path, flags, mode);
#endif
}

int open_no_attacker_symlinks(const char *path, int flags, mode_t mode)
{
return ona_open(path, flags, mode, NULL, 0);
return ona_open(path, flags, mode, NULL, 0, 1);
}

/* A log's ownership check must not inherit file-list confinement. */
int open_log_file(const char *path, int flags, mode_t mode)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding enforce_root introduces a third mechanism controlling root confinement alongside files_from (filesfrom_owner_walk_active()) and operator_path_resolve. Having three overlapping flags makes the confinement logic inconsistent and harder to reason about or debug later.

Instead of adding a new parameter to ona_open(), it would be cleaner to handle this inside open_log_file() by temporarily setting confine_root = NULL (or files_from = NULL) as exception only for log-files when used with files-from (filesfrom_owner_walk_active returns 1) , then restoring them right after ( same as operator_path_resolve style) .

Note that zeroing confine_root will cause pseudo-paths (Test 2) to fail, which is actually expected. I was already planning to open a separate PR to allow trusted pseudo-paths under --confine-root when operator_path_resolve == 0 (for --log-file, --files-from, etc.), since rejecting them unconditionally on if (confine_root) is a separate bug we need to fix anyway.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steadytao the pseudo-paths fix are covered here #1169

{
return ona_open(path, flags, mode, NULL, 0, 0);
}

/* Open a directory for traversal or as *at()/fchdir() authority. Unlike an
* O_RDONLY directory endpoint, this accepts a searchable but unreadable
* directory on Linux. */
int open_no_attacker_symlinks_dirfd(const char *path)
{
return ona_open(path, directory_traverse_flags(), 0, NULL, 0);
return ona_open(path, directory_traverse_flags(), 0, NULL, 0, 1);
}

/* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied
Expand Down Expand Up @@ -780,7 +787,7 @@ int owner_walk_parent(const char *path, const char **bname)
*bname = slash ? slash + 1 : path;
pabs[0] = '\0';
if (!slash)
dfd = ona_open(".", directory_traverse_flags(), 0, pabs, sizeof pabs);
dfd = ona_open(".", directory_traverse_flags(), 0, pabs, sizeof pabs, 1);
else {
dlen = slash == path ? 1 : (size_t)(slash - path); /* "/x" -> parent "/" */
if (dlen >= sizeof dir) {
Expand All @@ -789,7 +796,7 @@ int owner_walk_parent(const char *path, const char **bname)
}
memcpy(dir, path, dlen);
dir[dlen] = '\0';
dfd = ona_open(dir, directory_traverse_flags(), 0, pabs, sizeof pabs);
dfd = ona_open(dir, directory_traverse_flags(), 0, pabs, sizeof pabs, 1);
}
if (dfd < 0)
return -1;
Expand Down
Loading
Loading