mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-16 15:17:41 -04:00
parse_one_refuse_match() marked only the first long_options row whose long name matched the configured spelling, then broke out for a non-wildcard rule. --compress-threads and --zt are separate popt rows that both write &do_compression_threads, so "refuse options = compress-threads" disabled the canonical row and left the alias accepted: the refused capability was still reachable under its other name. The same shape covers zc/compress-choice and zl/compress-level. An exact rule names a capability, not one spelling of it, so mark every row that does the same thing. Comparing the raw table fields is not enough for that: popt's `val` means different things per argInfo. For POPT_ARG_VAL it IS the value stored in `arg`, while elsewhere a nonzero `val` is an action code for the parser's switch, and POPT_ARG_NONE with a destination stores 1 whatever `val` says. --del is POPT_ARG_NONE/&delete_during/0 and --delete-during is POPT_ARG_VAL/&delete_during/1: the same destination and the same resulting value, but unequal as table entries, so "refuse options = delete-during" was still evaded by --del and the mirror held too. Compare what a row does instead -- the destination and the constant it assigns, falling back to table-entry equality for rows that store a runtime value or only dispatch an action. Enumerating all 258 rows, this couples exactly one pair the field comparison missed, del and delete-during, and changes nothing else. Opposite switches such as --foo and --no-foo stay distinct because they assign different values. Two regressions. The compress-threads one drives the raw daemon protocol -- not to preserve the spelling, which -M--zt=N would do just as well, but because it goes on to observe the worker pool the bypass delivers. Its oracle is the refusal itself -- the alias connection torn down and "configured to refuse --zt" logged -- and deliberately not the resulting worker count: an accepted --zt is a defeated refuse rule however few threads it produces, and the daemon worker cap being added alongside this holds that count to 9, so a count-based assertion passes while the alias is still accepted. Run that test against the cap without this parser fix and it does exactly that; the two changes were covering for each other. The delete one needs neither zstd nor a socket: --remote-option puts the option in the daemon's argv verbatim, which is the reach an ordinary user already has, so it drives a stock client both ways round against modules refusing each spelling, with an unrefused module as the control. The compress-threads test needs --use-tcp, so it skips in every other column and is declared in the workflows that enforce a skip set, which a fleet run otherwise reports as an unexpected skip on fourteen cells.
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""``refuse options = compress-threads`` must also refuse its ``--zt`` alias.
|
|
|
|
set_refuse_options() stopped after the first exact long-option match, so the
|
|
canonical compress-threads entry was disabled but its later zt alias stayed
|
|
accepted. Both halves here build the daemon argv by hand -- a stock client
|
|
does not forward this option at all unless asked to with -M, and the raw
|
|
protocol is what preserves the exact spelling under test.
|
|
|
|
The oracle is the refusal itself: the alias connection must be torn down and
|
|
the daemon must log "configured to refuse --zt". It is deliberately NOT the
|
|
worker count. An accepted --zt that happens to produce few threads is still a
|
|
defeated refuse rule, and several unrelated things bound that count -- a
|
|
separate daemon worker cap (see the Zstandard thread-cap change), a libzstd
|
|
built without multithreading, or a process/thread limit -- so a count-based
|
|
assertion goes green while the rule is bypassed. The count is still measured,
|
|
but only to say how much capability the bypass delivered.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
from rsyncfns import (
|
|
SCRATCHDIR, claim_ports, require_tcp, rmtree, run_rsync,
|
|
start_rsyncd, test_fail, test_skipped,
|
|
)
|
|
import rsync_proto as rp
|
|
|
|
PORT = 13121
|
|
REQUESTED_THREADS = 256
|
|
MAX_SAFE_THREADS = 64
|
|
|
|
require_tcp("the malicious receiver needs a real TCP daemon; run with --use-tcp")
|
|
claim_ports(PORT)
|
|
|
|
version = json.loads(run_rsync('-VV', check=True, capture_output=True).stdout)
|
|
if 'zstd' not in version.get('compress_list', []):
|
|
test_skipped('this build does not include Zstandard')
|
|
|
|
base = SCRATCHDIR / 'daemon-refuse-compress-threads-alias'
|
|
rmtree(base)
|
|
mod = base / 'module'
|
|
mod.mkdir(parents=True)
|
|
(mod / 'f').write_bytes(os.urandom(2 * 1024 * 1024))
|
|
|
|
log = base / 'rsyncd.log'
|
|
conf = base / 'rsyncd.conf'
|
|
conf.write_text(f"""\
|
|
pid file = {base}/rsyncd.pid
|
|
log file = {log}
|
|
use chroot = no
|
|
|
|
[mod]
|
|
path = {mod}
|
|
refuse options = compress-threads
|
|
""")
|
|
start_rsyncd(conf, PORT)
|
|
|
|
|
|
def refusal_logged(spelling):
|
|
needle = f'configured to refuse --{spelling}'
|
|
for _ in range(100):
|
|
if log.exists() and needle in log.read_text(errors='replace'):
|
|
return True
|
|
time.sleep(0.02)
|
|
return False
|
|
|
|
|
|
# Control: preserve the canonical spelling in a hand-built daemon argv and
|
|
# verify that the configured rule rejects it before any file list is served.
|
|
control = rp.DaemonClient('127.0.0.1', PORT)
|
|
control_rejected = False
|
|
try:
|
|
control.handshake(
|
|
'mod',
|
|
[
|
|
'--server', '--sender', '-e.LsfxCIu', '--compress',
|
|
'--compress-choice=zstd', '--compress-threads=2', '.', 'mod/f',
|
|
],
|
|
greeting_version=30,
|
|
)
|
|
control.recv_flist(preserve_links=False)
|
|
except (rp.ProtocolError, OSError):
|
|
control_rejected = True
|
|
finally:
|
|
control.close()
|
|
if not control_rejected or not refusal_logged('compress-threads'):
|
|
test_fail('canonical --compress-threads unexpectedly bypassed its refuse rule')
|
|
|
|
# Attack: preserve the accepted alias spelling in a hand-built daemon argv.
|
|
c = rp.DaemonClient('127.0.0.1', PORT)
|
|
try:
|
|
c.handshake(
|
|
'mod',
|
|
[
|
|
'--server', '--sender', '-e.LsfxCIu', '--compress',
|
|
'--compress-choice=zstd', f'--zt={REQUESTED_THREADS}', '.', 'mod/f',
|
|
],
|
|
greeting_version=30,
|
|
)
|
|
entries = rp.sort_entries(c.recv_flist(preserve_links=False))
|
|
except (rp.ProtocolError, OSError):
|
|
c.close()
|
|
if not refusal_logged('zt'):
|
|
test_fail('the alias connection ended, but the daemon never logged a '
|
|
'--zt refusal, so it was not the refuse rule that stopped it')
|
|
print('daemon refuse rule rejected both --compress-threads and --zt')
|
|
sys.exit(0)
|
|
|
|
# Reaching here means the daemon served a file list for --zt: the refuse rule
|
|
# did not fire and the capability was delivered. That is the bug, whatever
|
|
# the worker count turns out to be. Measure it anyway, to report what the
|
|
# bypass was worth.
|
|
ndx = next(i for i, entry in enumerate(entries) if entry.name == b'f')
|
|
c.send_data(c.make_request(ndx) + c.w_ndx(rp.NDX_DONE))
|
|
|
|
child_pid = None
|
|
for _ in range(100):
|
|
matches = re.findall(r'\[(\d+)\] rsync on mod/f',
|
|
log.read_text(errors='replace') if log.exists() else '')
|
|
if matches:
|
|
child_pid = int(matches[-1])
|
|
break
|
|
time.sleep(0.02)
|
|
if child_pid is None:
|
|
c.close()
|
|
test_fail('could not identify the per-connection daemon sender')
|
|
|
|
|
|
def thread_count(pid):
|
|
if sys.platform == 'darwin':
|
|
proc = subprocess.run(['ps', '-M', str(pid)], capture_output=True,
|
|
text=True)
|
|
return max(0, len(proc.stdout.splitlines()) - 1)
|
|
if sys.platform.startswith('linux'):
|
|
try:
|
|
status = open(f'/proc/{pid}/status').read()
|
|
except OSError:
|
|
return 0
|
|
match = re.search(r'^Threads:\s+(\d+)', status, re.M)
|
|
return int(match.group(1)) if match else 0
|
|
return -1
|
|
|
|
|
|
observed = 0
|
|
for _ in range(100):
|
|
observed = max(observed, thread_count(child_pid))
|
|
if observed >= REQUESTED_THREADS:
|
|
break
|
|
time.sleep(0.02)
|
|
c.close()
|
|
|
|
how_many = (f'and it created {observed} worker threads'
|
|
if observed > 0 else
|
|
'though its thread count could not be read here')
|
|
test_fail(f'canonical --compress-threads was refused but its --zt alias was '
|
|
f'accepted: the daemon served the file list {how_many}. A refuse '
|
|
f'rule naming one spelling of an option must cover every spelling '
|
|
f'of it')
|