I generated the following report using Claude Opus 5 (but did review and did verify the reproducer works).
See also #922 for a previous report; however I don’t think the fix works, or at least not for my situation.
I noticed this in my nightly backup log:
rrsync error: unsafe arg: / ['', '/srv/backup/frigaten']
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: error in rsync protocol data stream (code 12) at io.c(232) [sender=3.4.4]
and, a few minutes later, a transfer summary that should not have been possible:
Number of created files: 1,141
Literal data: 949,186,488 bytes
Matched data: 0 bytes
The cause is a one-line change in support/rrsync that shipped in rsync 3.4. It is
present in v3.4, v3.4.2, v3.4.3 and v3.4.4, and still present in master today.
How the backup is supposed to work
Each backed-up host pushes to its own restricted directory on the NAS. The
authorized_keys entry is the usual rrsync forced command:
command="/nix/store/…-rrsync-3.4.4/bin/rrsync /srv/backup/midna" ssh-ed25519 AAAA… root@midna
The client asks the server what snapshots already exist, then hardlinks against the
most recent one:
rsync --list-only -e ssh "$dest:/"
rsync -ax --relative --numeric-ids --link-dest=/2026-07-09 / "$dest:/2026-07-31"
Both paths are absolute, anchored at the root of the restricted directory. That is
rrsync's documented convention: a leading / means "the root of DIR", and rrsync
rewrites it to the real path.
Two things broke
The listing broke first. A bare / is now mangled into the empty string, and rrsync
then fails its own safety check against it — that is the unsafe arg: / line. Because
the listing runs in backticks with its exit status ignored, the client concluded
"no previous backups exist", skipped --link-dest entirely, and wrote a full copy.
Changing the listing to "$dest:." fixed that. It also made the second, quieter bug
visible. The client now correctly finds 2026-07-09 and passes
--link-dest=/2026-07-09 — and rsync answers:
--link-dest arg does not exist: 2026-07-09
Note the missing leading slash. That is the whole bug, and note also that this is a
warning: rsync carries on without the link-dest and produces a complete, correct,
enormous full copy. Nothing exits non-zero. Nothing in my monitoring noticed for
three weeks.
Why the path comes out wrong
rrsync chdirs into the restricted directory and validates each path argument
relative to that CWD. rsync resolves a relative --link-dest relative to the
destination directory. Those two are the same place only if the destination is
the restricted root — and in a dated-snapshot layout it never is.
So once the leading slash is stripped, 2026-07-09 is looked for inside
/srv/backup/midna/2026-07-31/, where it obviously does not exist.
There is no way to spell around this from the client:
- absolute (
/2026-07-09) — the slash is stripped, and the code that would re-anchor
it is now unreachable;
- parent-relative (
../2026-07-09) — rejected outright by rrsync's .. check;
- destination-at-the-root, with the date moved into the transferred path — I tried
this; it fails, because --link-dest matches files by their path within the
transfer, so it goes looking for 2026-07-09/2026-07-31/sub/a.txt.
Upstream added an -absolute flag in 0d0399bb1420cea150931245f152feb4768e1e06 and
closed the corresponding bug report as fixed by it. It does not help — see below.
The upstream change was a security fix
My first instinct was that the change was simply wrong and should be reverted. It is
not, and it should not.
The commit is d4c4f6754eff0d8ea6fdb327abf5c874bfccb8dd, "fixed remove multiple
leading slashes", authored 2025-06-11:
if arg.startswith('./'):
arg = arg[1:]
arg = arg.replace('//', '/')
+ arg = arg.lstrip('/')
if args.dir != '/':
if HAS_DOT_DOT_RE.search(arg):
die("do not use .. in", opt, …)
if arg.startswith('/'):
arg = args.dir + arg
str.replace('//', '/') is a single non-recursive left-to-right pass, so it does not
collapse runs of slashes:
'//foo' -> '/foo'
'///foo' -> '//foo' # survives
'////foo' -> '//foo'
That surviving double slash is a sandbox escape. Trace ///etc/hostname with
DIR=/srv/backup/midna:
| step |
value |
after replace('//', '/') |
//etc/hostname |
after joining with args.dir |
/srv/backup/midna//etc/hostname |
os.path.realpath() |
/srv/backup/midna/etc/hostname — check passes |
after arg[dir_slash_len:] |
/etc/hostname |
realpath() normalizes the double slash away, so the safety check is satisfied. Then
the final slice removes /srv/backup/midna/ and leaves a leading slash behind, and
rsync treats the result as an absolute path. The client reads — or writes — anywhere
on the server, as whatever user the forced command runs as. For a backup NAS that is
root.
I confirmed both directions against the pre-change script: pulling
fake:///etc/hostname returned the server's real /etc/hostname, and pushing to an
absolute path outside the restricted directory landed the file there.
So the fix was necessary. It just reached one character too far: it removes all
leading slashes when it only needed to collapse them to one.
Reproducer
Self-contained; takes the rrsync to test as its only argument. It touches nothing
outside its own temporary directory — the "secret" it tries to steal is a file the
script plants next to the restricted directory, so there is no dependency on what
/etc happens to look like.
#!/bin/sh
# Demonstrate the rsync 3.4 rrsync --link-dest regression, and the sandbox
# escape that the offending commit was closing.
#
# Usage: repro.sh /path/to/rrsync
die() { echo "ERROR: $*" >&2; exit 2; }
nlink() { stat -c %h "$1" 2>/dev/null || stat -f %l "$1" 2>/dev/null; }
RRSYNC=$1
[ -n "$RRSYNC" ] || die "usage: $0 /path/to/rrsync (try: $0 \"\$(command -v rrsync)\")"
[ -x "$RRSYNC" ] || die "not executable: $RRSYNC"
command -v rsync >/dev/null 2>&1 || die "no rsync in PATH"
echo "rsync: $(rsync --version 2>/dev/null | head -1)"
echo "rrsync: $RRSYNC"
echo
tmp=$(mktemp -d) || die "mktemp failed"
trap 'rm -rf "$tmp"' EXIT
cd "$tmp" || die "cannot cd to $tmp"
# The restricted dir holds yesterday's snapshot. secret.txt sits outside it.
mkdir -p restricted/2026-07-09/sub src/sub out
echo hello > src/sub/a.txt
cp -a src/sub/a.txt restricted/2026-07-09/sub/a.txt
echo "you should not be able to read this" > secret.txt
# Stands in for ssh: drop the hostname, hand the rest to rrsync as a forced command.
cat > fakessh <<EOF
#!/bin/sh
shift
SSH_ORIGINAL_COMMAND="\$*" exec "$RRSYNC" "$tmp/restricted"
EOF
chmod +x fakessh
echo "=== 1. incremental backup against yesterday, via --link-dest=/2026-07-09 ==="
rsync -a --stats -e "$tmp/fakessh" --link-dest=/2026-07-09 src/ fake:/2026-07-31 >rsync.out 2>&1
rc=$?
interesting=$(grep -E 'link-dest|Literal data|Matched data|error|rrsync' rsync.out)
[ -n "$interesting" ] || interesting=$(cat rsync.out)
printf '%s\n' "$interesting" | sed 's/^/ /'
[ $rc -eq 0 ] || echo " rsync exited $rc"
if [ ! -f restricted/2026-07-31/sub/a.txt ]; then
echo " RESULT: no transfer happened at all (rsync rc=$rc)"
elif [ "$(nlink restricted/2026-07-31/sub/a.txt)" -gt 1 ]; then
echo " RESULT: OK — hardlinked to yesterday (st_nlink=$(nlink restricted/2026-07-31/sub/a.txt))"
else
echo " RESULT: BROKEN — --link-dest ignored, full copy written (st_nlink=1)"
fi
echo
echo "=== 2. client asks for a path outside the restricted dir (3 leading slashes) ==="
echo " requesting fake://$tmp/secret.txt"
rsync -a -e "$tmp/fakessh" "fake://$tmp/secret.txt" out/ >esc.out 2>&1
rc=$?
sed 's/^/ /' esc.out
if [ -s out/secret.txt ]; then
echo " got: $(cat out/secret.txt)"
echo " RESULT: ESCAPED — this rrsync leaks files outside the restricted dir"
else
echo " rsync exited $rc, nothing fetched"
echo " RESULT: OK — confined"
fi
Against the rrsync from rsync 3.4.4:
=== 1. incremental backup against yesterday, via --link-dest=/2026-07-09 ===
--link-dest arg does not exist: 2026-07-09
Literal data: 6 bytes
Matched data: 0 bytes
RESULT: BROKEN — --link-dest ignored, full copy written (st_nlink=1)
=== 2. client asks for a path outside the restricted dir (3 leading slashes) ===
requesting fake:///tmp/tmp.tPo5ppUE5N/secret.txt
rsync: [sender] change_dir "/tmp/tmp.tPo5ppUE5N/restricted/tmp/tmp.tPo5ppUE5N" failed: No such file or directory (2)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1876) [Receiver=3.4.4]
rsync exited 23, nothing fetched
RESULT: OK — confined
and against the same script with d4c4f6754eff0d8ea6fdb327abf5c874bfccb8dd backed out,
which is the trade the commit was making:
=== 1. incremental backup against yesterday, via --link-dest=/2026-07-09 ===
Literal data: 0 bytes
Matched data: 0 bytes
RESULT: OK — hardlinked to yesterday (st_nlink=2)
=== 2. client asks for a path outside the restricted dir (3 leading slashes) ===
requesting fake:///tmp/tmp.kAEvjuOpY3/secret.txt
got: you should not be able to read this
RESULT: ESCAPED — this rrsync leaks files outside the restricted dir
The two properties are in tension in every released version:
| rrsync |
--link-dest |
hardlinks |
escape via /// |
before d4c4f675… |
works |
2 |
escapes |
| 3.4 … 3.4.4, master |
ignored (warning only) |
1 |
confined |
master + -absolute |
ignored (warning only) |
1 |
confined |
| 3.4.4 + fix |
works |
2 |
confined |
-absolute is not a fix
The listing half of this is reported upstream as issue #922: rsync host:/ . failing
with unsafe arg: /. It is closed with "fixed in #890 in master", where #890 is the
-absolute commit. Two commenters on the issue say that is not really a fix, and
they are right — but it is worse than they say, because it does not fix the command
in the issue's own title either.
I built support/rrsync from master and ran it both ways. With -absolute,
--link-dest still writes a full copy in every spelling:
--link-dest= |
destination |
result |
/2026-07-09 |
/2026-07-31 |
full copy |
/2026-07-09 |
$RESTRICTED/2026-07-31 |
full copy |
$RESTRICTED/2026-07-09 |
/2026-07-31 |
full copy |
$RESTRICTED/2026-07-09 |
$RESTRICTED/2026-07-31 |
full copy |
One clause explains all four rows:
is_absolute_arg = args.absolute and opt == 'arg' and …
--link-dest is declared as long_opts['link-dest'] = 2 and reaches validated_arg()
with opt='link-dest', so it never qualifies as an absolute arg and is still
lstriped.
The reported case fares no better. rsync host:/ . still dies with unsafe arg: /
with the flag on, because / is neither equal to args.dir nor prefixed by it.
Sending the full server-side path instead trades that for a new failure —
ERROR: rejecting unrequested file-list name: 2026-07-09 — which is rsync 3.4's own
client-side file-list hardening tripping over the args.dir → . rewrite that
-absolute performs.
There is a design objection too: -absolute requires the client to name the server's
restricted directory, so both ends now have to agree on a string they previously did
not share. My clients derive nothing from the server beyond "the root of my own
restricted dir", which is exactly what the leading / used to mean.
The fix
Two versions, because 0d0399bb… changed the surrounding code.
Against 3.4.4 — and anything else in the 3.4 series, where the re-anchoring is still
present but unreachable — collapse the leading slashes instead of removing them:
- arg = arg.lstrip('/')
+ arg = re.sub(r'^/+', '/', arg)
With it, ///etc/hostname becomes /etc/hostname, gets anchored to
/srv/backup/midna/etc/hostname with no doubled separator, and the final slice yields
a relative path as intended. --link-dest=/2026-07-09 survives as an absolute path
again. Both escape directions stay closed, and --list-only "$dest:/" works again too.
Against master it takes two hunks, because 0d0399bb… also deleted the anchoring:
- arg = arg.lstrip('/')
+ arg = re.sub(r'^/+', '/', arg)
if args.dir != '/':
if HAS_DOT_DOT_RE.search(arg):
die("do not use .. in", opt, …)
+ if arg.startswith('/'):
+ arg = args.dir + arg
The collapse alone is not enough on master, and is in fact worse than the status quo:
with the anchoring gone, a preserved leading slash goes straight through as a real
absolute path. I measured that variant — it escapes, and the backup half tries to
mkdir "/2026-07-31" at the filesystem root. The fix suggested in issue #922,
while arg != (arg := arg.replace("//", "/")): pass, has the same problem if applied
to master unchanged; on 3.4.4 it is equivalent to the one-liner above.
With both hunks, master passes everything:
list host:/ rc=0
--link-dest=/2026-07-09 dest=/2026-07-31 OK hardlinked
escape via /// confined
I generated the following report using Claude Opus 5 (but did review and did verify the reproducer works).
See also #922 for a previous report; however I don’t think the fix works, or at least not for my situation.
I noticed this in my nightly backup log:
and, a few minutes later, a transfer summary that should not have been possible:
The cause is a one-line change in
support/rrsyncthat shipped in rsync 3.4. It ispresent in v3.4, v3.4.2, v3.4.3 and v3.4.4, and still present in master today.
How the backup is supposed to work
Each backed-up host pushes to its own restricted directory on the NAS. The
authorized_keysentry is the usual rrsync forced command:The client asks the server what snapshots already exist, then hardlinks against the
most recent one:
Both paths are absolute, anchored at the root of the restricted directory. That is
rrsync's documented convention: a leading
/means "the root of DIR", and rrsyncrewrites it to the real path.
Two things broke
The listing broke first. A bare
/is now mangled into the empty string, and rrsyncthen fails its own safety check against it — that is the
unsafe arg: /line. Becausethe listing runs in backticks with its exit status ignored, the client concluded
"no previous backups exist", skipped
--link-destentirely, and wrote a full copy.Changing the listing to
"$dest:."fixed that. It also made the second, quieter bugvisible. The client now correctly finds
2026-07-09and passes--link-dest=/2026-07-09— and rsync answers:Note the missing leading slash. That is the whole bug, and note also that this is a
warning: rsync carries on without the link-dest and produces a complete, correct,
enormous full copy. Nothing exits non-zero. Nothing in my monitoring noticed for
three weeks.
Why the path comes out wrong
rrsync
chdirs into the restricted directory and validates each path argumentrelative to that CWD. rsync resolves a relative
--link-destrelative to thedestination directory. Those two are the same place only if the destination is
the restricted root — and in a dated-snapshot layout it never is.
So once the leading slash is stripped,
2026-07-09is looked for inside/srv/backup/midna/2026-07-31/, where it obviously does not exist.There is no way to spell around this from the client:
/2026-07-09) — the slash is stripped, and the code that would re-anchorit is now unreachable;
../2026-07-09) — rejected outright by rrsync's..check;this; it fails, because
--link-destmatches files by their path within thetransfer, so it goes looking for
2026-07-09/2026-07-31/sub/a.txt.Upstream added an
-absoluteflag in0d0399bb1420cea150931245f152feb4768e1e06andclosed the corresponding bug report as fixed by it. It does not help — see below.
The upstream change was a security fix
My first instinct was that the change was simply wrong and should be reverted. It is
not, and it should not.
The commit is
d4c4f6754eff0d8ea6fdb327abf5c874bfccb8dd, "fixed remove multipleleading slashes", authored 2025-06-11:
if arg.startswith('./'): arg = arg[1:] arg = arg.replace('//', '/') + arg = arg.lstrip('/') if args.dir != '/': if HAS_DOT_DOT_RE.search(arg): die("do not use .. in", opt, …) if arg.startswith('/'): arg = args.dir + argstr.replace('//', '/')is a single non-recursive left-to-right pass, so it does notcollapse runs of slashes:
That surviving double slash is a sandbox escape. Trace
///etc/hostnamewithDIR=/srv/backup/midna:replace('//', '/')//etc/hostnameargs.dir/srv/backup/midna//etc/hostnameos.path.realpath()/srv/backup/midna/etc/hostname— check passesarg[dir_slash_len:]/etc/hostnamerealpath()normalizes the double slash away, so the safety check is satisfied. Thenthe final slice removes
/srv/backup/midna/and leaves a leading slash behind, andrsync treats the result as an absolute path. The client reads — or writes — anywhere
on the server, as whatever user the forced command runs as. For a backup NAS that is
root.
I confirmed both directions against the pre-change script: pulling
fake:///etc/hostnamereturned the server's real/etc/hostname, and pushing to anabsolute path outside the restricted directory landed the file there.
So the fix was necessary. It just reached one character too far: it removes all
leading slashes when it only needed to collapse them to one.
Reproducer
Self-contained; takes the rrsync to test as its only argument. It touches nothing
outside its own temporary directory — the "secret" it tries to steal is a file the
script plants next to the restricted directory, so there is no dependency on what
/etchappens to look like.Against the rrsync from rsync 3.4.4:
and against the same script with
d4c4f6754eff0d8ea6fdb327abf5c874bfccb8ddbacked out,which is the trade the commit was making:
The two properties are in tension in every released version:
--link-dest///d4c4f675…-absolute-absoluteis not a fixThe listing half of this is reported upstream as issue #922:
rsync host:/ .failingwith
unsafe arg: /. It is closed with "fixed in #890 in master", where #890 is the-absolutecommit. Two commenters on the issue say that is not really a fix, andthey are right — but it is worse than they say, because it does not fix the command
in the issue's own title either.
I built
support/rrsyncfrom master and ran it both ways. With-absolute,--link-deststill writes a full copy in every spelling:--link-dest=/2026-07-09/2026-07-31/2026-07-09$RESTRICTED/2026-07-31$RESTRICTED/2026-07-09/2026-07-31$RESTRICTED/2026-07-09$RESTRICTED/2026-07-31One clause explains all four rows:
--link-destis declared aslong_opts['link-dest'] = 2and reachesvalidated_arg()with
opt='link-dest', so it never qualifies as an absolute arg and is stilllstriped.The reported case fares no better.
rsync host:/ .still dies withunsafe arg: /with the flag on, because
/is neither equal toargs.dirnor prefixed by it.Sending the full server-side path instead trades that for a new failure —
ERROR: rejecting unrequested file-list name: 2026-07-09— which is rsync 3.4's ownclient-side file-list hardening tripping over the
args.dir→.rewrite that-absoluteperforms.There is a design objection too:
-absoluterequires the client to name the server'srestricted directory, so both ends now have to agree on a string they previously did
not share. My clients derive nothing from the server beyond "the root of my own
restricted dir", which is exactly what the leading
/used to mean.The fix
Two versions, because
0d0399bb…changed the surrounding code.Against 3.4.4 — and anything else in the 3.4 series, where the re-anchoring is still
present but unreachable — collapse the leading slashes instead of removing them:
With it,
///etc/hostnamebecomes/etc/hostname, gets anchored to/srv/backup/midna/etc/hostnamewith no doubled separator, and the final slice yieldsa relative path as intended.
--link-dest=/2026-07-09survives as an absolute pathagain. Both escape directions stay closed, and
--list-only "$dest:/"works again too.Against master it takes two hunks, because
0d0399bb…also deleted the anchoring:The collapse alone is not enough on master, and is in fact worse than the status quo:
with the anchoring gone, a preserved leading slash goes straight through as a real
absolute path. I measured that variant — it escapes, and the backup half tries to
mkdir "/2026-07-31"at the filesystem root. The fix suggested in issue #922,while arg != (arg := arg.replace("//", "/")): pass, has the same problem if appliedto master unchanged; on 3.4.4 it is equivalent to the one-liner above.
With both hunks, master passes everything: