From: Wayne Davison Date: Sun, 26 Dec 2021 20:29:00 +0000 (-0800) Subject: rrsync improvements X-Git-Tag: v3.2.4pre1~17 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=72adf49ba8cb81426e2b9799fbd43c6284b013a9;p=thirdparty%2Frsync.git rrsync improvements - Convert rrsync to python. - Enhance security of arg & option checking. - Reject `-L` (`--copy-links`) by default. - Add `-munge` and `-no-del` options. - Tweak the logfile line format. - Created an rrsync man page. - Use `configure --with-rrsync` if you want `make install` to install rrsync and its man page. - Give lsh more rrsync testing support. --- diff --git a/Makefile.in b/Makefile.in index 3c8c2240..5eed339e 100644 --- a/Makefile.in +++ b/Makefile.in @@ -6,6 +6,7 @@ exec_prefix=@exec_prefix@ bindir=@bindir@ libdir=@libdir@/rsync mandir=@mandir@ +with_rrsync=@with_rrsync@ LIBS=@LIBS@ CC=@CC@ @@ -80,6 +81,10 @@ install: all if test -f rsync.1; then $(INSTALLMAN) -m 644 rsync.1 $(DESTDIR)$(mandir)/man1; fi if test -f rsync-ssl.1; then $(INSTALLMAN) -m 644 rsync-ssl.1 $(DESTDIR)$(mandir)/man1; fi if test -f rsyncd.conf.5; then $(INSTALLMAN) -m 644 rsyncd.conf.5 $(DESTDIR)$(mandir)/man5; fi + if test "$(with_rrsync)" = yes; then \ + $(INSTALLCMD) -m 755 $(srcdir)/support/rrsync $(DESTDIR)$(bindir); \ + if test -f rrsync.1; then $(INSTALLMAN) -m 644 rrsync.1 $(DESTDIR)$(mandir)/man1; fi; \ + fi install-ssl-daemon: stunnel-rsyncd.conf -$(MKDIR_P) $(DESTDIR)/etc/stunnel @@ -247,7 +252,7 @@ proto.h-tstamp: $(srcdir)/*.c $(srcdir)/lib/compat.c daemon-parm.h $(AWK) -f $(srcdir)/mkproto.awk $(srcdir)/*.c $(srcdir)/lib/compat.c daemon-parm.h .PHONY: man -man: rsync.1 rsync-ssl.1 rsyncd.conf.5 +man: rsync.1 rsync-ssl.1 rsyncd.conf.5 rrsync.1 rsync.1: rsync.1.md md2man version.h Makefile @$(srcdir)/maybe-make-man $(srcdir) rsync.1.md @@ -258,6 +263,9 @@ rsync-ssl.1: rsync-ssl.1.md md2man version.h Makefile rsyncd.conf.5: rsyncd.conf.5.md md2man version.h Makefile @$(srcdir)/maybe-make-man $(srcdir) rsyncd.conf.5.md +rrsync.1: support/rrsync.1.md md2man Makefile + @$(srcdir)/maybe-make-man $(srcdir) support/rrsync.1.md + .PHONY: clean clean: cleantests rm -f *~ $(OBJS) $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_SYMLINKS) \ diff --git a/NEWS.md b/NEWS.md index eaa82b39..b3002e89 100644 --- a/NEWS.md +++ b/NEWS.md @@ -97,9 +97,15 @@ - More ASM optimizations from Shark64. - - Make rrsync pass --munge-links to rsync by default to make the restricted - dir extra safe (with an option to turn it off if you trust your users). - Also updated the known options list. + - Transformed rrsync into a python script with improvements: security has been + beefed up; the known rsync options were updated to include recent additions; + rrsync rejects `-L` (`--copy-links`) by default to make it harder to exploit + any out-of-subdir symlinks; a new rrsync option of `-munge` tells rrsync to + always enable the `--munge-links` rsync option on the server side; a new + rrsync option of `-no-del` disables all `--remove*` and `--delete*` rsync + options on the server side; the log format has been tweaked slightly to add + seconds to the timestamp and output the command executed as a tuple; an + rrsync.1 manpage is now created. - Work around a glibc bug where lchmod() breaks in a chroot w/o /proc mounted. @@ -107,6 +113,12 @@ ### PACKAGING RELATED: + - Give configure the --with-rrsync option if you want `make install` to + install the (now python3) rrsync script and its (new) man page. + + - If the rrsync script is installed, make its package depend on python3 and + (suggested but not required) the python3 braceexpand lib. + - When creating a package from a non-release version (w/o a git checkout), the packager can elect to create git-version.h and define RSYNC_GITVER to the string they want `--version` to output. (The file is still auto-generated diff --git a/configure.ac b/configure.ac index 9e7338cf..84111de8 100644 --- a/configure.ac +++ b/configure.ac @@ -136,6 +136,13 @@ if test x"$GCC" = x"yes"; then CFLAGS="$CFLAGS -Wall -W" fi +AC_ARG_WITH(rrsync, + AS_HELP_STRING([--with-rrsync],[also install the rrsync script and its man page])) +if test x"$with_rrsync" != x"yes"; then + with_rrsync=no +fi +AC_SUBST(with_rrsync) + AC_ARG_WITH(included-popt, AS_HELP_STRING([--with-included-popt],[use bundled popt library, not from system])) diff --git a/maybe-make-man b/maybe-make-man index b7f0a9f1..59f2dce4 100755 --- a/maybe-make-man +++ b/maybe-make-man @@ -37,4 +37,4 @@ if [ ! -f "$flagfile" ]; then fi fi -"$srcdir/md2man" "$srcdir/$inname" +"$srcdir/md2man" -s "$srcdir" "$srcdir/$inname" diff --git a/md2man b/md2man index fa1d2e82..fd546f19 100755 --- a/md2man +++ b/md2man @@ -85,7 +85,9 @@ def main(): die('Failed to parse NAME.NUM.md out of input file:', args.mdfile) fi = argparse.Namespace(**fi.groupdict()) - if not fi.srcdir: + if args.srcdir: + fi.srcdir = args.srcdir + '/' + elif not fi.srcdir: fi.srcdir = './' fi.title = fi.prog + '(' + fi.sect + ') man page' @@ -105,7 +107,7 @@ def main(): for fn in (fi.srcdir + 'version.h', 'Makefile'): try: st = os.lstat(fn) - except: + except OSError: die('Failed to find', fi.srcdir + fn) if not fi.mtime: fi.mtime = st.st_mtime @@ -129,6 +131,10 @@ def main(): if var == 'srcdir': break + fi.prog_ver = 'rsync ' + env_subs['VERSION'] + if fi.prog != 'rsync': + fi.prog_ver = fi.prog + ' from ' + fi.prog_ver + with open(fi.fn, 'r', encoding='utf-8') as fh: txt = fh.read() @@ -140,7 +146,7 @@ def main(): txt = None fi.date = time.strftime('%d %b %Y', time.localtime(fi.mtime)) - fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog + ' ' + env_subs['VERSION'], env_subs['prefix']) + fi.man_headings = (fi.prog, fi.sect, fi.date, fi.prog_ver, env_subs['prefix']) HtmlToManPage(fi) @@ -374,6 +380,7 @@ def die(*msg): if __name__ == '__main__': parser = argparse.ArgumentParser(description='Transform a NAME.NUM.md markdown file into a NAME.NUM.html web page & a NAME.NUM man page.', add_help=False) + parser.add_argument('--srcdir', '-s', help='Specify the source dir if the input file is not in it.') parser.add_argument('--test', action='store_true', help='Test if we can parse the input w/o updating any files.') parser.add_argument('--debug', '-D', action='count', default=0, help='Output copious info on the html parsing. Repeat for even more.') parser.add_argument("--help", "-h", action="help", help="Output this help message and exit.") diff --git a/packaging/cull_options b/packaging/cull_options index 85311c7c..d4e1c626 100755 --- a/packaging/cull_options +++ b/packaging/cull_options @@ -7,7 +7,7 @@ import re, argparse short_no_arg = { } short_with_num = { '@': 1 }; -long_opt = { # These include some extra long-args that BackupPC uses: +long_opts = { # These include some extra long-args that BackupPC uses: 'block-size': 1, 'daemon': -1, 'debug': 1, @@ -25,6 +25,7 @@ long_opt = { # These include some extra long-args that BackupPC uses: 'owner': 0, 'perms': 0, 'recursive': 0, + 'stderr': 1, 'times': 0, 'write-devices': -1, } @@ -49,8 +50,8 @@ def main(): m = re.search(r'args\[ac\+\+\] = "--([^"=]+)"', line) if m: last_long_opt = m.group(1) - if last_long_opt not in long_opt: - long_opt[last_long_opt] = 0 + if last_long_opt not in long_opts: + long_opts[last_long_opt] = 0 else: last_long_opt = None continue @@ -58,13 +59,13 @@ def main(): if last_long_opt: m = re.search(r'args\[ac\+\+\] = ([^["\s]+);', line) if m: - long_opt[last_long_opt] = 2 + long_opts[last_long_opt] = 2 last_long_opt = None continue m = re.search(r'return "--([^"]+-dest)";', line) if m: - long_opt[m.group(1)] = 2 + long_opts[m.group(1)] = 2 last_long_opt = None continue @@ -74,19 +75,18 @@ def main(): if not m: m = re.search(r'fmt = .*: "--([^"=]+)=', line) if m: - long_opt[m.group(1)] = 1 + long_opts[m.group(1)] = 1 last_long_opt = None - long_opt['files-from'] = 3 + long_opts['files-from'] = 3 - txt = """ -# These options are the only options that rsync might send to the server, -# and only in the option format that the stock rsync produces. + txt = """\ +### START of options data produced by the cull_options script. ### # To disable a short-named option, add its letter to this string: """ - txt += str_assign('short_disabled', 's') + "\n" + txt += str_assign('short_disabled', 'Ls') + "\n" txt += str_assign('short_no_arg', ''.join(sorted(short_no_arg)), 'DO NOT REMOVE ANY') txt += str_assign('short_with_num', ''.join(sorted(short_with_num)), 'DO NOT REMOVE ANY') @@ -99,24 +99,24 @@ def main(): print(txt, end='') if args.python: - print("long_opt = {") + print("long_opts = {") sep = ':' else: print("our %long_opt = (") sep = ' =>' - for opt in sorted(long_opt): + for opt in sorted(long_opts): if opt.startswith(('min-', 'max-')): val = 1 else: - val = long_opt[opt] + val = long_opts[opt] print(' ', repr(opt) + sep, str(val) + ',') if args.python: print("}") else: print(");") - print('') + print("\n### END of options data produced by the cull_options script. ###") def str_assign(name, val, comment=None): @@ -129,10 +129,12 @@ def str_assign(name, val, comment=None): if __name__ == '__main__': parser = argparse.ArgumentParser(description="Output culled rsync options for rrsync.", add_help=False) out_group = parser.add_mutually_exclusive_group() - out_group.add_argument('--perl', action='store_true', help="Output perl code (the default).") - out_group.add_argument('--python', action='store_true', help="Output python code.") + out_group.add_argument('--perl', action='store_true', help="Output perl code.") + out_group.add_argument('--python', action='store_true', help="Output python code (the default).") parser.add_argument('--help', '-h', action='help', help="Output this help message and exit.") args = parser.parse_args() + if not args.perl: + args.python = True main() # vim: sw=4 et diff --git a/support/lsh b/support/lsh index ebfe898c..40fe3d73 100755 --- a/support/lsh +++ b/support/lsh @@ -18,6 +18,8 @@ GetOptions( 'rrsync=s' => \( my $rrsync_dir ), 'ro' => \( my $rrsync_ro = '' ), 'wo' => \( my $rrsync_wo = '' ), + 'munge' => \( my $rrsync_munge = '' ), + 'no-del' => \( my $rrsync_no_del = '' ), ) or &usage; &usage unless @ARGV > 1; @@ -71,16 +73,12 @@ unless ($no_chdir) { } if ($rrsync_dir) { - my $cmd = ''; - foreach (@ARGV) { - (my $arg = $_) =~ s/(['";|()\[\]{}\$!*?<> \t&~\\])/\\$1/g; - $cmd .= ' ' . $arg; - } - $cmd =~ s/^\s+//; - $ENV{SSH_ORIGINAL_COMMAND} = $cmd; + $ENV{SSH_ORIGINAL_COMMAND} = join(' ', @ARGV); push @cmd, 'rrsync'; push @cmd, '-ro' if $rrsync_ro; push @cmd, '-wo' if $rrsync_wo; + push @cmd, '-munge' if $rrsync_munge; + push @cmd, '-no-del' if $rrsync_no_del; push @cmd, $rrsync_dir; } else { push @cmd, '/bin/sh', '-c', "@ARGV"; diff --git a/support/rrsync b/support/rrsync index 4c5dd2aa..5b43a819 100755 --- a/support/rrsync +++ b/support/rrsync @@ -1,282 +1,353 @@ -#!/usr/bin/env perl -# Name: /usr/local/bin/rrsync (should also have a symlink in /usr/bin) -# Purpose: Restricts rsync to subdirectory declared in .ssh/authorized_keys -# Author: Joe Smith 30-Sep-2004 -# Modified by: Wayne Davison -use strict; - -use Socket; -use Cwd 'abs_path'; -use File::Glob ':glob'; - -# You may configure these values to your liking. See also the section -# of options if you want to disable any options that rsync accepts. -use constant RSYNC => '/usr/bin/rsync'; -use constant LOGFILE => 'rrsync.log'; - -my $Usage = < 30-Sep-2004 +# Python version by: Wayne Davison + +# 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). -# These options are the only options that rsync might send to the server, -# and only in the option format that the stock rsync produces. +# 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: -our $short_disabled = 's'; +short_disabled = 'Ls' -our $short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz'; # DO NOT REMOVE ANY -our $short_with_num = '@B'; # DO NOT REMOVE ANY +short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANY +short_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. -our %long_opt = ( - '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, - 'copy-dest' => 2, - '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, - 'existing' => 0, - 'fake-super' => 0, - 'files-from' => 3, - 'force' => 0, - 'from0' => 0, - 'fsync' => 2, - '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, - 'suffix' => 1, - 'super' => 0, - 'temp-dir' => 2, - 'timeout' => 1, - 'times' => 0, - 'use-qsort' => 0, - 'usermap' => 1, - 'write-devices' => -1, -); +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, + 'copy-dest': 2, + '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, + 'existing': 0, + 'fake-super': 0, + 'files-from': 3, + 'force': 0, + 'from0': 0, + 'fsync': 2, + '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. ### -if ($only eq 'r') { - foreach my $opt (keys %long_opt) { - if ($opt =~ /^(remove-|log-file)/) { - $long_opt{$opt} = -1; - } - } -} elsif ($only eq 'w') { - $long_opt{'sender'} = -1; -} +import os, sys, re, argparse, glob, socket, time +from argparse import RawTextHelpFormatter -if ($short_disabled ne '') { - $short_no_arg =~ s/[$short_disabled]//go; - $short_with_num =~ s/[$short_disabled]//go; -} -$short_no_arg = "[$short_no_arg]" if length($short_no_arg) > 1; -$short_with_num = "[$short_with_num]" if length($short_with_num) > 1; - -my $write_log = -f LOGFILE && open(LOG, '>>', LOGFILE); - -chdir($subdir) or die "$0: Unable to chdir to restricted dir: $!\n"; - -my(@opts, @args); -my $in_options = 1; -my $last_opt = ''; -my $check_type; -while ($command =~ /((?:[^\s\\]+|\\.[^\s\\]*)+)/g) { - $_ = $1; - if ($check_type) { - push(@opts, check_arg($last_opt, $_, $check_type)); - $check_type = 0; - } elsif ($in_options) { - if ($_ eq '.') { - $in_options = 0; - } else { - die "$0: invalid option: '-'\n" if $_ eq '-'; - push(@opts, $_); - next if /^-$short_no_arg*(e\d*\.\w*)?$/o || /^-$short_with_num\d+$/o; - - my($opt,$arg) = /^--([^=]+)(?:=(.*))?$/; - my $disabled; - if (defined $opt) { - my $ct = $long_opt{$opt}; - last unless defined $ct; - next if $ct == 0; - if ($ct > 0) { - if (!defined $arg) { - $check_type = $ct; - $last_opt = $opt; - next; - } - $arg = check_arg($opt, $arg, $ct); - $opts[-1] =~ s/=.*/=$arg/; - next; - } - $disabled = 1; - $opt = "--$opt"; - } elsif ($short_disabled ne '') { - $disabled = /^-$short_no_arg*([$short_disabled])/o; - $opt = "-$1"; - } - - last unless $disabled; # Generate generic failure - die "$0: option $opt has been disabled on this server.\n"; - } - } else { - if ($subdir ne '/') { - # Validate args to ensure they don't try to leave our restricted dir. - s{//+}{/}g; - s{^/}{}; - s{^$}{.}; - } - push(@args, bsd_glob($_, GLOB_LIMIT|GLOB_NOCHECK|GLOB_BRACE|GLOB_QUOTE)); - } -} -die "$0: invalid rsync-command syntax or options\n" if $in_options; +try: + from braceexpand import braceexpand +except: + braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ] -if ($subdir ne '/') { - die "$0: do not use .. in any path!\n" if grep m{(^|/)\.\.(/|$)}, @args; -} +HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)') +LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$') +DE_BACKSLASH_RE = re.compile(r'\\(.)') -if ($force_munge) { - push(@opts, '--munge-links'); -} +def main(): + if not os.path.isdir(args.dir): + die("Restricted directory does not exist!") -@args = ( '.' ) if !@args; + # 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 -if ($write_log) { - my ($mm,$hh) = (localtime)[1,2]; - my $host = $ENV{SSH_CONNECTION} || 'unknown'; - $host =~ s/ .*//; # Keep only the client's IP addr - $host =~ s/^::ffff://; - $host = gethostbyaddr(inet_aton($host),AF_INET) || $host; - printf LOG "%02d:%02d %-13s [%s]\n", $hh, $mm, $host, "@opts @args"; - close LOG; -} + command = os.environ.get('SSH_ORIGINAL_COMMAND', None) + if not command: + die("Not invoked via sshd") + 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] -# Note: This assumes that the rsync protocol will not be maliciously hijacked. -exec(RSYNC, @opts, '--', '.', @args) or die "exec(rsync @opts -- . @args) failed: $? $!"; - -sub check_arg -{ - my($opt, $arg, $type) = @_; - $arg =~ s/\\(.)/$1/g; - if ($subdir ne '/' && ($type == 3 || ($type == 2 && !$am_sender))) { - $arg =~ s{//}{/}g; - die "Do not use .. in --$opt; anchor the path at the root of your restricted dir.\n" - if $arg =~ m{(^|/)\.\.(/|$)}; - $arg =~ s{^/}{$subdir/}; - } - $arg; -} + global am_sender + am_sender = command.startswith("--sender ") # Restrictive on purpose! + if args.ro and not am_sender: + die("sending to read-only server is not allowed") + if args.wo and am_sender: + die("reading from write-only server is not allowed") + + if args.wo or not am_sender: + long_opts['sender'] = -1 + if args.no_del: + for opt in long_opts: + if opt.startswith(('remove', 'delete')): + long_opts[opt] = -1 + if args.ro: + long_opts['log-file'] = -1 + + short_no_arg_re = short_no_arg + short_with_num_re = short_with_num + if short_disabled: + for ltr in short_disabled: + short_no_arg_re = short_no_arg_re.replace(ltr, '') + short_with_num_re = short_with_num_re.replace(ltr, '') + short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled)) + short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re) + short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re) + + log_fh = open(LOGFILE, 'a') if os.path.isfile(LOGFILE) else None + + try: + os.chdir(args.dir) + except OSError as e: + die('unable to chdir to restricted dir:', str(e)) + + rsync_opts = [ '--server' ] + rsync_args = [ ] + saw_the_dot_arg = False + last_opt = check_type = None + + for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command): + if check_type: + rsync_opts.append(validated_arg(last_opt, arg, check_type)) + check_type = None + elif saw_the_dot_arg: + # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple. + try: + b_e = braceexpand(arg) # Also removes backslashes + except: # Handle errors such as unbalanced braces by just de-backslashing the arg: + b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ] + for xarg in b_e: + rsync_args += validated_arg('arg', xarg, wild=True) + else: # parsing the option args + if arg == '.': + saw_the_dot_arg = True + continue + rsync_opts.append(arg) + if short_no_arg_re.match(arg) or short_with_num_re.match(arg): + continue + disabled = False + m = LONG_OPT_RE.match(arg) + if m: + opt = m.group(1) + opt_arg = m.group(2) + ct = long_opts.get(opt, None) + if ct is None: + break # Generate generic failure due to unfinished arg parsing + if ct == 0: + continue + opt = '--' + opt + if ct > 0: + if opt_arg is not None: + rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct) + else: + check_type = ct + last_opt = opt + continue + disabled = True + elif short_disabled: + m = short_disabled_re.match(arg) + if m: + disabled = True + opt = '-' + m.group(1) + + if disabled: + die("option", opt, "has been disabled on this server.") + break # Generate a generic failure + + if not saw_the_dot_arg: + die("invalid rsync-command syntax or options") + + if args.munge: + rsync_opts.append('--munge-links') + + if not rsync_args: + rsync_args = [ '.' ] + + cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args) + + if log_fh: + now = time.localtime() + host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr + if host.startswith('::ffff:'): + host = host[7:] + try: + host = socket.gethostbyaddr(socket.inet_aton(host)) + except: + pass + log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd))) + log_fh.close() + + # NOTE: This assumes that the rsync protocol will not be maliciously hijacked. + os.execlp(RSYNC, *cmd) + die("execlp(", RSYNC, *cmd, ') failed') + + +def validated_arg(opt, arg, typ=3, wild=False): + if opt != 'arg': # arg values already have their backslashes removed. + arg = DE_BACKSLASH_RE.sub(r'\1', arg) + + orig_arg = arg + if arg.startswith('./'): + arg = arg[1:] + arg = arg.replace('//', '/') + if args.dir != '/': + if HAS_DOT_DOT_RE.search(arg): + die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)") + if arg.startswith('/'): + arg = args.dir + arg + + if wild: + got = glob.glob(arg) + if not got: + got = [ arg ] + else: + got = [ arg ] + + ret = [ ] + for arg in got: + if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)): + arg_has_trailing_slash = arg.endswith('/') + if arg_has_trailing_slash: + arg = arg[:-1] + else: + arg_has_trailing_slash_dot = arg.endswith('/.') + if arg_has_trailing_slash_dot: + arg = arg[:-2] + real_arg = os.path.realpath(arg) + if arg != real_arg and not real_arg.startswith(args.dir_slash): + die('unsafe arg:', orig_arg, [arg, real_arg]) + if arg_has_trailing_slash: + arg += '/' + elif arg_has_trailing_slash_dot: + arg += '/.' + if opt == 'arg' and arg.startswith(args.dir_slash): + arg = arg[args.dir_slash_len:] + if arg == '': + arg = '.' + ret.append(arg) + + return ret if wild else ret[0] + + +def die(*msg): + print(sys.argv[0], 'error:', *msg, file=sys.stderr) + if sys.stdin.isatty(): + arg_parser.print_help(sys.stderr) + sys.exit(1) + + +# This class displays the --help to the user on argparse error IFF they're running it interactively. +class OurArgParser(argparse.ArgumentParser): + def error(self, msg): + die(msg) + + +if __name__ == '__main__': + our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command.""" + arg_parser = OurArgParser(description=our_desc, add_help=False) + only_group = arg_parser.add_mutually_exclusive_group() + only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del.") + only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.") + arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.") + arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.") + 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) + if args.ro: + args.no_del = True + main() -# vim: sw=2 +# vim: sw=4 et diff --git a/support/rrsync.1.md b/support/rrsync.1.md new file mode 100644 index 00000000..b945ecf0 --- /dev/null +++ b/support/rrsync.1.md @@ -0,0 +1,89 @@ +# NAME + +rrsync - a script to setup restricted rsync users via ssh logins + +# SYNOPSIS + +``` +rrsync [-ro|-rw] [-munge] [-no-del] DIR +``` + +# DESCRIPTION + +A user's ssh login can be restricted to only allow the running of an rsync +transfer in one of two easy ways: forcing the running of the rrsync script +or forcing the running of an rsync daemon-over-ssh command. + +To use the rrsync script, add a prefix like one of the following (followed by a +space) in front of each ssh-key line in the user's `~/.ssh/authorized_keys` +file that should be restricted: + +> ``` +> command="rrsync DIR" +> command="rrsync -ro DIR" +> command="rrsync -munge -no-del DIR" +> ``` + +Then, ensure that the rrsync script has your desired option restrictions. You +may want to copy the script to a local bin dir with a unique name if you want +to have multiple configurations. One or more rrsync options can be specified +prior to the `DIR` if you want to further restrict the transfer. + +To use an rsync daemon setup, add one of the following prefixes (followed by a +space) in front of each ssh-key line in the user's `~/.ssh/authorized_keys` +file that should be restricted: + +> ``` +> command="rsync --server --daemon ." +> command="rsync --server --daemon --config=/PATH/TO/rsyncd.conf ." +> ``` + +Then, ensure that the rsyncd.conf file is created with one or more module names +with the appropriate path and option restrictions. If the `--config` option is +omitted, it defaults to `~/rsyncd.conf`. See the `rsyncd.conf` man page for +details of how to configure an rsync daemon. + +The remainder of this man page is dedicated to using the rrsync script. + +# OPTION SUMMARY + +``` +-ro Allow only reading from the DIR. Implies -no-del. +-wo Allow only writing to the DIR. +-no-del Disable rsync's --delete* and --remove* options. +-munge Enable rsync's --munge-links on the server side. +-help, -h Output this help message and exit. +``` + +A single non-option argument specifies the restricted DIR to use. It can be +relative to the user's home directory or an absolute path. + +# SECURITY RESTRICTIONS + +The rrsync script validates the path arguments it is sent to try to restrict +them to staying within the specified DIR. + +The rrsync script rejects rsync's `--copy-links`` option (by default) so that a +copy cannot dereference a symlink within the DIR to get to a file outside the +DIR. + +The rrsync script rejects rsync's `--protect-args` (`-s`) option because it +would allow options to be sent to the server-side that the script could not +check. If you want to support `--protect-args`, use a daemon-over-ssh setup. + +The rrsync script accepts just a subset of rsync's options that the real rsync +uses when running the server command. A few extra convenience options are also +included to help it to interact with BackupPC and accept some convenient user +overrides. + +The script (or a copy of it) can be manually edited if you want it to customize +the option handling. + +# EXAMPLES + +The `.ssh/authorized_keys` file might have lines in it like this: + +> ``` +> command="rrsync client/logs" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAzG... +> command="rrsync -ro results" ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEAmk... +> ```