3 # Restricts rsync to subdirectory declared in .ssh/authorized_keys. See
4 # the rrsync man page for details of how to make use of this script.
6 # NOTE: install python3 braceexpand to support brace expansion in the args!
8 # Originally a perl script by: Joe Smith <js-cgi@inwap.com> 30-Sep-2004
9 # Python version by: Wayne Davison <wayne@opencoder.net>
11 # You may configure these 2 values to your liking. See also the section of
12 # short & long options if you want to disable any options that rsync accepts.
13 RSYNC = '/usr/bin/rsync'
14 LOGFILE = 'rrsync.log' # NOTE: the file must exist for a line to be appended!
16 # The following options are mainly the options that a client rsync can send
17 # to the server, and usually just in the one option format that the stock
18 # rsync produces. However, there are some additional convenience options
19 # added as well, and thus a few options are present in both the short and
20 # long lists (such as --group, --owner, and --perms).
22 # NOTE when disabling: check for both a short & long version of the option!
24 ### START of options data produced by the cull-options script. ###
26 # To disable a short-named option, add its letter to this string:
29 # These are also disabled when the restricted dir is not "/":
30 short_disabled_subdir = 'KLk'
32 # These are all possible short options that we will accept (when not disabled above):
33 short_no_arg = 'ACDEHIJKLNORSUWXbcdgklmnopqrstuvxyz' # DO NOT REMOVE ANY
34 short_with_num = '@B' # DO NOT REMOVE ANY
36 # To disable a long-named option, change its value to a -1. The values mean:
37 # 0 = the option has no arg; 1 = the arg doesn't need any checking; 2 = only
38 # check the arg when receiving; and 3 = always check the arg.
49 'compress-threads': 1,
52 'copy-unsafe-links': 0,
62 'delete-missing-args': 0,
77 'ignore-missing-args': 0,
104 'one-file-system': 0,
105 'only-write-batch': 1,
113 'remove-sent-files': 0,
114 'remove-source-files': 0,
133 ### END of options data produced by the cull-options script. ###
135 import os, sys, re, argparse, glob, socket, stat, time, subprocess
136 from argparse import RawTextHelpFormatter
138 # Held open across exec so rsync inherits them. Each entry pins a path
139 # validated_arg() approved; the corresponding arg passed to rsync is
140 # rewritten to /proc/self/fd/N so rsync's path resolution cannot be
141 # race-flipped after rrsync's realpath check, closing the realpath-vs-exec
145 # Directory pins, keyed by (st_dev, st_ino), so a glob or a multi-arg command
146 # whose args share a parent inherits one fd rather than one per arg.
149 # Whether the client asked for --relative/-R, which decides how much of a
150 # sender arg rsync transmits as the file's name (see sender_pinned_arg).
151 client_relative = False
153 # The inode-pin trick needs /proc/self/fd/N to be a Linux-style magic symlink
154 # whose readlink yields the open file's real path. macOS/BSD lack the directory
155 # entirely; Solaris HAS /proc/self/fd but its entries are not such symlinks (its
156 # readlink does not return the path), so an isdir() check is not enough -- probe
157 # the actual behaviour once against a known fd. Where it works we pin (and a
158 # later readlink failure is an anomaly that fails closed); where it does not we
159 # fall through to the unhardened path.
161 # A correct readlink is NOT sufficient evidence that the fd pins anything, and
162 # the two platforms that get this wrong fail in opposite directions:
164 # * NetBSD makes the entry a symlink for DIRECTORIES only -- readlink of a
165 # regular file's entry fails with EINVAL, so a directory-only probe claims
166 # support that is not there and every pull of a file dies in the post-pin
168 # * Cygwin's readlink returns the right path, but opening the magic link
169 # RE-RESOLVES it: rename the directory out from under a held fd and the
170 # magic link reaches the replacement. The pin silently protects nothing.
172 # Only the Linux kernel gives the inode-bound magic link this relies on, so
173 # require that explicitly and keep the runtime probes as a guard for Linux-like
174 # environments where /proc is absent or restricted (containers, seccomp).
175 def _probe_proc_self_fd():
176 if not sys.platform.startswith(('linux', 'android')):
181 fd = os.open(path, os.O_RDONLY)
185 return os.readlink('/proc/self/fd/%d' % fd) == os.path.realpath(path)
191 return resolves('/') and resolves(os.path.realpath(__file__))
193 HAVE_PROC_SELF_FD = _probe_proc_self_fd()
196 from braceexpand import braceexpand
198 braceexpand = lambda x: [ DE_BACKSLASH_RE.sub(r'\1', x) ]
200 HAS_DOT_DOT_RE = re.compile(r'(^|/)\.\.(/|$)')
201 LONG_OPT_RE = re.compile(r'^--([^=]+)(?:=(.*))?$')
202 DE_BACKSLASH_RE = re.compile(r'\\(.)')
204 def make_inheritable(fd):
205 """Clear FD_CLOEXEC so the exec'd rsync inherits `fd`.
207 os.set_inheritable() prefers ioctl(FIONCLEX), which an O_PATH descriptor
208 rejects with EBADF on older kernels; F_SETFD is one of the few operations
209 O_PATH always allows.
212 os.set_inheritable(fd, True)
215 fcntl.fcntl(fd, fcntl.F_SETFD,
216 fcntl.fcntl(fd, fcntl.F_GETFD) & ~fcntl.FD_CLOEXEC)
218 def pin_dir(path, orig_arg):
219 """Inode-pin a directory and return an fd rsync will inherit.
221 The open resolves `path` normally -- including a symlink at its last
222 component, which is legitimate and which 3.4.4 accepts -- so a component
223 could be flipped first. The readlink check afterwards is what makes that
224 safe: it proves the inode we ended up holding is inside the restricted
225 tree. From then on the fd names that inode, not the path, so nothing above
226 it can be flipped again.
228 O_PATH, not O_RDONLY: reaching a known name beneath a directory needs only
229 search permission, and a mode 0111 parent is a perfectly ordinary way to
230 publish a file without letting it be listed. An O_PATH directory fd is
231 just as firmly pinned when used as a /proc/self/fd/N/... prefix (the O_PATH
232 caveat in validated_arg() is about reopening the magic link as the file
233 itself, which is not what happens here).
235 flags = os.O_DIRECTORY | getattr(os, 'O_PATH', 0)
236 if not flags & getattr(os, 'O_PATH', 0):
239 fd = os.open(path or '.', flags)
241 die('unable to pin sender path:', orig_arg, e.strerror)
244 pinned_path = os.readlink('/proc/self/fd/%d' % fd)
247 die('post-pin readlink failed (race?):', orig_arg, e.strerror)
248 if pinned_path != args.dir and not pinned_path.startswith(args.dir_slash):
250 die('post-pin path escaped tree (race?):', orig_arg, pinned_path)
251 key = (st.st_dev, st.st_ino)
252 if key in pinned_dirs:
254 return pinned_dirs[key]
256 pinned_fds.append(fd)
257 pinned_dirs[key] = fd
260 # Checked receiver-side directory options, split by what rsync does with a
261 # missing one. It creates these itself on demand, so rrsync creates and pins
262 # them (0700 for the partial dir, which is what rsync uses -- partial files are
263 # incomplete copies of the peer's data and rsync deliberately does not publish
264 # them to the rest of the machine).
265 CREATE_DIR_MODE = {'--backup-dir': 0o777, '--partial-dir': 0o700}
266 # It requires this one to exist already, so a missing one stays an error.
267 MUST_EXIST_DIR_OPTS = ('--temp-dir',)
268 # And it only READS through these: a missing one is the ordinary first-run case
269 # and must keep working, so stand in an empty directory rather than refusing.
270 EMPTY_BASIS_OPTS = ('--link-dest', '--compare-dest', '--copy-dest')
272 def pinned_empty_dir(orig_arg):
273 """Pin an empty unlinked directory to stand in for a missing basis dir.
275 A basis lookup cannot tell an empty directory from a missing one, so this
276 preserves "first run has no basis" exactly -- but without leaving a name
277 the peer can win: the directory is created inside the restricted dir,
278 opened, then unlinked while we keep the fd, so what rsync is handed has no
279 path at all for an in-band symlink to take over.
281 name = '.rrsync-empty-basis.%d' % os.getpid()
282 rootfd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)
284 os.mkdir(name, 0o700, dir_fd=rootfd)
285 except FileExistsError:
286 pass # our own leftover, or something planted; the open decides
289 die('unable to create basis placeholder:', orig_arg, e.strerror)
291 fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
295 die('unable to pin basis placeholder:', orig_arg, e.strerror)
297 os.rmdir(name, dir_fd=rootfd)
298 except FileNotFoundError:
299 pass # already gone; we hold the inode either way
301 # The whole point is that rsync gets an inode with no name. If the
302 # name survives, the peer can still reach and fill the directory, so
303 # this is not a placeholder we can safely hand over.
306 die('unable to detach basis placeholder:', orig_arg, e.strerror)
310 die('basis placeholder is not empty:', orig_arg)
312 pinned_fds.append(fd)
313 return '/proc/self/fd/%d' % fd
315 def create_pinned_dir(path, orig_arg, mode):
316 """Create a missing receiver-option directory and return a pin of it.
318 rsync makes --backup-dir/--partial-dir itself and then works inside it, so
319 the parent-pinned /proc/self/fd/<parent>/<leaf> spelling is not enough: the
320 same transfer can plant a symlink at <leaf> first and rsync would create
321 through it, outside the restricted dir. Creating it here, beneath the
322 already-pinned parent, means the name is a real directory before rsync ever
323 looks at it, and the O_NOFOLLOW reopen proves we hold what we made rather
324 than something that raced in between.
326 # Walk down from the restricted dir a component at a time, creating what
327 # is missing. rsync builds a whole missing --backup-dir hierarchy itself
328 # (backup.c make_bak_dir()), so stopping at "the immediate parent must
329 # exist" would refuse a first-use dated/nested name that works today.
331 # O_NOFOLLOW on every component costs nothing and rules out a symlink
332 # anywhere along the way: `path` is a realpath, so none of its components
333 # is legitimately a symlink, and each open is relative to the fd we are
334 # already holding rather than to a name that could be reshaped underneath.
335 fd = os.open('.', os.O_RDONLY | os.O_DIRECTORY) # the chdir'd restricted dir
336 for comp in os.path.relpath(path, args.dir).split(os.sep):
337 if comp in ('', '.', '..'):
339 die('bad receiver option path:', orig_arg)
341 nfd = os.open(comp, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
343 except FileNotFoundError:
345 os.mkdir(comp, mode, dir_fd=fd)
346 except FileExistsError:
347 pass # raced in; the open below decides if it is usable
350 die('unable to create receiver option dir:',
351 orig_arg, e.strerror)
354 os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY,
358 die('receiver option path is not a usable directory:',
359 orig_arg, e.strerror)
362 die('receiver option path is not a usable directory:',
363 orig_arg, e.strerror)
367 dpath = os.readlink('/proc/self/fd/%d' % fd)
370 die('post-pin readlink failed (race?):', orig_arg, e.strerror)
371 if dpath != args.dir and not dpath.startswith(args.dir_slash):
373 die('post-pin path escaped tree (race?):', orig_arg, dpath)
375 pinned_fds.append(fd)
376 return '/proc/self/fd/%d' % fd
378 # sender_pinned_arg() verdicts that are not a rewritten argument.
379 KEEP_LEAF_PIN = 'keep' # hand rsync the leaf's own /proc/self/fd/N
380 LEAF_PIN_UNUSABLE = 'unusable' # no pin for this shape; keep the plain name
382 def sender_pinned_arg(fd, arg, orig_arg, has_slash, has_slash_dot):
383 """Return a source name rsync will both resolve safely and name correctly.
385 The obvious rewrite -- hand rsync /proc/self/fd/N for the leaf itself --
386 only works where rsync open()s the argument. A sender lstat()s it first,
387 and lstat of a procfs magic link is always S_IFLNK, so rsync describes the
388 argument as a symlink instead of sending the file. Which pin is usable
389 therefore depends on what rsync does with the argument:
391 * a trailing "/" or "/." directory is opened, not lstat()ed, and rsync
392 DOES follow a symlink there, so it keeps the leaf pin;
393 * anything else keeps the pin one level up and passes the leaf by name.
394 rsync will not follow a symlink at that position (it sends the symlink
395 itself), the follow options that would change that are already disabled
396 for a restricted dir, and its own leaf open is O_NOFOLLOW.
398 Under --relative the transmitted name is the whole argument rather than
399 its basename, so the pin has to move up to wherever that name starts and
400 the rest is spelled after a /./ marker, which is how rsync is told where
401 the transmitted portion begins.
404 # Look for the marker in the argument as the client spelled it: the
405 # caller has already split off a trailing "/" or "/.", which is exactly
406 # what turns "sub/./" into a terminal marker.
407 full = arg + ('/' if has_slash else '/.' if has_slash_dot else '')
408 # rsync honours the FIRST /./, so split there and keep the client's
409 # marker rather than inserting a second, earlier one.
410 head, marker, tail = full.partition('/./')
412 anchor, suffix = head, tail.rstrip('/')
414 anchor, suffix = '', arg
415 if suffix.startswith('/'):
416 return LEAF_PIN_UNUSABLE
417 if suffix in ('', '.'):
418 # A terminal marker: everything the client wants transmitted starts
419 # at the argument itself, which is the directory we already pinned.
420 # The caller re-appends the trailing "/" or "/.".
421 dfd = pin_dir(anchor, orig_arg)
423 pinned = '/proc/self/fd/%d/.' % dfd
425 dfd = pin_dir(anchor, orig_arg)
426 pinned = '/proc/self/fd/%d/./%s' % (dfd, suffix)
429 if has_slash or has_slash_dot:
431 anchor, _, leaf = arg.rpartition('/')
432 if not leaf or leaf in ('.', '..'):
433 return LEAF_PIN_UNUSABLE
434 dfd = pin_dir(anchor, orig_arg)
435 pinned = '/proc/self/fd/%d/%s' % (dfd, leaf)
438 # Tie the pinned directory to the inode realpath() validated: resolving
439 # `check` beneath the held fd cannot be redirected above the leaf, so if it
440 # does not reach the same file, something was flipped -- fail closed.
441 # fd is None for a leaf we deliberately never opened (a symlink, or a
442 # device/FIFO/socket): there is no inode to compare against, and the leaf
443 # was never going to be content-opened by the sender either.
447 st = os.stat(check, dir_fd=dfd)
449 die('post-pin stat failed (race?):', orig_arg, e.strerror)
450 leaf_st = os.fstat(fd)
451 if (st.st_dev, st.st_ino) != (leaf_st.st_dev, leaf_st.st_ino):
452 die('post-pin path changed (race?):', orig_arg, check)
455 def safe_open_logfile():
456 nofollow = getattr(os, 'O_NOFOLLOW', 0)
458 st = os.lstat(LOGFILE)
461 if not stat.S_ISREG(st.st_mode):
464 fd = os.open(LOGFILE, os.O_WRONLY | os.O_APPEND | nofollow)
468 if not stat.S_ISREG(st2.st_mode) or st.st_dev != st2.st_dev or st.st_ino != st2.st_ino:
471 return os.fdopen(fd, 'a')
474 if not os.path.isdir(args.dir):
475 die("Restricted directory does not exist!")
477 # The format of the environment variables set by sshd:
478 # SSH_ORIGINAL_COMMAND:
479 # rsync --server -vlogDtpre.iLsfxCIvu --etc . ARG # push
480 # rsync --server --sender -vlogDtpre.iLsfxCIvu --etc . ARGS # pull
481 # SSH_CONNECTION (client_ip client_port server_ip server_port):
482 # 192.168.1.100 64106 192.168.1.2 22
484 command = os.environ.get('SSH_ORIGINAL_COMMAND', None)
486 die("Not invoked via sshd")
487 if command == 'true':
488 # Allow checking connectivity with "ssh <host> true". (For example,
489 # rsbackup uses this.)
491 command = command.split(' ', 2)
492 if command[0:1] != ['rsync']:
493 die("SSH_ORIGINAL_COMMAND does not run rsync")
494 if command[1:2] != ['--server']:
495 die("--server option is not the first arg")
496 command = '' if len(command) < 3 else command[2]
499 am_sender = command.startswith("--sender ") # Restrictive on purpose!
500 if args.ro and not am_sender:
501 die("sending to read-only server is not allowed")
502 if args.wo and am_sender:
503 die("reading from write-only server is not allowed")
505 if args.wo or not am_sender:
506 long_opts['sender'] = -1
508 for opt in long_opts:
509 if opt.startswith(('remove', 'delete')):
512 long_opts['log-file'] = -1
514 global short_disabled
515 if args.no_overwrite:
516 # --ignore-existing guards only the live transfer destination. These
517 # options append to, consume, move or remove other existing objects in
518 # the restricted dir. Backup mode belongs here too: publishing a
519 # backup onto a name that already exists deletes what is there
520 # (backup.c make_backup()), and deletion backs files up as well
521 # (delete.c), so an unrelated --delete can land on a protected name.
522 long_opts['log-file'] = long_opts['partial-dir'] = long_opts['delay-updates'] = -1
523 long_opts['backup-dir'] = -1
524 short_disabled += 'b' # must precede the short_no_arg_re build below
527 short_disabled += short_disabled_subdir
528 long_opts['copy-unsafe-links'] = -1
530 short_no_arg_re = short_no_arg
531 short_with_num_re = short_with_num
533 for ltr in short_disabled:
534 short_no_arg_re = short_no_arg_re.replace(ltr, '')
535 short_with_num_re = short_with_num_re.replace(ltr, '')
536 short_disabled_re = re.compile(r'^-[%s]*([%s])' % (short_no_arg_re, short_disabled))
537 short_no_arg_re = re.compile(r'^-(?=.)[%s]*(e\d*\.\w*)?$' % short_no_arg_re)
538 short_with_num_re = re.compile(r'^-[%s]\d+$' % short_with_num_re)
540 log_fh = safe_open_logfile()
545 die('unable to chdir to restricted dir:', str(e))
547 global client_relative
548 rsync_opts = [ '--server' ]
550 saw_the_dot_arg = False
551 last_opt = check_type = None
553 for arg in re.findall(r'(?:[^\s\\]+|\\.[^\s\\]*)+', command):
555 rsync_opts.append(validated_arg(last_opt, arg, check_type))
557 elif saw_the_dot_arg:
558 # NOTE: an arg that starts with a '-' is safe due to our use of "--" in the cmd tuple.
560 b_e = braceexpand(arg) # Also removes backslashes
561 except: # Handle errors such as unbalanced braces by just de-backslashing the arg:
562 b_e = [ DE_BACKSLASH_RE.sub(r'\1', arg) ]
564 rsync_args += validated_arg('arg', xarg, wild=True)
565 else: # parsing the option args
567 saw_the_dot_arg = True
569 rsync_opts.append(arg)
570 sm = short_no_arg_re.match(arg)
571 if sm or short_with_num_re.match(arg):
573 # Scan the cluster's own letters only: the trailing
574 # capability blob (-e.iLsfxC) is not a set of options.
577 letters = letters[:-len(sm.group(1))]
579 client_relative = True
582 m = LONG_OPT_RE.match(arg)
586 ct = long_opts.get(opt, None)
588 break # Generate generic failure due to unfinished arg parsing
589 # Last one wins, matching rsync's own option handling.
590 if opt == 'relative':
591 client_relative = True
592 elif opt == 'no-relative':
593 client_relative = False
598 if opt_arg is not None:
599 rsync_opts[-1] = opt + '=' + validated_arg(opt, opt_arg, ct)
606 m = short_disabled_re.match(arg)
609 opt = '-' + m.group(1)
612 die("option", opt, "has been disabled on this server.")
613 break # Generate a generic failure
615 if not saw_the_dot_arg:
616 die("invalid rsync-command syntax or options")
618 if args.dir != '/' and not am_sender:
619 # A restricted dir denies device/special CREATION, but `-a` (-rlptgoD)
620 # bundles -D into the client's short-option string, so rejecting -D
621 # outright would break every `rsync -a` to a restricted rrsync.
623 # --no-D cannot do this job: preserve_devices/preserve_specials also
624 # frame the file list's rdev fields, and only this end of the
625 # connection gets the option, so the client's -D sender writes rdev
626 # that a --no-D receiver never reads. That desynchronises the list --
627 # a FIFO hangs the transfer at protocol 29 and corrupts it at 30, a
628 # device node breaks EVERY protocol. --drop-D refuses the creation
629 # while leaving the wire format alone. (Needs rsync 3.5.0+, which is
630 # what rrsync is installed alongside.)
632 # Only on the receiving side: creation happens where files are
633 # written, so a sender has nothing to deny -- --drop-D would be a
635 rsync_opts.append('--drop-D')
638 # Filter rules travel over the protocol, not in the argv we validate, so
639 # a client can name a merge file outside the restricted dir and have the
640 # server read it in as rules -- a pull needs no --delete and no
641 # verbosity for that. --confine-root bounds the server's own resolution
642 # of such paths, which is the only end that can see them. Both
643 # directions: a dir-merge is read by whichever side the rule applies to.
644 rsync_opts.append('--confine-root=' + os.getcwd())
647 rsync_opts.append('--munge-links')
649 if args.no_overwrite:
650 rsync_opts.append('--ignore-existing')
655 cmd = (RSYNC, *rsync_opts, '--', '.', *rsync_args)
658 now = time.localtime()
659 host = os.environ.get('SSH_CONNECTION', 'unknown').split()[0] # Drop everything after the IP addr
660 if host.startswith('::ffff:'):
663 host = socket.gethostbyaddr(socket.inet_aton(host))
666 log_fh.write("%02d:%02d:%02d %-16s %s\n" % (now.tm_hour, now.tm_min, now.tm_sec, host, str(cmd)))
669 # NOTE: This assumes that the rsync protocol will not be maliciously hijacked.
671 os.execlp(RSYNC, *cmd)
672 die("execlp(", RSYNC, *cmd, ') failed')
673 # pass_fds keeps the inode-pinning O_PATH fds open across the spawn so
674 # /proc/self/fd/N in the cmd resolves correctly in the child. See the
675 # pinned_fds comment near the top.
676 child = subprocess.run(cmd, pass_fds=tuple(pinned_fds))
677 if child.returncode != 0:
678 sys.exit(child.returncode)
681 def validated_arg(opt, arg, typ=3, wild=False):
682 if opt != 'arg': # arg values already have their backslashes removed.
683 arg = DE_BACKSLASH_RE.sub(r'\1', arg)
685 # "-" is rsync's read-the-list-from-stdin sentinel, not a pathname: a pull
686 # with a local --files-from sends exactly "--files-from=-" to the server.
687 if opt == '--files-from':
691 die('a write-only server cannot read a remote --files-from path')
694 if arg.startswith('./'):
696 arg = arg.replace('//', '/')
697 is_absolute_arg = args.absolute and opt == 'arg' and args.dir != '/' and (arg == args.dir or arg.startswith(args.dir_slash))
698 if not is_absolute_arg:
699 arg = arg.lstrip('/')
701 if HAS_DOT_DOT_RE.search(arg):
702 die("do not use .. in", opt, "(anchor the path at the root of your restricted dir)")
713 if args.dir != '/' and arg != '.' and (typ == 3 or (typ == 2 and not am_sender)):
714 arg_has_trailing_slash = arg.endswith('/')
715 arg_has_trailing_slash_dot = False
716 if arg_has_trailing_slash:
719 arg_has_trailing_slash_dot = arg.endswith('/.')
720 if arg_has_trailing_slash_dot:
722 real_arg = os.path.realpath(arg)
723 if arg != real_arg and not real_arg.startswith(args.dir_slash):
724 if not (is_absolute_arg and real_arg == args.dir):
725 die('unsafe arg:', orig_arg, [arg, real_arg])
726 # Inode-pin the validated path so an attacker cannot flip a
727 # path component AFTER realpath validates it but BEFORE the
728 # exec'd rsync resolves it.
730 # CRITICAL: open with O_RDONLY (not O_PATH). An O_PATH fd
731 # holds a path/dentry reference and /proc/self/fd/N for an
732 # O_PATH fd re-resolves the path on open -- which means the
733 # race window stays open across the exec. A regular
734 # O_RDONLY fd holds an open file (inode-bound), and
735 # /proc/self/fd/N for a regular fd references the inode
736 # directly -- exactly the race-closing primitive we need.
738 # O_NOFOLLOW on this open means a symlink that raced into
739 # place between realpath and this open is refused at the
740 # leaf. A subsequent fstat() + readlink-of-fd verifies the
741 # pinned inode is still within the restricted tree (a
742 # parent-component race that landed on an in-tree symlink
743 # but outside-tree target would surface here).
745 # /proc/self/fd/N then routes the exec'd rsync's open
746 # through the kernel's magic link to the SAME pinned inode
747 # regardless of any subsequent flip; the race is closed.
749 # Linux-only (O_PATH/proc trick is Linux specific); on
750 # non-Linux fall through to the unhardened path. For paths
751 # that don't exist yet (receiver-side new dest) os.open
752 # fails -- we skip pinning there; the new-dest race is a
754 # Only a regular file or directory gets its CONTENT opened. A
755 # sender needs neither for anything else: rsync transmits a symlink
756 # by its target string and skips a device/FIFO/socket under the
757 # forced --no-D. Opening them here is also actively wrong --
758 # O_RDONLY on a FIFO blocks until a writer appears, so naming an
759 # in-tree FIFO wedged rrsync before exec, and a dangling symlink
760 # resolved to a missing target and was reported as a race. 3.4.4
761 # transfers both. These shapes take the parent pin, which is what
762 # confines them anyway.
763 # NOT for a trailing "/" or "/." argument: rsync opens that one and
764 # DOES follow a symlink there, so its leaf pin is load-bearing --
765 # rrsync-sender-leaf-flip proves a raced flip leaks the outside
766 # directory's content without it.
767 sender_leaf_unopened = False
768 # Not gated on HAVE_PROC_SELF_FD: this is a decision about what
769 # rsync does with the argument, not about whether we can pin it, so
770 # it has to hold on the BSDs, macOS, Solaris and Cygwin too -- where
771 # otherwise a dangling symlink still resolved to nothing and died.
772 if (am_sender and opt == 'arg'
773 and not arg_has_trailing_slash
774 and not arg_has_trailing_slash_dot):
779 if lst is not None and not (stat.S_ISREG(lst.st_mode)
780 or stat.S_ISDIR(lst.st_mode)):
781 sender_leaf_unopened = True
783 if sender_leaf_unopened:
784 raise InterruptedError() # jump to the sender-pin branch
786 # O_NONBLOCK so a special file that raced in after the
787 # lstat above still cannot block this open.
788 fd = os.open(real_arg,
789 os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
790 except IsADirectoryError:
791 fd = os.open(real_arg,
792 os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
793 except InterruptedError:
794 # No CONTENT fd for this leaf -- but it is still named
795 # beneath a pinned directory below, not re-resolved from
798 except FileNotFoundError:
799 # In --sender mode the path MUST exist (we're reading
800 # from it) -- ENOENT here means the rename-based race
801 # caught a transient gap in the flipper's swap. Die.
803 die('post-realpath open failed (race detected):',
804 orig_arg, 'No such file or directory')
805 if opt in MUST_EXIST_DIR_OPTS:
806 die('receiver option path does not exist:', orig_arg)
807 # Receiver-side new destination: the leaf has no inode to pin
808 # yet, but pin its existing PARENT directory and route the
809 # exec'd rsync's creation through /proc/self/fd/<parent>/<leaf>,
810 # so a parent-component flip after realpath can't redirect the
811 # new file/dir out of the tree. Linux-only (the /proc magic
812 # link); elsewhere, or if the parent itself doesn't exist yet
813 # (a deeper -R new path), fall through unpinned as before.
815 leaf = os.path.basename(real_arg)
816 if opt in CREATE_DIR_MODE or opt in EMPTY_BASIS_OPTS:
817 # An auxiliary directory the peer can supply mid-transfer.
818 # The parent-pinned spelling below is not enough for these:
819 # the same transfer can plant a symlink at <leaf> first,
820 # and rsync would create or read through it, outside the
821 # tree. Both answers below hand rsync an inode instead of
822 # a name, so without that primitive there is no safe way to
823 # proceed -- refuse rather than pass the name through.
824 if not HAVE_PROC_SELF_FD:
825 die('receiver option path does not exist:', orig_arg)
826 if opt in CREATE_DIR_MODE:
827 if not leaf or leaf in ('.', '..'):
828 die('bad receiver option path:', orig_arg)
829 arg = create_pinned_dir(real_arg, orig_arg,
830 CREATE_DIR_MODE[opt])
832 arg = pinned_empty_dir(orig_arg)
833 elif HAVE_PROC_SELF_FD and leaf and leaf not in ('.', '..'):
835 pfd = os.open(os.path.dirname(real_arg) or '/',
836 os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
841 ppath = os.readlink('/proc/self/fd/%d' % pfd)
844 die('post-pin readlink failed (race?):',
845 orig_arg, e.strerror)
846 # The pinned parent must be the tree root or under it.
847 if ppath != args.dir and not ppath.startswith(args.dir_slash):
849 die('post-pin path escaped tree (race?):',
851 os.set_inheritable(pfd, True)
852 pinned_fds.append(pfd)
853 arg = '/proc/self/fd/%d/%s' % (pfd, leaf)
855 # ELOOP or anything else is a race signal: realpath
856 # validated the path moments ago, but the open just
857 # failed -- something flipped between the check and
858 # the pin (typically a symlink-flip on the leaf).
859 die('post-realpath open failed (race detected):',
860 orig_arg, e.strerror)
861 if am_sender and opt == 'arg':
864 if logical == args.dir:
866 elif logical.startswith(args.dir_slash):
867 logical = logical[args.dir_slash_len:]
868 if fd is None and sender_leaf_unopened:
869 # A leaf we deliberately never opened is still spelled beneath
870 # a pinned directory: leaving the bare name for rsync to
871 # re-resolve puts every component back in play, which is
872 # CVE-2026-53783 -- measured at 3 leaks in 83 raced pulls with
873 # a dangling-symlink leaf whose parent was flipped to point
874 # outside the tree. It costs nothing here: the directory is
875 # opened O_PATH, so the special file itself is never opened
876 # and a FIFO cannot block, and whatever the leaf turns into
877 # afterwards is reached only from beneath the held one.
879 # WHICH directory is sender_pinned_arg()'s decision, and it is
880 # the immediate parent for every shape EXCEPT a --relative
881 # argument with no client "/./": there the whole argument is
882 # the transmitted name, so only the anchor it starts from can
883 # be pinned and the components below it stay raceable. That
884 # --relative limit predates this and is stated in NEWS.
885 if HAVE_PROC_SELF_FD:
886 pinned = sender_pinned_arg(None, logical, orig_arg,
887 arg_has_trailing_slash,
888 arg_has_trailing_slash_dot)
889 if pinned != LEAF_PIN_UNUSABLE:
892 # The inode-pin trick (verify + route the exec'd rsync's open via
893 # the /proc/self/fd magic link) is Linux-only. Where /proc/self/fd
894 # does not exist at all (the BSDs, Solaris, macOS, Cygwin, or a
895 # /proc-less namespace) we cannot pin -- fall through to the
896 # unhardened path (close the fd, keep the realpath-validated arg)
897 # per the design note above. But where /proc/self/fd DOES exist
898 # (Linux), a readlink failure is an anomaly (sandbox/seccomp), not
899 # a no-proc platform: fail CLOSED rather than silently unharden.
900 if not HAVE_PROC_SELF_FD:
901 os.close(fd) # no /proc/self/fd: run unpinned
904 pinned_path = os.readlink('/proc/self/fd/%d' % fd)
907 die('post-pin readlink failed (race?):',
908 orig_arg, e.strerror)
909 # The pinned inode must live under args.dir_slash (or BE
910 # args.dir). Catches a parent-component flip that landed
911 # inside an in-tree path but pointed outside.
912 if (not pinned_path.startswith(args.dir_slash)
913 and pinned_path != args.dir):
915 die('post-pin path escaped tree (race?):',
916 orig_arg, pinned_path)
917 if am_sender and opt == 'arg':
918 pinned = sender_pinned_arg(fd, logical, orig_arg,
919 arg_has_trailing_slash,
920 arg_has_trailing_slash_dot)
922 pinned = KEEP_LEAF_PIN
923 if pinned == KEEP_LEAF_PIN:
924 os.set_inheritable(fd, True)
925 pinned_fds.append(fd)
926 arg = '/proc/self/fd/%d' % fd
927 elif pinned == LEAF_PIN_UNUSABLE:
928 # Nothing to spell beneath a held directory (a bare "."
929 # or the tree root): keep the realpath-validated name,
930 # which is what 3.4.4 passes.
933 os.close(fd) # only needed to validate the pin
935 if arg_has_trailing_slash:
937 elif arg_has_trailing_slash_dot:
939 if is_absolute_arg and arg == args.dir:
941 elif opt == 'arg' and arg.startswith(args.dir_slash):
942 arg = arg[args.dir_slash_len:]
947 return ret if wild else ret[0]
950 def lock_or_die(dirname):
953 lock_handle = os.open(dirname, os.O_RDONLY)
955 fcntl.flock(lock_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
957 if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
958 die('Another instance of rrsync is already accessing this directory.')
959 # flock() is unavailable on this fd/platform -- e.g. Solaris returns
960 # EBADF for flock() on a directory fd. The single-run lock is a
961 # best-effort convenience (cf. -no-lock), not a security control, so
962 # proceed without it rather than abort every transfer.
963 os.close(lock_handle)
968 print(sys.argv[0], 'error:', *msg, file=sys.stderr)
969 if sys.stdin.isatty():
970 arg_parser.print_help(sys.stderr)
974 # This class displays the --help to the user on argparse error IFF they're running it interactively.
975 class OurArgParser(argparse.ArgumentParser):
976 def error(self, msg):
980 if __name__ == '__main__':
981 our_desc = """Use "man rrsync" to learn how to restrict ssh users to using a restricted rsync command."""
982 arg_parser = OurArgParser(description=our_desc, add_help=False)
983 only_group = arg_parser.add_mutually_exclusive_group()
984 only_group.add_argument('-ro', action='store_true', help="Allow only reading from the DIR. Implies -no-del and -no-lock.")
985 only_group.add_argument('-wo', action='store_true', help="Allow only writing to the DIR.")
986 arg_parser.add_argument('-munge', action='store_true', help="Enable rsync's --munge-links on the server side.")
987 arg_parser.add_argument('-absolute', action='store_true', help="Allow transfer args to use absolute server paths under DIR.")
988 arg_parser.add_argument('-no-del', action='store_true', help="Disable rsync's --delete* and --remove* options.")
989 arg_parser.add_argument('-no-lock', action='store_true', help="Avoid the single-run (per-user) lock check.")
990 arg_parser.add_argument('-no-overwrite', action='store_true', help="Prevent overwriting existing files by enforcing --ignore-existing")
991 arg_parser.add_argument('-help', '-h', action='help', help="Output this help message and exit.")
992 arg_parser.add_argument('dir', metavar='DIR', help="The restricted directory to use.")
993 args = arg_parser.parse_args()
994 args.dir = os.path.realpath(args.dir)
995 args.dir_slash = args.dir + '/'
996 args.dir_slash_len = len(args.dir_slash)
999 elif not args.no_lock:
1000 lock_or_die(args.dir)