|
| 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