auth: read only the first password-file line (#1137)

* auth: read only the first password-file line

strtok() skips leading delimiters, so an empty first line followed by a
password was interpreted as that password. Stop at the first CR/LF
instead, keeping an empty first line empty and making file and stdin
behavior consistent.

Add daemon-auth regression coverage for LF, CRLF, empty input, an empty
first line, and a final line without a newline for both input sources.

Fixes #1134.

Signed-off-by: Eduard Zateev <hackerowskiy@gmail.com>

* tests: reject password-file fallback for empty first lines

Supply the valid daemon password through RSYNC_PASSWORD in negative
cases, then require exit status 1 and the password read diagnostic.

Signed-off-by: Eduard Zateev <hackerowskiy@gmail.com>

---------

Signed-off-by: Eduard Zateev <hackerowskiy@gmail.com>
This commit is contained in:
SAIREX authored and GitHub committed 2026-10-23 16:34:19 +10:00
1 parent 92a2a09e3f
commit be124601c4
2 files changed
+74 -2

No files matched your search

+4 -2
View File
@@ -271,8 +271,10 @@ static const char *getpassf(const char *filename)
if (n > 0) {
buffer[n] = '\0';
if ((p = strtok(buffer, "\n\r")) != NULL)
return strdup(p);
p = buffer + strcspn(buffer, "\n\r");
*p = '\0';
if (buffer[0] != '\0')
return strdup(buffer);
}
rprintf(FERROR, "ERROR: failed to read a password from %s\n", filename);
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Only the first line of --password-file is used, for files and stdin."""
import os
import subprocess
from rsyncfns import (
SCRATCHDIR, makepath, rmtree, rsync_argv, start_test_daemon,
test_fail, write_daemon_conf,
)
DAEMON_PORT = 12904
USER = 'tuser'
PASSWORD = b'secretpass'
base = SCRATCHDIR / 'password-file-first-line'
rmtree(base)
src = base / 'src'
dest = base / 'dest'
makepath(src)
(src / 'payload').write_text('regression test\n')
secrets = base / 'secrets'
secrets.write_bytes(USER.encode() + b':' + PASSWORD + b'\n')
secrets.chmod(0o600)
conf = write_daemon_conf([
('auth', {'path': dest, 'read only': 'no',
'auth users': USER, 'secrets file': secrets}),
])
url = start_test_daemon(conf, DAEMON_PORT)
userurl = url.replace('rsync://', f'rsync://{USER}@', 1)
cases = (
('LF', PASSWORD + b'\nignored\n', True),
('CRLF', PASSWORD + b'\r\nignored\r\n', True),
('empty-file', b'', False),
('empty-first-line', b'\n' + PASSWORD + b'\n', False),
('no-final-newline', PASSWORD, True),
)
for source in ('file', 'stdin'):
for name, contents, succeeds in cases:
rmtree(dest)
makepath(dest)
args = ['-a', f'{src}/', f'{userurl}auth/']
if source == 'file':
pwfile = base / f'{name}.password'
pwfile.write_bytes(contents)
pwfile.chmod(0o600)
args.insert(1, f'--password-file={pwfile}')
stdin = subprocess.DEVNULL
else:
args.insert(1, '--password-file=-')
stdin = subprocess.PIPE
proc = subprocess.run(rsync_argv(*args), input=contents if source == 'stdin' else None,
stdin=stdin if source == 'file' else None,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=False, timeout=20,
env={**os.environ, 'RSYNC_PASSWORD':
'wrong-fallback' if succeeds else PASSWORD.decode()})
passed = proc.returncode == 0
if passed != succeeds:
test_fail(f'{source}/{name}: expected success={succeeds}, '
f'rc={proc.returncode}, stderr={proc.stderr!r}')
if not succeeds and (proc.returncode != 1
or b'failed to read a password' not in proc.stderr):
test_fail(f'{source}/{name}: expected password read failure, '
f'rc={proc.returncode}, stderr={proc.stderr!r}')
print('password-file: first-line behavior matches for files and stdin')