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:
@@ -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:
|
||||
|
||||
@@ -315,15 +315,24 @@ static void test_loopback_helpers() {
|
||||
|
||||
EXPECT_FALSE(utils_sockaddr_is_loopback(NULL));
|
||||
|
||||
/* A pipe has no socket peer: getpeername fails with ENOTSOCK, which is the
|
||||
--stdio/SSH case and must count as local. */
|
||||
/* A pipe has no socket peer: getpeername fails with ENOTSOCK. The helper is
|
||||
fail-closed, so an unprovable channel is NOT local (daemon auth modules are
|
||||
daemon-only and never run over the --stdio pipe). */
|
||||
int pipe_fds[2];
|
||||
EXPECT_EQ_INT(pipe(pipe_fds), 0);
|
||||
EXPECT_TRUE(utils_fd_peer_is_local(pipe_fds[0]));
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(pipe_fds[0]));
|
||||
close(pipe_fds[0]);
|
||||
close(pipe_fds[1]);
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(-1));
|
||||
|
||||
/* A connected AF_UNIX socketpair is a socket, but its peer is not a loopback
|
||||
IP address, so it is not local either. */
|
||||
int pair_fds[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, pair_fds), 0);
|
||||
EXPECT_FALSE(utils_fd_peer_is_local(pair_fds[0]));
|
||||
close(pair_fds[0]);
|
||||
close(pair_fds[1]);
|
||||
|
||||
/* A real loopback TCP peer is local. */
|
||||
int listener = socket(AF_INET, SOCK_STREAM, 0);
|
||||
EXPECT_TRUE(listener >= 0);
|
||||
|
||||
Reference in New Issue
Block a user