Files
rsync/testsuite/rsync-ssl-hostname-validation_test.py
Andrew Tridgell 84832f0ff7 rsync-ssl, testsuite: quote paths interpolated into shell text
Third layer of the space-in-build-path work, and the first part that is not
test-only.

rsync-ssl expanded the helper program paths unquoted -- "exec
$RSYNC_SSL_OPENSSL s_client ...", likewise for gnutls and stunnel -- so an
openssl installed under a path containing a space is split and never runs.
That affects anyone with such a path, not just the testsuite.  Quoted; the
neighbouring $caopt/$certopt/... stay unquoted because they are option lists
that rely on word splitting.  Its own re-exec passes --rsh="$0 --HELPER",
which rsync then tokenises, so $0 is single-quoted for rsync's parser.

On the test side, the same shape in generated shell scripts: redirect targets
("printf ... > {capture}") and daemon hook commands, which rsync runs through a
shell, both interpolated a path with no quoting.

In a directory with a space: 235 pass, 18 fail, from 0 able to run.
Unchanged in a normal path: 257 passed, 0 failed.
2026-08-02 08:13:25 +10:00

60 lines
2.8 KiB
Python

#!/usr/bin/env python3
# Finding [20] (CWE-88 argument injection): rsync-ssl expands an untrusted SSL
# hostspec into the openssl/gnutls command line unquoted (rsync-ssl:146) and into
# the stunnel heredoc config (rsync-ssl:154) with no character policy. A hostname
# containing whitespace / newline / control characters therefore injects extra
# TLS options or stunnel directives -- a forced connection target, a verification
# bypass, or stunnel config injection. Fix: validate_ssl_hostname rejects such
# hostnames before any helper is invoked (rsync-ssl:138).
#
# Part 1 demonstrates the injection against an UNFIXED copy of rsync-ssl plus a
# fake openssl that records its argv: the crafted host's payload lands as a
# separate openssl argument. Part 2 is the fix: the shipped rsync-ssl rejects the
# host string before exec'ing the helper.
import os
import shlex
import re
import subprocess
from rsyncfns import SCRATCHDIR, SRCDIR, rmtree, test_fail
base = SCRATCHDIR / 'rsync-ssl-hostname'
rmtree(base)
base.mkdir(parents=True)
argv_capture = base / 'openssl_argv'
fake_openssl = base / 'fake_openssl'
fake_openssl.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {shlex.quote(str(argv_capture))}\nexit 0\n")
fake_openssl.chmod(0o755)
# An unfixed rsync-ssl: drop the validate_ssl_hostname guard so the host string
# reaches the helper command line as it did before the fix.
unfixed = base / 'rsync-ssl-unfixed'
unfixed.write_text(re.sub(r'(?m)^(\s*)validate_ssl_hostname .*$',
r'\1: # hostname guard removed for this regression test',
(SRCDIR / 'rsync-ssl').read_text()))
unfixed.chmod(0o755)
EVIL_HOST = 'localhost INJECTED_OPENSSL_ARG'
env = {**os.environ, 'RSYNC_SSL_TYPE': 'openssl',
'RSYNC_SSL_OPENSSL': str(fake_openssl), 'RSYNC_SSL_CA_CERT': '/dev/null'}
helper = ['--HELPER', EVIL_HOST, 'rsync', '--server', '--daemon', '.']
# --- Part 1: the unfixed helper injects the payload into the openssl argv.
subprocess.run(['bash', str(unfixed)] + helper, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
captured = argv_capture.read_text().splitlines() if argv_capture.exists() else []
if 'INJECTED_OPENSSL_ARG' not in captured:
test_fail("premise: the crafted hostname did not inject a separate openssl "
f"argument (captured argv: {captured})")
# --- Part 2: the shipped rsync-ssl rejects the injecting host string.
proc = subprocess.run(['bash', str(SRCDIR / 'rsync-ssl')] + helper, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if proc.returncode == 0 or 'invalid rsync-ssl hostname' not in proc.stdout:
test_fail("rsync-ssl did NOT reject the argument-injecting hostname:\n" + proc.stdout)
print("rsync-ssl-hostname-validation: a control-char hostspec injects helper "
"arguments; rsync-ssl rejects it")