Merge branch 'fix/sec-server' into fix/sec-integration

This commit is contained in:
2026-09-14 17:27:38 +02:00
19 changed files with 537 additions and 58 deletions
+60 -6
View File
@@ -37,6 +37,15 @@ static bool allow_unauthenticated;
* root), so no super-user activity is attempted and any client --copy-as is
* refused. Set once in main before the accept loop / stdio handler. */
static bool server_no_super;
/* --allow-super: locally-launched standalone TCP opt-in that preserves the
* historical permissive super mode for a root receiver. When false, a
* privileged standalone receiver forces SUPER_MODE_OFF for every connection
* (C3), so a client cannot make it create device nodes / write raw devices /
* apply client-chosen ownership. It is REJECTED for --stdio (the SSH remote
* argv is composed by the client, so it must never be able to opt a root
* receiver back into super mode); the --stdio path always keeps the secure
* default. */
static bool server_allow_super;
static const char* required_client_cn;
/* --iconv CONVERT_SPEC the server was itself started with (borrowed argv
* pointer). Its LOCAL half may override the local charset the client assumed;
@@ -187,13 +196,25 @@ static bool tls_client_identity_allowed(SSL* ssl) {
X509* certificate = SSL_get1_peer_certificate(ssl);
if (!certificate)
return false;
char common_name[256];
int length = X509_NAME_get_text_by_NID(X509_get_subject_name(certificate), NID_commonName,
common_name, sizeof(common_name));
size_t required_length = strlen(required_client_cn);
bool allowed = length >= 0 && (size_t)length == required_length &&
required_length < sizeof(common_name) &&
credentials_secure_equal(common_name, required_client_cn, required_length);
bool allowed = false;
X509_NAME* subject = X509_get_subject_name(certificate);
int index = subject ? X509_NAME_get_index_by_NID(subject, NID_commonName, -1) : -1;
if (index >= 0) {
X509_NAME_ENTRY* entry = X509_NAME_get_entry(subject, index);
ASN1_STRING* data = entry ? X509_NAME_ENTRY_get_data(entry) : NULL;
/* Convert the CN to UTF-8 to get its FULL byte length: unlike
* X509_NAME_get_text_by_NID (which truncates an over-long CN to the buffer
* and reports the truncated length), ASN1_STRING_to_UTF8 never truncates, so
* an exactly-required-length CN is accepted while an over-long one cannot be
* prefix-matched by a shorter required name. */
unsigned char* utf8 = NULL;
int cn_length = data ? ASN1_STRING_to_UTF8(&utf8, data) : -1;
if (cn_length >= 0 && (size_t)cn_length == required_length)
allowed = credentials_secure_equal((const char*)utf8, required_client_cn, required_length);
if (utf8)
OPENSSL_free(utf8);
}
X509_free(certificate);
return allowed;
}
@@ -592,6 +613,22 @@ static const char* server_module_gate(const Config* config, void* context) {
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
/* C3: a privileged (root) STANDALONE receiver defaults to SUPER_MODE_OFF.
* Without this a client --devices/--write-devices/--super would let a root
* server create arbitrary device nodes and write raw devices, and
* client-chosen ownership (--numeric-ids/--chown/--usermap/--groupmap) would
* be applied, with no operator opt-in. The operator must pass --allow-super
* to restore the historical permissive behavior; the flag is rejected for
* --stdio, whose client-composed argv must never defeat this default (an
* operator exposing `fastsync-server --stdio` over SSH needs a forced command
* to keep the permissive behavior). An unprivileged receiver is unaffected
* (the kernel refuses the confined attempts) and the daemon path keeps its
* per-module `client owner = yes` gate. */
if (g_daemon_conf == NULL && geteuid() == 0 && !server_allow_super) {
effective.super_mode = SUPER_MODE_OFF;
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
ownership of every written entry to the requested ids, which needs a
privileged (root) receiver. An unprivileged receiver REFUSES the whole
@@ -751,6 +788,13 @@ void handler(int file_descriptor) {
goto done;
}
config->use_delete = config->use_delete && allow_delete;
/* --force (receiver-side) is deletion authority too: it lets an incoming
* regular file recursively remove a non-empty destination directory tree, and
* lets --delete-missing-args remove a non-empty directory mirror. Without
* the operator's --allow-delete it must be inert, exactly like --delete and
* --delete-missing-args, so a client cannot use --force to bypass the delete
* policy. */
config->force_delete = config->force_delete && allow_delete;
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
now that the client's full CONVERT_SPEC has been received and validated,
before any received file name is decoded. The server's own --iconv (if
@@ -1010,6 +1054,13 @@ static void print_server_usage(void) {
printf(" --no-super Operator veto: never attempt super-user activities\n");
printf(" (ownership, device nodes) even as root, and refuse\n");
printf(" any client --copy-as/--super request\n");
printf(" --allow-super Standalone TCP listener only: keep super-user\n");
printf(" activities enabled for a root receiver. Without it a\n");
printf(" root standalone server forces SUPER_MODE_OFF, so client\n");
printf(" --devices/--write-devices/--super and ownership\n");
printf(" requests are refused/skipped. Never honored with\n");
printf(" --stdio (the SSH remote argv is client-composed, so\n");
printf(" super stays off there); no effect when not root\n");
printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n");
printf(" conversion: received names are translated to this\n");
printf(" charset (the wire charset still comes from the\n");
@@ -1134,6 +1185,9 @@ int main(int argc, char* argv[]) {
trust_sender = opts.trust_sender;
allow_unauthenticated = opts.allow_unauthenticated;
server_no_super = opts.no_super;
/* --stdio rejects --allow-super at parse time; force it off here as well so
* this process-global policy cannot be re-enabled by a future caller. */
server_allow_super = opts.allow_super && !opts.stdio_mode;
server_iconv_spec = opts.iconv_spec;
signal(SIGINT, cleanup);
signal(SIGTERM, cleanup);
+24
View File
@@ -179,6 +179,8 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
opts->trust_sender = true;
} else if (arg_is(argv[i], "--no-super")) {
opts->no_super = true;
} else if (arg_is(argv[i], "--allow-super")) {
opts->allow_super = true;
} else if (arg_is(argv[i], "--allow-unauthenticated")) {
opts->allow_unauthenticated = true;
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
@@ -259,6 +261,28 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
return -1;
}
if (opts->allow_super && opts->no_super) {
set_error(err, err_size, "--allow-super and --no-super are mutually exclusive");
return -1;
}
if (opts->allow_super && opts->daemon_mode) {
set_error(err, err_size,
"--allow-super is for a locally-launched standalone TCP server; daemon modules opt "
"in per module with 'client owner = yes'");
return -1;
}
/* --stdio is the SSH transport: the remote server argv is composed by the
* CLIENT (directly and via --remote-option), so a client could otherwise pass
* --allow-super to a root --stdio receiver and defeat the C3 secure default.
* Never honor it there; the super mode stays forced OFF. An operator who
* must keep the historical permissive behavior over SSH has to launch the
* receiver through a forced command, not via client-composed argv. */
if (opts->allow_super && opts->stdio_mode) {
set_error(err, err_size,
"--allow-super is not accepted with --stdio (the remote argv is client-composed; "
"use a forced command if the default must hold)");
return -1;
}
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
set_error(err, err_size, "--iterations requires --hash-credentials");
return -1;
+11
View File
@@ -45,6 +45,17 @@ typedef struct ServerCliOptions {
* device-node creation) even when running as root. Applies to --stdio and
* --daemon alike; also makes the server refuse any client --copy-as. */
bool no_super; /* --no-super */
/* --allow-super: locally-launched standalone TCP listener opt-in that keeps
* the historical permissive behavior for a PRIVILEGED (root) receiver.
* Without it a root standalone server forces SUPER_MODE_OFF, so a client
* --devices / --write-devices / --super / ownership request cannot make it
* create device nodes, write raw devices, or apply client-chosen ownership.
* It is rejected for --stdio: that path's remote argv is composed by the
* client (directly and via --remote-option), so it must never opt a root
* receiver back into super mode. Non-root receivers are unaffected (the
* kernel refuses the confined attempts). The daemon path instead uses the
* per-module `client owner = yes` opt-in. */
bool allow_super; /* --allow-super */
/* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The
* client's full spec rides the wire config frame anyway; when the server is
* started with its own --iconv, its LOCAL half overrides the local charset
+23 -6
View File
@@ -613,19 +613,36 @@ int config_parse_transport_dest(Config* config) {
int daemon_ret = config_parse_daemon_dest(config);
if (daemon_ret != 0)
return daemon_ret;
config_parse_ssh_dest(config);
return 0;
/* 0 for a local destination (nothing parsed) or a valid SSH destination;
* -1 (already logged) for an injection-shaped user@host. */
return config_parse_ssh_dest(config);
}
void config_parse_ssh_dest(Config* config) {
int config_parse_ssh_dest(Config* config) {
if (!config || !config->receive_root_directory)
return 0;
if (!config_is_remote_dest(config->receive_root_directory))
return;
return 0;
const char* dest = config->receive_root_directory;
const char* colon = strchr(dest, ':');
/* The user@host token is passed to ssh in option position, so a user or host
* beginning with '-' would be consumed by ssh as an option (argument
* injection: e.g. "-oProxyCommand=..."). An empty host is likewise not a
* valid destination. Validate before any wire/argv construction. */
const char* at = memchr(dest, '@', (size_t)(colon - dest));
const char* host = at ? at + 1 : dest;
size_t host_len = (size_t)(colon - host);
size_t user_len = at ? (size_t)(at - dest) : 0;
if (host_len == 0 || host[0] == '-' || (user_len > 0 && dest[0] == '-'))
return daemon_dest_parse_error("invalid remote destination user@host (must not be empty or "
"start with '-')",
dest);
config->transport = TRANSPORT_SSH;
config->ssh_destination = str_dup(config->receive_root_directory);
const char* colon = strchr(config->receive_root_directory, ':');
config->ssh_destination = str_dup(dest);
char* path = str_dup(colon + 1);
free(config->receive_root_directory);
config->receive_root_directory = path;
return 0;
}
void config_burn_auth(Config* config) {
+4 -1
View File
@@ -851,7 +851,10 @@ bool config_send(int file_descriptor, const Config* config);
bool config_send_wire_block(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config);
/* Parse a single-colon host:path SSH destination (0 = not an SSH destination or
* parsed successfully, -1 = rejected, e.g. a user@host beginning with '-'; the
* reason is logged). */
int config_parse_ssh_dest(Config* config);
/* A ConfigValidateFunc may return this sentinel to tell
* config_receive_with_validate that the callback ALREADY sent a terminal status
+2 -2
View File
@@ -158,13 +158,13 @@ static int hex_value(char c) {
* digits. Such a line is refused loudly (and never accepted) so an operator
* cannot keep a replayable bearer digest in place after the protocol bump. */
static bool secret_is_legacy_hex(const char* s) {
if (!s)
if (!s || strlen(s) != 64)
return false;
for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0)
return false;
}
return s[64] == '\0';
return true;
}
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
+24 -2
View File
@@ -133,6 +133,7 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false;
}
char* save = NULL;
int added = 0;
for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) {
if (!host_pattern_valid(token)) {
if (module_name)
@@ -163,8 +164,20 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false;
}
(*list)[(*count)++] = dup;
added++;
}
free(copy);
/* A present key with an empty (or separator-only) value would otherwise
* install a zero-length list, i.e. no ACL at all: a strict-parse config must
* never silently turn a restrictive directive into "allow everyone". */
if (added == 0) {
if (module_name)
set_error(err, err_size, "module '%s': '%s' must list at least one host pattern", module_name,
key);
else
set_error(err, err_size, "'%s' must list at least one host pattern", key);
return false;
}
return true;
}
@@ -403,6 +416,7 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false;
}
char* save = NULL;
int added = 0;
for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) {
const char* user = trim_ws(token);
if (*user == '\0')
@@ -430,8 +444,16 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false;
}
module->auth_users[module->auth_user_count++] = dup;
added++;
}
free(list);
/* An empty/separator-only value must not silently disable authentication:
* the key's presence is an explicit request for an allow-list. */
if (added == 0) {
set_error(err, err_size, "module '%s': 'auth users' must list at least one user",
module->name);
return false;
}
return true;
}
if (key_equals(key, "max connections"))
@@ -439,10 +461,10 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
"max connections", module->name, err, err_size);
if (key_equals(key, "hosts allow"))
return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow",
false, module->name, err, err_size);
module->name, false, err, err_size);
if (key_equals(key, "hosts deny"))
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
false, module->name, err, err_size);
module->name, false, err, err_size);
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
return false;
}
+19 -3
View File
@@ -75,6 +75,15 @@ static int parse_remote_dest(const char* dest, RemoteDest* r) {
memcpy(r->host, dest, host_len);
r->host[host_len] = '\0';
}
/* The user@host token is handed to ssh in option position. Reject anything
* that ssh would consume as an option (a leading '-') or an empty host, so a
* crafted destination can never inject an ssh option such as
* -oProxyCommand=... . This mirrors config_parse_ssh_dest's validation and
* is defense-in-depth for callers that bypass it. */
if (r->host[0] == '\0' || r->host[0] == '-' || (r->user[0] != '\0' && r->user[0] == '-')) {
remote_dest_destroy(r);
return -1;
}
return 0;
}
@@ -216,10 +225,10 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
nwords = 1;
}
/* Fixed tail: three -o pairs (6) + optional -p/value (2) + user@host +
* remote command + terminating NULL. */
/* Fixed tail: three -o pairs (6) + optional -p/value (2) + the "--" end of
* options marker + user@host + remote command + terminating NULL. */
int port_extra = (port > 0 && port != 22) ? 2 : 0;
size_t total = (size_t)nwords + 6 + (size_t)port_extra + 3;
size_t total = (size_t)nwords + 6 + (size_t)port_extra + 4;
char** argv = calloc(total, sizeof(char*));
if (!argv) {
for (int i = 0; i < nwords; i++)
@@ -253,6 +262,13 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
goto fail_argv;
ac++;
}
/* End of options: guarantees the user@host token that follows is treated as
* the destination and never re-interpreted as an ssh option, even if every
* caller-side validation were bypassed. */
argv[ac] = str_dup("--");
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = str_dup(userhost);
if (!argv[ac])
goto fail_argv;
+76 -15
View File
@@ -4,7 +4,9 @@
#include "transport_tcp.h"
#include "utils.h"
#include <arpa/inet.h>
#include <fcntl.h>
#include <openssl/err.h>
#include <openssl/pem.h>
#include <openssl/ssl.h>
#include <signal.h>
#include <stdio.h>
@@ -34,6 +36,59 @@ static void log_ssl_errors(void) {
}
}
/* Load the TLS private key through an already-opened, no-follow descriptor so
* the owner/mode policy is checked on the SAME file object that is loaded: an
* attacker cannot swap the path between a stat() and a later open() (TOCTOU).
* The exact-owner / 0600 policy is preserved and group/other execute bits are
* rejected as well. Ownership of the descriptor passes to the BIO and is
* released exactly once by BIO_free() (BIO_CLOSE). */
static bool load_private_key_secure(SSL_CTX* ctx, const char* key) {
int fd = open(key, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to open private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
return false;
}
struct stat key_stat;
if (fstat(fd, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH | S_IXGRP | S_IXOTH))) {
log_message(LOG_LEVEL_ERROR,
"TLS private key must be a regular file owned by the current user and private "
"(mode 0600)");
close(fd);
return false;
}
BIO* bio = BIO_new_fd(fd, BIO_CLOSE);
if (!bio) {
close(fd);
log_message(LOG_LEVEL_ERROR, "Failed to read private key");
return false;
}
EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
BIO_free(bio); /* releases fd via BIO_CLOSE */
if (!pkey) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
int use_ok = SSL_CTX_use_PrivateKey(ctx, pkey);
EVP_PKEY_free(pkey);
if (use_ok != 1) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to use private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
return true;
}
static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key,
const char* ca_path) {
if (!is_server && !ca_path) {
@@ -56,12 +111,21 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#ifdef SSL_OP_NO_RENEGOTIATION
SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION);
#endif
/* Let the server's own preference order decide the negotiated cipher rather
* than the client's, so a client cannot steer both peers into a weaker (but
* still offered) suite. */
SSL_CTX_set_options(ctx, SSL_OP_CIPHER_SERVER_PREFERENCE);
if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
SSL_CTX_free(ctx);
return NULL;
}
if (SSL_CTX_set_cipher_list(ctx, "HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES") != 1) {
/* TLS 1.2 and below: an AEAD-only suite list. "HIGH" still includes CBC
* suites (Lucky13/POODLE-adjacent MAC-then-encrypt constructions), so restrict
* the list to ECDHE key agreement with an AEAD record cipher (AES-GCM or
* ChaCha20-Poly1305). A NULL/weak/3DES cipher is never selectable. */
if (SSL_CTX_set_cipher_list(ctx, "ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!eNULL:!MD5:!RC4:!3DES") !=
1) {
SSL_CTX_free(ctx);
return NULL;
}
@@ -79,13 +143,6 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#endif
if (cert && key) {
struct stat key_stat;
if (stat(key, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH))) {
log_message(LOG_LEVEL_ERROR, "TLS private key must be owned by the current user and private");
SSL_CTX_free(ctx);
return NULL;
}
if (SSL_CTX_use_certificate_file(ctx, cert, SSL_FILETYPE_PEM) <= 0) {
char* escaped = output_escape(cert, false);
log_message(LOG_LEVEL_ERROR, "Failed to load certificate: %s",
@@ -95,12 +152,7 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
SSL_CTX_free(ctx);
return NULL;
}
if (SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) <= 0) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
if (!load_private_key_secure(ctx, key)) {
SSL_CTX_free(ctx);
return NULL;
}
@@ -144,7 +196,16 @@ static SSL* wrap_fd_with_ssl(int fd, SSL_CTX* ctx, bool is_server, const char* h
// Enable hostname verification for client connections when a hostname is provided.
// Must be done before SSL_connect to take effect during the handshake.
if (!is_server && hostname) {
if (SSL_set1_host(ssl, hostname) != 1) {
/* An IP-literal host must be verified against the certificate's IP SAN
* (X509_check_ip_asc), not as a DNS name: SSL_set1_host would look for a
* DNS SAN that a legitimate IP-SAN certificate never carries. */
struct in_addr ipv4;
struct in6_addr ipv6;
bool is_ip_literal =
inet_pton(AF_INET, hostname, &ipv4) == 1 || inet_pton(AF_INET6, hostname, &ipv6) == 1;
int set_ok = is_ip_literal ? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), hostname)
: SSL_set1_host(ssl, hostname);
if (set_ok != 1) {
SSL_free(ssl);
return NULL;
}