Skip to content

Commit 4a65e70

Browse files
authored
rrsync: restore restricted-root paths (#1055)
1 parent 7cc2921 commit 4a65e70

2 files changed

Lines changed: 134 additions & 1 deletion

File tree

support/rrsync

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,13 @@ def validated_arg(opt, arg, typ=3, wild=False):
694694
if arg.startswith('./'):
695695
arg = arg[1:]
696696
arg = arg.replace('//', '/')
697+
# One leading slash means the root of the restricted tree.
698+
arg = re.sub(r'^/+', '/', arg)
697699
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:
700+
is_root_relative_arg = arg.startswith('/') and not is_absolute_arg
701+
if is_root_relative_arg and args.dir != '/':
702+
arg = args.dir + arg
703+
elif not is_absolute_arg:
699704
arg = arg.lstrip('/')
700705
if args.dir != '/':
701706
if HAS_DOT_DOT_RE.search(arg):
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""Leading slashes through rrsync must remain relative to the restricted root."""
3+
4+
import os
5+
import shlex
6+
import subprocess
7+
8+
from rsyncfns import (
9+
RSYNC, SCRATCHDIR, makepath, patched_rrsync, rmtree, rsync_argv,
10+
rsync_path_arg, test_fail,
11+
)
12+
13+
T = 1234567890
14+
15+
base = SCRATCHDIR / 'rrsync-root-relative-paths'
16+
rmtree(base)
17+
restricted = base / 'restricted'
18+
source = base / 'source'
19+
outside = base / 'outside'
20+
pulled = base / 'pulled'
21+
makepath(restricted / 'previous', source, outside, pulled)
22+
23+
(restricted / 'previous' / 'file').write_text('unchanged\n')
24+
(source / 'file').write_text('unchanged\n')
25+
(outside / 'secret').write_text('outside\n')
26+
os.utime(restricted / 'previous' / 'file', (T, T))
27+
os.utime(source / 'file', (T, T))
28+
29+
shim = base / 'rsync-shim'
30+
shim.write_text('#!/bin/sh\nexec ' + rsync_path_arg(RSYNC) + ' "$@"\n')
31+
shim.chmod(0o755)
32+
rrsync = patched_rrsync(base, rsync_path=str(shim))
33+
34+
rsh = base / 'fake-rsh'
35+
rsh.write_text(
36+
'#!/bin/sh\n'
37+
'shift\n'
38+
'SSH_ORIGINAL_COMMAND="$*"\n'
39+
'export SSH_ORIGINAL_COMMAND\n'
40+
'exec %s %s\n' % (shlex.quote(str(rrsync)), shlex.quote(str(restricted))))
41+
rsh.chmod(0o755)
42+
43+
rsh_absolute = base / 'fake-rsh-absolute'
44+
rsh_absolute.write_text(
45+
'#!/bin/sh\n'
46+
'shift\n'
47+
'SSH_ORIGINAL_COMMAND="$*"\n'
48+
'export SSH_ORIGINAL_COMMAND\n'
49+
'exec %s -absolute %s\n'
50+
% (shlex.quote(str(rrsync)), shlex.quote(str(restricted))))
51+
rsh_absolute.chmod(0o755)
52+
53+
54+
def run(*args):
55+
return subprocess.run(
56+
rsync_argv('-e', str(rsh), *args),
57+
capture_output=True, text=True, timeout=30,
58+
)
59+
60+
61+
# A leading slash denotes the root of the restricted tree, not an empty path.
62+
listed = run('--list-only', 'dummy:/')
63+
listed_ctx = (f'rc={listed.returncode}, stdout={listed.stdout.strip()!r}, '
64+
f'stderr={listed.stderr.strip()!r}')
65+
if listed.returncode != 0 or 'previous' not in listed.stdout:
66+
test_fail(f'listing the restricted root with / failed ({listed_ctx})')
67+
68+
# Option operands use the same convention. The basis is a sibling of the
69+
# destination, so stripping the slash and leaving a relative name makes rsync
70+
# look for destination/previous and silently transfer a full copy.
71+
linked = run('-a', '--link-dest=/previous', str(source) + '/', 'dummy:/current')
72+
linked_ctx = (f'rc={linked.returncode}, stdout={linked.stdout.strip()!r}, '
73+
f'stderr={linked.stderr.strip()!r}')
74+
current = restricted / 'current' / 'file'
75+
previous = restricted / 'previous' / 'file'
76+
if linked.returncode != 0 or not current.is_file():
77+
test_fail(f'root-relative --link-dest transfer failed ({linked_ctx})')
78+
current_stat = os.stat(current)
79+
previous_stat = os.stat(previous)
80+
if ((current_stat.st_dev, current_stat.st_ino)
81+
!= (previous_stat.st_dev, previous_stat.st_ino)):
82+
test_fail(f'root-relative --link-dest did not hard-link to its basis '
83+
f'({linked_ctx})')
84+
85+
# Repeated leading slashes have the same restricted-root meaning. Assert the
86+
# authorised path still works rather than satisfying confinement by rejecting
87+
# every repeated-slash path.
88+
repeated = run('-a', 'dummy:///previous/file', str(pulled) + '/')
89+
if (repeated.returncode != 0
90+
or not (pulled / 'file').is_file()
91+
or (pulled / 'file').read_text() != 'unchanged\n'):
92+
test_fail('repeated leading slashes did not resolve beneath the '
93+
f'restricted root (rc={repeated.returncode}, '
94+
f'stderr={repeated.stderr.strip()!r})')
95+
96+
# The explicit -absolute mode continues to accept a complete server path under
97+
# the restricted directory; root-relative handling must not reinterpret it.
98+
rmtree(pulled)
99+
pulled.mkdir()
100+
absolute = subprocess.run(
101+
rsync_argv('-a', '-e', str(rsh_absolute),
102+
'dummy:' + str(restricted / 'previous' / 'file'),
103+
str(pulled) + '/'),
104+
capture_output=True, text=True, timeout=30,
105+
)
106+
if (absolute.returncode != 0
107+
or not (pulled / 'file').is_file()
108+
or (pulled / 'file').read_text() != 'unchanged\n'):
109+
test_fail('-absolute no longer accepts an in-tree server path '
110+
f'(rc={absolute.returncode}, stderr={absolute.stderr.strip()!r})')
111+
112+
# Repeated leading slashes must not regain host-root meaning in either transfer
113+
# direction. The requested path is the absolute spelling of a file outside
114+
# the restricted tree; a vulnerable wrapper would read or replace that file.
115+
outside_arg = '///' + str(outside / 'secret').lstrip('/')
116+
rmtree(pulled)
117+
pulled.mkdir()
118+
escaped_pull = run('-a', 'dummy:' + outside_arg, str(pulled) + '/')
119+
if (pulled / 'secret').exists():
120+
test_fail('repeated leading slashes escaped the restricted root on pull')
121+
122+
replacement = base / 'replacement'
123+
replacement.write_text('replacement\n')
124+
escaped_push = run('-a', str(replacement), 'dummy:' + outside_arg)
125+
if (outside / 'secret').read_text() != 'outside\n':
126+
test_fail('repeated leading slashes escaped the restricted root on push')
127+
128+
print('rrsync preserves restricted-root paths without repeated-slash escapes')

0 commit comments

Comments
 (0)