1003 lines · 43.7 KB · Text
RawDownload
#!/usr/bin/env python3 # Restricts rsync to subdirectory declared in .ssh/authorized_keys.  See# the rrsync man page for details of how to make use of this script. # NOTE: install python3 braceexpand to support brace expansion in the args! # Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004# Python version by: Wayne Davison <wayne@opencoder.net> # You may configure these 2 values to your liking.  See also the section of# short & long options if you want to disable any options that rsync accepts.RSYNC = '/usr/bin/rsync'LOGFILE = 'rrsync.log' # NOTE: the file must exist for a line to be appended! # The following options are mainly the options that a client rsync can send# to the server, and usually just in the one option format that the stock# rsync produces. However, there are some additional convenience options# added as well, and thus a few options are present in both the short and# long lists (such as --group, --owner, and --perms). # NOTE when disabling: check for both a short & long version of the option! ### START of options data produced by the cull-options script. ### # To disable a short-named option, add its letter to this string:short_disabled = 's' # These are also disabled when the restricted dir is not "/":short_disabled_subdir = 'KLk' # These are all possible short options that we will accept (when not disabled above):short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANYshort_with_num = '@B' # DO NOT REMOVE ANY # To disable a long-named option, change its value to a -1.  The values mean:# 0 = the option has no arg; 1 = the arg doesn't need any checking; 2 = only# check the arg when receiving; and 3 = always check the arg.long_opts = {  'append': 0,  'backup-dir': 2,  'block-size': 1,  'bwlimit': 1,  'checksum-choice': 1,  'checksum-seed': 1,  'compare-dest': 2,  'compress-choice': 1,  'compress-level': 1,  'compress-threads': 1,  'copy-dest': 2,  'copy-devices': -1,  'copy-unsafe-links': 0,  'daemon': -1,  'debug': -1,  'delay-updates': 0,  'delete': 0,  'delete-after': 0,  'delete-before': 0,  'delete-delay': 0,  'delete-during': 0,  'delete-excluded': 0,  'delete-missing-args': 0,  'dirs': 0,  'existing': 0,  'fake-super': 0,  'files-from': 3,  'force': 0,  'from0': 0,  'fsync': 0,  'fuzzy': 0,  'group': 0,  'groupmap': 1,  'hard-links': 0,  'iconv': 1,  'ignore-errors': 0,  'ignore-existing': 0,  'ignore-missing-args': 0,  'ignore-times': 0,  'info': 1,  'inplace': 0,  'link-dest': 2,  'links': 0,  'list-only': 0,  'log-file': 3,  'log-format': 1,  'max-alloc': 1,  'max-delete': 1,  'max-size': 1,  'min-size': 1,  'mkpath': 0,  'modify-window': 1,  'msgs2stderr': 0,  'munge-links': 0,  'new-compress': 0,  'no-W': 0,  'no-implied-dirs': 0,  'no-msgs2stderr': 0,  'no-munge-links': -1,  'no-r': 0,  'no-relative': 0,  'no-specials': 0,  'numeric-ids': 0,  'old-compress': 0,  'one-file-system': 0,  'only-write-batch': 1,  'open-noatime': 0,  'owner': 0,  'partial': 0,  'partial-dir': 2,  'perms': 0,  'preallocate': 0,  'recursive': 0,  'remove-sent-files': 0,  'remove-source-files': 0,  'safe-links': 0,  'sender': 0,  'server': 0,  'size-only': 0,  'skip-compress': 1,  'specials': 0,  'stats': 0,  'stderr': 1,  'suffix': 1,  'super': 0,  'temp-dir': 2,  'timeout': 1,  'times': 0,  'use-qsort': 0,  'usermap': 1,  'write-devices': -1,} ### END of options data produced by the cull-options script. ### import os, sys, re, argparse, glob, socket, stat, time, subprocessfrom argparse import RawTextHelpFormatter # Held open across exec so rsync inherits them. Each entry pins a path# validated_arg() approved; the corresponding arg passed to rsync is# rewritten to /proc/self/fd/N so rsync's path resolution cannot be# race-flipped after rrsync's realpath check, closing the realpath-vs-exec# TOCTOU.pinned_fds = [] # Directory pins, keyed by (st_dev, st_ino), so a glob or a multi-arg command# whose args share a parent inherits one fd rather than one per arg.pinned_dirs = {} # Whether the client asked for --relative/-R, which decides how much of a# sender arg rsync transmits as the file's name (see sender_pinned_arg).client_relative = False # The inode-pin trick needs /proc/self/fd/N to be a Linux-style magic symlink# whose readlink yields the open file's real path. macOS/BSD lack the directory# entirely; Solaris HAS /proc/self/fd but its entries are not such symlinks (its# readlink does not return the path), so an isdir() check is not enough -- probe# the actual behaviour once against a known fd. Where it works we pin (and a# later readlink failure is an anomaly that fails closed); where it does not we# fall through to the unhardened path.## A correct readlink is NOT sufficient evidence that the fd pins anything, and# the two platforms that get this wrong fail in opposite directions:##   * NetBSD makes the entry a symlink for DIRECTORIES only -- readlink of a#     regular file's entry fails with EINVAL, so a directory-only probe claims#     support that is not there and every pull of a file dies in the post-pin#     check.#   * Cygwin's readlink returns the right path, but opening the magic link#     RE-RESOLVES it: rename the directory out from under a held fd and the#     magic link reaches the replacement. The pin silently protects nothing.## Only the Linux kernel gives the inode-bound magic link this relies on, so# require that explicitly and keep the runtime probes as a guard for Linux-like# environments where /proc is absent or restricted (containers, seccomp).def _probe_proc_self_fd():    if not sys.platform.startswith(('linux', 'android')):        return False     def resolves(path):        try:            fd = os.open(path, os.O_RDONLY)        except OSError:            return False        try:            return os.readlink('/proc/self/fd/%d' % fd) == os.path.realpath(path)        except OSError:            return False        finally:            os.close(fd)     return resolves('/') and resolves(os.path.realpath(__file__)) HAVE_PROC_SELF_FD = _probe_proc_self_fd() try:    from braceexpand import braceexpandexcept:    braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ] HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')DE_BACKSLASH_RE = re.compile(r'\\(.)') def make_inheritable(fd):    """Clear FD_CLOEXEC so the exec'd rsync inherits `fd`.     os.set_inheritable() prefers ioctl(FIONCLEX), which an O_PATH descriptor    rejects with EBADF on older kernels; F_SETFD is one of the few operations    O_PATH always allows.    """    try:        os.set_inheritable(fd, True)    except OSError:        import fcntl        fcntl.fcntl(fd, fcntl.F_SETFD,                    fcntl.fcntl(fd, fcntl.F_GETFD) & ~fcntl.FD_CLOEXEC) def pin_dir(path, orig_arg):    """Inode-pin a directory and return an fd rsync will inherit.     The open resolves `path` normally -- including a symlink at its last    component, which is legitimate and which 3.4.4 accepts -- so a component    could be flipped first.  The readlink check afterwards is what makes that    safe: it proves the inode we ended up holding is inside the restricted    tree.  From then on the fd names that inode, not the path, so nothing above    it can be flipped again.     O_PATH, not O_RDONLY: reaching a known name beneath a directory needs only    search permission, and a mode 0111 parent is a perfectly ordinary way to    publish a file without letting it be listed.  An O_PATH directory fd is    just as firmly pinned when used as a /proc/self/fd/N/... prefix (the O_PATH    caveat in validated_arg() is about reopening the magic link as the file    itself, which is not what happens here).    """    flags = os.O_DIRECTORY | getattr(os, 'O_PATH', 0)    if not flags & getattr(os, 'O_PATH', 0):        flags |= os.O_RDONLY    try:        fd = os.open(path or '.', flags)    except OSError as e:        die('unable to pin sender path:', orig_arg, e.strerror)    try:        st = os.fstat(fd)        pinned_path = os.readlink('/proc/self/fd/%d' % fd)    except OSError as e:        os.close(fd)        die('post-pin readlink failed (race?):', orig_arg, e.strerror)    if pinned_path != args.dir and not pinned_path.startswith(args.dir_slash):        os.close(fd)        die('post-pin path escaped tree (race?):', orig_arg, pinned_path)    key = (st.st_dev, st.st_ino)    if key in pinned_dirs:        os.close(fd)        return pinned_dirs[key]    make_inheritable(fd)    pinned_fds.append(fd)    pinned_dirs[key] = fd    return fd # Checked receiver-side directory options, split by what rsync does with a# missing one.  It creates these itself on demand, so rrsync creates and pins# them (0700 for the partial dir, which is what rsync uses -- partial files are# incomplete copies of the peer's data and rsync deliberately does not publish# them to the rest of the machine).CREATE_DIR_MODE = {'--backup-dir': 0o777, '--partial-dir': 0o700}# It requires this one to exist already, so a missing one stays an error.MUST_EXIST_DIR_OPTS = ('--temp-dir',)# And it only READS through these: a missing one is the ordinary first-run case# and must keep working, so stand in an empty directory rather than refusing.EMPTY_BASIS_OPTS = ('--link-dest', '--compare-dest', '--copy-dest') def pinned_empty_dir(orig_arg):    """Pin an empty unlinked directory to stand in for a missing basis dir.     A basis lookup cannot tell an empty directory from a missing one, so this    preserves "first run has no basis" exactly -- but without leaving a name    the peer can win: the directory is created inside the restricted dir,    opened, then unlinked while we keep the fd, so what rsync is handed has no    path at all for an in-band symlink to take over.    """    name = '.rrsync-empty-basis.%d' % os.getpid()    rootfd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)    try:        os.mkdir(name, 0o700, dir_fd=rootfd)    except FileExistsError:        pass   # our own leftover, or something planted; the open decides    except OSError as e:        os.close(rootfd)        die('unable to create basis placeholder:', orig_arg, e.strerror)    try:        fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,                     dir_fd=rootfd)    except OSError as e:        os.close(rootfd)        die('unable to pin basis placeholder:', orig_arg, e.strerror)    try:        os.rmdir(name, dir_fd=rootfd)    except FileNotFoundError:        pass   # already gone; we hold the inode either way    except OSError as e:        # The whole point is that rsync gets an inode with no name.  If the        # name survives, the peer can still reach and fill the directory, so        # this is not a placeholder we can safely hand over.        os.close(rootfd)        os.close(fd)        die('unable to detach basis placeholder:', orig_arg, e.strerror)    os.close(rootfd)    if os.listdir(fd):        os.close(fd)        die('basis placeholder is not empty:', orig_arg)    make_inheritable(fd)    pinned_fds.append(fd)    return '/proc/self/fd/%d' % fd def create_pinned_dir(path, orig_arg, mode):    """Create a missing receiver-option directory and return a pin of it.     rsync makes --backup-dir/--partial-dir itself and then works inside it, so    the parent-pinned /proc/self/fd/<parent>/<leaf> spelling is not enough: the    same transfer can plant a symlink at <leaf> first and rsync would create    through it, outside the restricted dir.  Creating it here, beneath the    already-pinned parent, means the name is a real directory before rsync ever    looks at it, and the O_NOFOLLOW reopen proves we hold what we made rather    than something that raced in between.    """    # Walk down from the restricted dir a component at a time, creating what    # is missing.  rsync builds a whole missing --backup-dir hierarchy itself    # (backup.c make_bak_dir()), so stopping at "the immediate parent must    # exist" would refuse a first-use dated/nested name that works today.    #    # O_NOFOLLOW on every component costs nothing and rules out a symlink    # anywhere along the way: `path` is a realpath, so none of its components    # is legitimately a symlink, and each open is relative to the fd we are    # already holding rather than to a name that could be reshaped underneath.    fd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)   # the chdir'd restricted dir    for comp in os.path.relpath(path, args.dir).split(os.sep):        if comp in ('', '.', '..'):            os.close(fd)            die('bad receiver option path:', orig_arg)        try:            nfd = os.open(comp, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,                          dir_fd=fd)        except FileNotFoundError:            try:                os.mkdir(comp, mode, dir_fd=fd)            except FileExistsError:                pass   # raced in; the open below decides if it is usable            except OSError as e:                os.close(fd)                die('unable to create receiver option dir:',                    orig_arg, e.strerror)            try:                nfd = os.open(comp,                              os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,                              dir_fd=fd)            except OSError as e:                os.close(fd)                die('receiver option path is not a usable directory:',                    orig_arg, e.strerror)        except OSError as e:            os.close(fd)            die('receiver option path is not a usable directory:',                orig_arg, e.strerror)        os.close(fd)        fd = nfd    try:        dpath = os.readlink('/proc/self/fd/%d' % fd)    except OSError as e:        os.close(fd)        die('post-pin readlink failed (race?):', orig_arg, e.strerror)    if dpath != args.dir and not dpath.startswith(args.dir_slash):        os.close(fd)        die('post-pin path escaped tree (race?):', orig_arg, dpath)    make_inheritable(fd)    pinned_fds.append(fd)    return '/proc/self/fd/%d' % fd # sender_pinned_arg() verdicts that are not a rewritten argument.KEEP_LEAF_PIN = 'keep'          # hand rsync the leaf's own /proc/self/fd/NLEAF_PIN_UNUSABLE = 'unusable'  # no pin for this shape; keep the plain name def sender_pinned_arg(fd, arg, orig_arg, has_slash, has_slash_dot):    """Return a source name rsync will both resolve safely and name correctly.     The obvious rewrite -- hand rsync /proc/self/fd/N for the leaf itself --    only works where rsync open()s the argument.  A sender lstat()s it first,    and lstat of a procfs magic link is always S_IFLNK, so rsync describes the    argument as a symlink instead of sending the file.  Which pin is usable    therefore depends on what rsync does with the argument:       * a trailing "/" or "/." directory is opened, not lstat()ed, and rsync        DOES follow a symlink there, so it keeps the leaf pin;      * anything else keeps the pin one level up and passes the leaf by name.        rsync will not follow a symlink at that position (it sends the symlink        itself), the follow options that would change that are already disabled        for a restricted dir, and its own leaf open is O_NOFOLLOW.     Under --relative the transmitted name is the whole argument rather than    its basename, so the pin has to move up to wherever that name starts and    the rest is spelled after a /./ marker, which is how rsync is told where    the transmitted portion begins.    """    if client_relative:        # Look for the marker in the argument as the client spelled it: the        # caller has already split off a trailing "/" or "/.", which is exactly        # what turns "sub/./" into a terminal marker.        full = arg + ('/' if has_slash else '/.' if has_slash_dot else '')        # rsync honours the FIRST /./, so split there and keep the client's        # marker rather than inserting a second, earlier one.        head, marker, tail = full.partition('/./')        if marker:            anchor, suffix = head, tail.rstrip('/')        else:            anchor, suffix = '', arg        if suffix.startswith('/'):            return LEAF_PIN_UNUSABLE        if suffix in ('', '.'):            # A terminal marker: everything the client wants transmitted starts            # at the argument itself, which is the directory we already pinned.            # The caller re-appends the trailing "/" or "/.".            dfd = pin_dir(anchor, orig_arg)            check = '.'            pinned = '/proc/self/fd/%d/.' % dfd        else:            dfd = pin_dir(anchor, orig_arg)            pinned = '/proc/self/fd/%d/./%s' % (dfd, suffix)            check = suffix    else:        if has_slash or has_slash_dot:            return KEEP_LEAF_PIN        anchor, _, leaf = arg.rpartition('/')        if not leaf or leaf in ('.', '..'):            return LEAF_PIN_UNUSABLE        dfd = pin_dir(anchor, orig_arg)        pinned = '/proc/self/fd/%d/%s' % (dfd, leaf)        check = leaf     # Tie the pinned directory to the inode realpath() validated: resolving    # `check` beneath the held fd cannot be redirected above the leaf, so if it    # does not reach the same file, something was flipped -- fail closed.    # fd is None for a leaf we deliberately never opened (a symlink, or a    # device/FIFO/socket): there is no inode to compare against, and the leaf    # was never going to be content-opened by the sender either.    if fd is None:        return pinned    try:        st = os.stat(check, dir_fd=dfd)    except OSError as e:        die('post-pin stat failed (race?):', orig_arg, e.strerror)    leaf_st = os.fstat(fd)    if (st.st_dev, st.st_ino) != (leaf_st.st_dev, leaf_st.st_ino):        die('post-pin path changed (race?):', orig_arg, check)    return pinned def safe_open_logfile():    nofollow = getattr(os, 'O_NOFOLLOW', 0)    try:        st = os.lstat(LOGFILE)    except OSError:        return None    if not stat.S_ISREG(st.st_mode):        return None    try:        fd = os.open(LOGFILE, os.O_WRONLY | os.O_APPEND | nofollow)    except OSError:        return None    st2 = os.fstat(fd)    if not stat.S_ISREG(st2.st_mode) or st.st_dev != st2.st_dev or st.st_ino != st2.st_ino:        os.close(fd)        return None    return os.fdopen(fd, 'a') def main():    if not os.path.isdir(args.dir):        die("Restricted directory does not exist!")     # The format of the environment variables set by sshd:    #   SSH_ORIGINAL_COMMAND:    #     rsync --server          -vlogDtpre.iLsfxCIvu --etc . ARG  # push    #     rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull    #   SSH_CONNECTION (client_ip client_port server_ip server_port):    #     192.168.1.100 64106 192.168.1.2 22     command = os.environ.get('SSH_ORIGINAL_COMMAND', None)    if not command:        die("Not invoked via sshd")    if command == 'true':        # Allow checking connectivity with "ssh <host> true".  (For example,        # rsbackup uses this.)        sys.exit(0)    command = command.split(' ', 2)    if command[0:1] != ['rsync']:        die("SSH_ORIGINAL_COMMAND does not run rsync")    if command[1:2] != ['--server']:        die("--server option is not the first arg")    command = '' if len(command) < 3 else command[2]     global am_sender    am_sender = command.startswith("--sender ") # Restrictive on purpose!    if args.ro and not am_sender:

Lines 1–500 of 1003.