fix(a7-3/s1): fail closed on non-loopback peers; require plaintext opt-in before challenge

utils_fd_peer_is_local now returns true only when getpeername SUCCEEDS and the
peer address classifies as loopback. A non-socket descriptor (pipe/socketpair)
or any getpeername error is NOT local, so the daemon auth gate fails closed
instead of treating an untestable --stdio pipe as trusted (daemon auth modules
are --daemon-only and the stdio path never loads a daemon config).

server_module_gate now requires --allow-unauthenticated for the loopback
plaintext auth path: a plaintext loopback connection without the operator
opt-in is refused at the config gate BEFORE server_auth_handshake, so no SCRAM
challenge is sent. Remote peers still require verified TLS regardless of the
flag; the handler keeps its defense-in-depth checks.

Docs state the exact policy (verified TLS with matching --client-cn, or
operator-opted-in loopback plaintext), drop the SSH/stdio auth-transport claim
(they are daemon-only), and add the loopback trust-boundary relay caveat and
the CN-only (no SAN) residual. Adds a unit-test negative for pipe/socketpair
and an integration test where a relay observes no challenge when the flag is
absent.
This commit is contained in:
2026-09-12 19:18:29 +02:00
parent a7a1930e88
commit d53614d06b
7 changed files with 99 additions and 29 deletions
+41 -1
View File
@@ -597,6 +597,9 @@ class _AuthReplayProxy:
self.server.settimeout(20)
self.port = self.server.getsockname()[1]
self.stolen = None
# Set when a relayed connection received a SCRAM challenge from the
# backend; lets a test assert the daemon refused before any challenge.
self.saw_challenge = False
def close(self):
try:
@@ -634,6 +637,7 @@ class _AuthReplayProxy:
if len(buf_s) >= 4:
(status,) = struct.unpack_from("<i", buf_s, 0)
if status == STATUS_AUTH_CHALLENGE:
self.saw_challenge = True
off = 4 + 4 # status int + iteration int
for _ in range(2):
frame = _wire_string_frame_len(buf_s, off)
@@ -786,6 +790,41 @@ class TestDaemonAuthentication:
finally:
os.unlink(cred_path)
@pytest.mark.ci
def test_loopback_plaintext_refused_before_challenge_without_flag(self):
"""A7-3/S1: an auth-required module reached over loopback plaintext is
refused at the config gate -- before any SCRAM challenge is sent -- when
the operator did NOT pass --allow-unauthenticated. That flag is the
explicit opt-in that makes loopback plaintext an accepted auth
transport; it never permits remote plaintext auth. A relay records the
daemon's first status frame so a challenge is directly observable."""
d = DaemonManager()
port = _find_free_port()
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth_auth.log")
log = open(log_path, "w")
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
"--password-file", CRED_FILE, "--dparam", f"port={port}"]
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
start_new_session=True)
d._port = port
_wait_for_port(port, timeout=10)
proxy = _AuthReplayProxy(port)
try:
before = _tree_file_count(AUTH_MODULE)
cred = os.path.join(TEST_DATA_DIR, "noauth_loopback.pw")
_write_client_password_file(cred, "alice", ALICE_PASS)
proc = subprocess.Popen(_client_cmd("127.0.0.1::locked", proxy.port, cred),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
proxy._run_connection(capture=True)
out, err = proc.communicate(timeout=30)
assert proc.returncode != 0, "auth over unflagged loopback plaintext must be refused"
assert not proxy.saw_challenge, "daemon sent a SCRAM challenge before the refusal"
assert _tree_file_count(AUTH_MODULE) == before, "a refused connection wrote data"
os.unlink(cred)
finally:
proxy.close()
d.stop()
def test_client_empty_password_file_rejected(self):
"""Client-side: an empty --password-file is rejected (no credentials)."""
cred_path = os.path.join(TEST_DATA_DIR, "client_empty.pw")
@@ -1119,7 +1158,8 @@ class TestDaemonTLSAuth:
refused at the config gate when the CA-valid client certificate does not
match --client-cn -- before any SCRAM challenge is sent and before any
file data moves. The daemon is started WITH --allow-unauthenticated to
prove that flag does not relax the auth-module transport policy."""
prove that flag never relaxes the remote auth-module transport policy
(it only opts in plaintext from a loopback peer)."""
try:
remote_ip = socket.gethostbyname(socket.gethostname())
except OSError: