mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-22 10:15:04 -04:00
rrsync: restore restricted-root paths (#1055)
This commit is contained in:
1 parent
7cc29215a6
commit
4a65e70dfd
2 files changed
+134
-1
No files matched your search
+6
-1
@@ -694,8 +694,13 @@ def validated_arg(opt, arg, typ=3, wild=False):
|
||||
if arg.startswith('./'):
|
||||
arg = arg[1:]
|
||||
arg = arg.replace('//', '/')
|
||||
# One leading slash means the root of the restricted tree.
|
||||
arg = re.sub(r'^/+', '/', arg)
|
||||
is_absolute_arg = args.absolute and opt == 'arg' and args.dir != '/' and (arg == args.dir or arg.startswith(args.dir_slash))
|
||||
if not is_absolute_arg:
|
||||
is_root_relative_arg = arg.startswith('/') and not is_absolute_arg
|
||||
if is_root_relative_arg and args.dir != '/':
|
||||
arg = args.dir + arg
|
||||
elif not is_absolute_arg:
|
||||
arg = arg.lstrip('/')
|
||||
if args.dir != '/':
|
||||
if HAS_DOT_DOT_RE.search(arg):
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Leading slashes through rrsync must remain relative to the restricted root."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from rsyncfns import (
|
||||
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv,
|
||||
rsync_path_arg, test_fail,
|
||||
)
|
||||
|
||||
T = 1234567890
|
||||
|
||||
base = SCRATCHDIR / 'rrsync-root-relative-paths'
|
||||
rmtree(base)
|
||||
restricted = base / 'restricted'
|
||||
source = base / 'source'
|
||||
outside = base / 'outside'
|
||||
pulled = base / 'pulled'
|
||||
makepath(restricted / 'previous', source, outside, pulled)
|
||||
|
||||
(restricted / 'previous' / 'file').write_text('unchanged\n')
|
||||
(source / 'file').write_text('unchanged\n')
|
||||
(outside / 'secret').write_text('outside\n')
|
||||
os.utime(restricted / 'previous' / 'file', (T, T))
|
||||
os.utime(source / 'file', (T, T))
|
||||
|
||||
shim = base / 'rsync-shim'
|
||||
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
|
||||
shim.chmod(0o755)
|
||||
rrsync = patched_rrsync(base, rsync_path=str(shim))
|
||||
|
||||
rsh = base / 'fake-rsh'
|
||||
rsh.write_text(
|
||||
'#!/bin/sh\n'
|
||||
'shift\n'
|
||||
'SSH_ORIGINAL_COMMAND="$*"\n'
|
||||
'export SSH_ORIGINAL_COMMAND\n'
|
||||
'exec %s %s\n' % (shlex.quote(str(rrsync)), shlex.quote(str(restricted))))
|
||||
rsh.chmod(0o755)
|
||||
|
||||
rsh_absolute = base / 'fake-rsh-absolute'
|
||||
rsh_absolute.write_text(
|
||||
'#!/bin/sh\n'
|
||||
'shift\n'
|
||||
'SSH_ORIGINAL_COMMAND="$*"\n'
|
||||
'export SSH_ORIGINAL_COMMAND\n'
|
||||
'exec %s -absolute %s\n'
|
||||
% (shlex.quote(str(rrsync)), shlex.quote(str(restricted))))
|
||||
rsh_absolute.chmod(0o755)
|
||||
|
||||
|
||||
def run(*args):
|
||||
return subprocess.run(
|
||||
rsync_argv('-e', str(rsh), *args),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
|
||||
|
||||
# A leading slash denotes the root of the restricted tree, not an empty path.
|
||||
listed = run('--list-only', 'dummy:/')
|
||||
listed_ctx = (f'rc={listed.returncode}, stdout={listed.stdout.strip()!r}, '
|
||||
f'stderr={listed.stderr.strip()!r}')
|
||||
if listed.returncode != 0 or 'previous' not in listed.stdout:
|
||||
test_fail(f'listing the restricted root with / failed ({listed_ctx})')
|
||||
|
||||
# Option operands use the same convention. The basis is a sibling of the
|
||||
# destination, so stripping the slash and leaving a relative name makes rsync
|
||||
# look for destination/previous and silently transfer a full copy.
|
||||
linked = run('-a', '--link-dest=/previous', str(source) + '/', 'dummy:/current')
|
||||
linked_ctx = (f'rc={linked.returncode}, stdout={linked.stdout.strip()!r}, '
|
||||
f'stderr={linked.stderr.strip()!r}')
|
||||
current = restricted / 'current' / 'file'
|
||||
previous = restricted / 'previous' / 'file'
|
||||
if linked.returncode != 0 or not current.is_file():
|
||||
test_fail(f'root-relative --link-dest transfer failed ({linked_ctx})')
|
||||
current_stat = os.stat(current)
|
||||
previous_stat = os.stat(previous)
|
||||
if ((current_stat.st_dev, current_stat.st_ino)
|
||||
!= (previous_stat.st_dev, previous_stat.st_ino)):
|
||||
test_fail(f'root-relative --link-dest did not hard-link to its basis '
|
||||
f'({linked_ctx})')
|
||||
|
||||
# Repeated leading slashes have the same restricted-root meaning. Assert the
|
||||
# authorised path still works rather than satisfying confinement by rejecting
|
||||
# every repeated-slash path.
|
||||
repeated = run('-a', 'dummy:///previous/file', str(pulled) + '/')
|
||||
if (repeated.returncode != 0
|
||||
or not (pulled / 'file').is_file()
|
||||
or (pulled / 'file').read_text() != 'unchanged\n'):
|
||||
test_fail('repeated leading slashes did not resolve beneath the '
|
||||
f'restricted root (rc={repeated.returncode}, '
|
||||
f'stderr={repeated.stderr.strip()!r})')
|
||||
|
||||
# The explicit -absolute mode continues to accept a complete server path under
|
||||
# the restricted directory; root-relative handling must not reinterpret it.
|
||||
rmtree(pulled)
|
||||
pulled.mkdir()
|
||||
absolute = subprocess.run(
|
||||
rsync_argv('-a', '-e', str(rsh_absolute),
|
||||
'dummy:' + str(restricted / 'previous' / 'file'),
|
||||
str(pulled) + '/'),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if (absolute.returncode != 0
|
||||
or not (pulled / 'file').is_file()
|
||||
or (pulled / 'file').read_text() != 'unchanged\n'):
|
||||
test_fail('-absolute no longer accepts an in-tree server path '
|
||||
f'(rc={absolute.returncode}, stderr={absolute.stderr.strip()!r})')
|
||||
|
||||
# Repeated leading slashes must not regain host-root meaning in either transfer
|
||||
# direction. The requested path is the absolute spelling of a file outside
|
||||
# the restricted tree; a vulnerable wrapper would read or replace that file.
|
||||
outside_arg = '///' + str(outside / 'secret').lstrip('/')
|
||||
rmtree(pulled)
|
||||
pulled.mkdir()
|
||||
escaped_pull = run('-a', 'dummy:' + outside_arg, str(pulled) + '/')
|
||||
if (pulled / 'secret').exists():
|
||||
test_fail('repeated leading slashes escaped the restricted root on pull')
|
||||
|
||||
replacement = base / 'replacement'
|
||||
replacement.write_text('replacement\n')
|
||||
escaped_push = run('-a', str(replacement), 'dummy:' + outside_arg)
|
||||
if (outside / 'secret').read_text() != 'outside\n':
|
||||
test_fail('repeated leading slashes escaped the restricted root on push')
|
||||
|
||||
print('rrsync preserves restricted-root paths without repeated-slash escapes')
|
||||
Reference in new issue
Block a user