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
+11 -8
View File
@@ -383,19 +383,22 @@ static const char* server_module_gate(const Config* config, void* context) {
"store is configured";
}
/* Transport policy (A7-3/S1): an auth-required module only accepts
* credentials over an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or over a local/SSH transport (a
* loopback TCP peer, or the --stdio pipe). A remote plaintext peer is
* refused HERE, before the challenge is sent, so an unverified client never
* receives a nonce. --allow-unauthenticated is intentionally NOT consulted:
* that flag relaxes the standalone plaintext gate, never this one. */
* credentials over (a) an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or (b) a plaintext connection from a
* loopback peer that the operator explicitly opted into with
* --allow-unauthenticated. A remote plaintext peer and an un-flagged
* loopback plaintext peer are both refused HERE, before the challenge is
* sent, so an unverified client never receives a nonce. The operator flag
* never permits REMOTE plaintext auth: remote peers still require verified
* TLS regardless of the flag. */
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
tls_client_identity_allowed(gate_ctx->ssl);
bool local_ok = gate_ctx && gate_ctx->fd >= 0 && utils_fd_peer_is_local(gate_ctx->fd);
bool local_ok = allow_unauthenticated && gate_ctx && gate_ctx->fd >= 0 &&
utils_fd_peer_is_local(gate_ctx->fd);
if (!tls_ok && !local_ok) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication over an encrypted, verified TLS "
"connection (or a local/SSH transport); refusing",
"connection (or an opted-in loopback plaintext transport); refusing",
config->module);
return "daemon module requires authentication over an encrypted, verified TLS "
"connection";
+7 -5
View File
@@ -571,17 +571,19 @@ bool utils_sockaddr_is_loopback(const struct sockaddr* addr) {
return false;
}
/* True when the fd's peer is a local channel: a loopback TCP peer, or a
non-socket descriptor (the --stdio SSH transport is a pipe, so a failed
getpeername with ENOTSOCK counts as local). Any other socket peer is not
local. */
/* True when the fd's peer is provably a loopback TCP peer: getpeername must
succeed AND the returned address must classify as loopback. Everything else
is NOT local, including a non-socket descriptor (pipe/socketpair): a failed
getpeername (ENOTSOCK, ENOTCONN, ...) fails closed. The daemon auth gate
must not treat "I cannot tell" as "trusted", and daemon auth modules are
daemon-only anyway (the --stdio path never loads a daemon config). */
bool utils_fd_peer_is_local(int fd) {
if (fd < 0)
return false;
struct sockaddr_storage peer;
socklen_t length = sizeof(peer);
if (getpeername(fd, (struct sockaddr*)&peer, &length) != 0)
return errno == ENOTSOCK;
return false;
return utils_sockaddr_is_loopback((const struct sockaddr*)&peer);
}
+6 -1
View File
@@ -68,7 +68,12 @@ bool append_resume_eligible(unsigned long long old_size, unsigned long long chec
bool append_tail_length(unsigned long long old_size, unsigned long long check_size,
unsigned long long* tail_out);
/* Loopback / local-transport classification for the daemon auth gate and the
client credential rule. See utils.c for the exact accepted forms. */
client credential rule. utils_sockaddr_is_loopback accepts 127.0.0.0/8,
IPv6 ::1 and IPv4-mapped ::ffff:127.x.x.x; utils_host_is_loopback additionally
accepts the literal "localhost". utils_fd_peer_is_local is fail-closed: it is
true only when getpeername SUCCEEDS and reports a loopback peer -- a non-socket
descriptor (pipe/socketpair) or any getpeername error yields false. See
utils.c for the exact accepted forms. */
bool utils_sockaddr_is_loopback(const struct sockaddr* addr);
bool utils_fd_peer_is_local(int fd);
bool utils_host_is_loopback(const char* host);