From f75a69f96aeece3dcf06c1c94094b87e2b469112 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:08:56 +0200 Subject: [PATCH 1/6] fix(ssh): reject option-injection destinations (C1) A remote destination's user@host token is passed to ssh in option position, so a host beginning with '-' (e.g. -oProxyCommand=...) was parsed by ssh as an option, allowing arbitrary command execution. - config_parse_ssh_dest now validates the user@host prefix and returns -1 (with a clear logged error) for an empty host or a user/host that starts with '-'; config_parse_transport_dest propagates the failure. - transport_ssh.c's parse_remote_dest applies the same validation as defense-in-depth, and ssh_build_client_argv inserts a '--' end-of-options marker before the destination token. - Unit tests cover -oProxyCommand=... / -prefixed hosts / empty host rejection and the argv shape. --- src/shared/config.c | 29 ++++++++++++++++++++++------ src/shared/config.h | 5 ++++- src/shared/transport_ssh.c | 22 ++++++++++++++++++--- tests/test_config.c | 29 ++++++++++++++++++++++++++++ tests/test_transport_ssh.c | 39 ++++++++++++++++++++++++++++---------- 5 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/shared/config.c b/src/shared/config.c index c5a3779..b7ad0d0 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -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) { diff --git a/src/shared/config.h b/src/shared/config.h index 05b01f9..509e3ee 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -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 diff --git a/src/shared/transport_ssh.c b/src/shared/transport_ssh.c index 97eb1ea..b9d1f93 100644 --- a/src/shared/transport_ssh.c +++ b/src/shared/transport_ssh.c @@ -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; diff --git a/tests/test_config.c b/tests/test_config.c index c4c83e9..f45baa2 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -87,6 +87,34 @@ static void test_config_ssh_dest_no_user() { config_delete(cfg); } +/* C1: the user@host token is passed to ssh in option position, so a host or user + * beginning with '-' (e.g. "-oProxyCommand=...") must be rejected before any + * argv is built, and an empty host must be rejected too. */ +static void test_config_ssh_dest_rejects_option_injection() { + Config* cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false, + false, 1, false, 0); + EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1); + EXPECT_EQ_INT(cfg->transport, TRANSPORT_TCP); + EXPECT_NULL(cfg->ssh_destination); + config_delete(cfg); + + cfg = + make_config("1.0", "/src", "-user@host:/dst", true, false, false, false, false, 1, false, 0); + EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1); + config_delete(cfg); + + cfg = make_config("1.0", "/src", "user@:/dst", true, false, false, false, false, 1, false, 0); + EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1); + config_delete(cfg); + + /* config_parse_transport_dest propagates the rejection (and still returns 1 + * for daemon syntax first). */ + cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false, false, 1, + false, 0); + EXPECT_EQ_INT(config_parse_transport_dest(cfg), -1); + config_delete(cfg); +} + static void test_config_daemon_dest_parse() { Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false, false, 1, false, 0); @@ -2789,6 +2817,7 @@ void test_config() { test_config_ssh_dest(); test_config_ssh_dest_local_path(); test_config_ssh_dest_no_user(); + test_config_ssh_dest_rejects_option_injection(); test_config_daemon_dest_parse(); test_config_daemon_dest_no_path(); test_config_daemon_dest_double_slash_normalized(); diff --git a/tests/test_transport_ssh.c b/tests/test_transport_ssh.c index 94a896a..2889444 100644 --- a/tests/test_transport_ssh.c +++ b/tests/test_transport_ssh.c @@ -66,16 +66,18 @@ static void test_ssh_remote_command_argument_modes() { free(command); } -/* The build for a single-word argv is [prog, six -o args, user, command]. */ +/* The build for a single-word argv is [prog, six -o args, "--", user, command]. */ static void test_ssh_build_client_argv_default_is_ssh() { char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio"); EXPECT_NOT_NULL(argv); EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[1], "-o"); - EXPECT_EQ_STR(argv[7], "u@h"); - EXPECT_EQ_STR(argv[8], "'srv' --stdio"); - EXPECT_NULL(argv[9]); + /* The "--" end-of-options marker precedes the destination token. */ + EXPECT_EQ_STR(argv[7], "--"); + EXPECT_EQ_STR(argv[8], "u@h"); + EXPECT_EQ_STR(argv[9], "'srv' --stdio"); + EXPECT_NULL(argv[10]); ssh_free_client_argv(argv); } @@ -84,7 +86,7 @@ static void test_ssh_build_client_argv_uses_custom_rsh() { char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc"); EXPECT_NOT_NULL(argv); EXPECT_EQ_STR(argv[0], "myrsh"); - EXPECT_NULL(argv[9]); + EXPECT_NULL(argv[10]); ssh_free_client_argv(argv); } @@ -96,21 +98,37 @@ static void test_ssh_build_client_argv_whitespace_command_and_port() { EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[1], "-p"); EXPECT_EQ_STR(argv[2], "2222"); - EXPECT_NULL(argv[11]); + EXPECT_NULL(argv[12]); ssh_free_client_argv(argv); argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc"); EXPECT_NOT_NULL(argv); EXPECT_EQ_STR(argv[0], "ssh"); - /* Flat [prog, -o x6, -p, port, user, command]. */ + /* Flat [prog, -o x6, -p, port, "--", user, command]. */ EXPECT_EQ_STR(argv[7], "-p"); EXPECT_EQ_STR(argv[8], "2222"); - EXPECT_EQ_STR(argv[9], "u@h"); - EXPECT_EQ_STR(argv[10], "rc"); - EXPECT_NULL(argv[11]); + EXPECT_EQ_STR(argv[9], "--"); + EXPECT_EQ_STR(argv[10], "u@h"); + EXPECT_EQ_STR(argv[11], "rc"); + EXPECT_NULL(argv[12]); ssh_free_client_argv(argv); } +/* C1: a destination host/user beginning with '-' would be parsed by ssh as an + * option (argument injection: -oProxyCommand=...), and an empty host is never + * valid. These are refused before any child is forked, so no Client is + * returned and no command can run. */ +static void test_ssh_connect_rejects_option_host() { + /* cppcheck-suppress constVariablePointer */ + Client* client = client_connect_ssh("-oProxyCommand=touch /tmp/pwned:/remote", 22, NULL, false, + NULL, false, NULL, 0); + EXPECT_NULL(client); + client = client_connect_ssh("-evil:/remote", 22, NULL, false, NULL, false, NULL, 0); + EXPECT_NULL(client); + client = client_connect_ssh("user@:/remote", 22, NULL, false, NULL, false, NULL, 0); + EXPECT_NULL(client); +} + /* --remote-option=OPT appends OPT to the remote command line after " --stdio", * each escaped as its own single-quoted shell word. Metacharacters that could * break out of the quoting are neutralized (never injected), matching the @@ -162,6 +180,7 @@ void test_transport_ssh() { test_ssh_connect_invalid_dest_empty(); test_ssh_connect_malformed(); test_ssh_connect_unreachable(); + test_ssh_connect_rejects_option_host(); test_ssh_remote_command_argument_modes(); test_ssh_build_client_argv_default_is_ssh(); test_ssh_build_client_argv_uses_custom_rsh(); From 0d6c1f784fc9470eb79dc8ce1abad0516e7757a8 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:09:01 +0200 Subject: [PATCH 2/6] fix(daemon-conf): reject empty hosts/auth allow-lists (C4) A present hosts allow/hosts deny/auth users key with an empty or separator-only value produced a zero-length list, silently meaning no ACL / no auth and contradicting the strict-parse contract. store_host_list and the auth users parser now track how many entries a present key actually added and fail the load with a clear error when it is zero, so a restrictive directive can never silently become open. Unit tests cover empty, whitespace-only and comma-only values. --- src/shared/daemon_conf.c | 22 ++++++++++++++++++ tests/test_daemon_conf.c | 50 ++++++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 300e196..0e30cb3 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -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")) diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index 3cddf0f..9b8f412 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -391,6 +391,22 @@ static void test_daemon_conf_auth_users_validated() { EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice"); EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob"); daemon_conf_free(ok_conf); + + /* C4: an empty or separator-only `auth users` value is a parse error. It + * would otherwise leave the module with a zero-length allow-list, silently + * disabling the authentication the operator asked for. */ + const char* empty_auth[] = { + "[m]\npath = /x\nauth users = \n", + "[m]\npath = /x\nauth users = , ,\n", + "[m]\npath = /x\nauth users = \t\n", + }; + for (size_t i = 0; i < sizeof(empty_auth) / sizeof(empty_auth[0]); i++) { + EXPECT_EQ_INT(write_conf(empty_auth[i], &path), 0); + const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(rejected); + EXPECT_TRUE(strstr(err, "'auth users' must list at least one user") != NULL); + } } /* Wave 3 daemon hardening: configurable global/per-module connection caps, @@ -475,13 +491,33 @@ static void test_daemon_conf_limits_and_hosts_parse() { EXPECT_EQ_INT(conf->modules[0].max_connections, 0); daemon_conf_free(conf); - /* An empty hosts list is not an error (no patterns are added). */ - EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0); - conf = daemon_conf_load(path, err, sizeof(err)); - free(path); - EXPECT_NOT_NULL(conf); - EXPECT_EQ_INT(conf->global.hosts_allow_count, 0); - daemon_conf_free(conf); + /* C4: a present hosts key with an empty/separator-only value must not silently + * install a zero-length (allow-everyone) list. */ + const char* empty_hosts[] = { + "hosts allow = \n[m]\npath = /x\n", + "hosts deny = \n[m]\npath = /x\n", + "hosts allow = , ,\n[m]\npath = /x\n", + "hosts deny = \t\n[m]\npath = /x\n", + }; + for (size_t i = 0; i < sizeof(empty_hosts) / sizeof(empty_hosts[0]); i++) { + EXPECT_EQ_INT(write_conf(empty_hosts[i], &path), 0); + const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(rejected); + EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL); + } + + const char* empty_module_hosts[] = { + "[m]\npath = /x\nhosts allow = \n", + "[m]\npath = /x\nhosts deny = ,\n", + }; + for (size_t i = 0; i < sizeof(empty_module_hosts) / sizeof(empty_module_hosts[0]); i++) { + EXPECT_EQ_INT(write_conf(empty_module_hosts[i], &path), 0); + const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NULL(rejected); + EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL); + } } static void test_daemon_hosts_allowed() { From 551c1870059825be64e4890059b8a67b104d15c1 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:09:21 +0200 Subject: [PATCH 3/6] fix(tls): AEAD-only 1.2 suites, server preference, TOCTOU key load, IP SAN C5: restrict the TLS 1.2 and below cipher list to ECDHE AEAD suites (ECDHE+AESGCM:ECDHE+CHACHA20, minus NULL/eNULL/MD5/RC4/3DES) instead of HIGH (which includes CBC), and set SSL_OP_CIPHER_SERVER_PREFERENCE so the server's order decides the negotiated cipher. Client and server share create_ssl_ctx, so both are updated. C7: load the private key through an O_RDONLY|O_NOFOLLOW|O_CLOEXEC fd, fstat that fd and validate owner/mode (now also rejecting group/other execute bits), then load from the fd via BIO_new_fd. This removes the stat-to-load TOCTOU race while keeping the exact-owner/0600 policy. C8: verify an IP-literal client hostname against the certificate IP SAN with X509_VERIFY_PARAM_set1_ip_asc instead of SSL_set1_host (a DNS check), falling back to SSL_set1_host for real names. Unit tests assert the server-preference option, the absence of CBC/RC4/ 3DES suites, and that context creation still succeeds. --- src/shared/transport_tls.c | 91 +++++++++++++++++++++++++++++++------- tests/test_transport_tls.c | 11 +++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/shared/transport_tls.c b/src/shared/transport_tls.c index f95a7bc..839f2bc 100644 --- a/src/shared/transport_tls.c +++ b/src/shared/transport_tls.c @@ -4,7 +4,9 @@ #include "transport_tcp.h" #include "utils.h" #include +#include #include +#include #include #include #include @@ -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 : ""); + 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 : ""); + 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 : ""); + 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 : ""); - 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; } diff --git a/tests/test_transport_tls.c b/tests/test_transport_tls.c index 28bbba5..cc13aea 100644 --- a/tests/test_transport_tls.c +++ b/tests/test_transport_tls.c @@ -24,6 +24,17 @@ static void test_server_create_tls_without_certs() { #ifdef SSL_OP_NO_RENEGOTIATION EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0); #endif + /* C5: the server's preference order decides the cipher and the TLS 1.2 list is + * AEAD-only (no CBC/RC4/3DES legacy suites). */ + EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_CIPHER_SERVER_PREFERENCE) != 0); + STACK_OF(SSL_CIPHER)* ciphers = SSL_CTX_get_ciphers(ctx); + EXPECT_NOT_NULL(ciphers); + for (int i = 0; i < sk_SSL_CIPHER_num(ciphers); i++) { + const char* name = SSL_CIPHER_get_name(sk_SSL_CIPHER_value(ciphers, i)); + EXPECT_TRUE(name != NULL && strstr(name, "CBC") == NULL); + EXPECT_TRUE(name != NULL && strstr(name, "RC4") == NULL); + EXPECT_TRUE(name != NULL && strstr(name, "3DES") == NULL); + } server_delete(&s); EXPECT_NULL(s); } From 80c1ff321c8be45cccb3d4364689be12bd12a0c7 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:09:25 +0200 Subject: [PATCH 4/6] fix(credentials): length-check before legacy-hex scan (C9) secret_is_legacy_hex indexed s[0..63] without first checking the string length, reading out of bounds for a shorter secret. Require strlen(s) == 64 before scanning, and add a unit test that short and 63-hex-digit secrets are rejected as ordinary malformed verifiers (never misreported as legacy). --- src/shared/credentials.c | 4 ++-- tests/test_credentials.c | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/shared/credentials.c b/src/shared/credentials.c index f1a0c98..7f79c52 100644 --- a/src/shared/credentials.c +++ b/src/shared/credentials.c @@ -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) { diff --git a/tests/test_credentials.c b/tests/test_credentials.c index 86d4dd6..80280e7 100644 --- a/tests/test_credentials.c +++ b/tests/test_credentials.c @@ -473,6 +473,34 @@ static void test_credentials_store_rejects_legacy_hex() { free(path); } +/* C9: the legacy-hex detector must check the length before indexing 64 bytes, so + * a short secret is never read out of bounds. Such a line is rejected for the + * ordinary "expected verifier" reason, never as legacy. */ +static void test_credentials_store_rejects_short_secret() { + char contents[CREDENTIAL_MAX_LINE]; + snprintf(contents, sizeof(contents), "alice:%s\n", "abc"); + char* path = make_tmp_file(contents); + EXPECT_NOT_NULL(path); + char err[512]; + const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err)); + EXPECT_NULL(store); + EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL); + rm_temp(path); + free(path); + + char short_hex[64]; + memset(short_hex, 'a', 63); + short_hex[63] = '\0'; + snprintf(contents, sizeof(contents), "alice:%s\n", short_hex); + path = make_tmp_file(contents); + EXPECT_NOT_NULL(path); + store = credentials_load(path, NULL, err, sizeof(err)); + EXPECT_NULL(store); + EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL); + rm_temp(path); + free(path); +} + static void test_credentials_store_duplicate_rejected() { char line[CREDENTIAL_MAX_LINE]; EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line))); @@ -1066,6 +1094,7 @@ void test_credentials(void) { test_credentials_store_parse_valid(); test_credentials_store_parse_rejects_malformed(); test_credentials_store_rejects_legacy_hex(); + test_credentials_store_rejects_short_secret(); test_credentials_store_duplicate_rejected(); test_credentials_store_rejects_nonuniform_iters(); test_credentials_store_parse_missing_file(); From 9da5a0a9ed9c1b685238bcf64c09c31b2d8c2869 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 16:09:37 +0200 Subject: [PATCH 5/6] fix(server): gate --force by --allow-delete and secure root super default C2: --force is deletion authority (an incoming regular file may remove a non-empty destination directory tree, and --delete-missing-args may remove a non-empty directory mirror), but it was not masked by the operator --allow-delete policy. The handler now clears config->force_delete unless --allow-delete was given, exactly like --delete and --delete-missing-args. C3: a standalone TCP / --stdio server running as root defaulted to SUPER_MODE_AUTO, so an untrusted client --devices/--write-devices/ --super could make it create device nodes, write raw devices, or apply client-chosen ownership. A privileged standalone receiver now forces SUPER_MODE_OFF unless the operator opts in with the new server-only --allow-super flag. Non-root receivers are unchanged, and the daemon path keeps its per-module `client owner = yes` gate. --allow-super is rejected with --no-super or --daemon. C6: tls_client_identity_allowed now rejects a CN whose reported length reached the buffer bound, so a truncated over-long CN cannot be matched by a required --client-cn prefix. Tests: an integration regression proving --force cannot replace a destination directory without --allow-delete; standalone-default tests for --copy-as refusal and (root-only) skipped device creation; a CLI unit test for the new flag. The integration shared_server fixture opts in with --allow-super so the existing root-only ownership/device/copy-as tests continue to exercise the opted-in configuration. README and RSYNC_COMPAT document the flag and the force/delete gating. --- README.md | 4 +- RSYNC_COMPAT.md | 12 ++--- src/server/server.c | 41 ++++++++++++++- src/server/server_cli.c | 12 +++++ src/server/server_cli.h | 8 +++ tests/conftest.py | 8 ++- tests/integration/test_features.py | 84 +++++++++++++++++++++++++++++- tests/test_server_cli.c | 23 ++++++++ 8 files changed, 180 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b7d8a0e..9ab5b00 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ transfer is never aborted. | `--ca ` | TLS CA certificate file for verification (PEM) | | `--destination-root ` | Authorized destination root (default: `.`) | | `--allow-delete` | Permit manifest deletion | +| `--allow-super` | Standalone/`--stdio` only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. No effect when not root. | | `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. | | `-v, --verbose` | Enable debug logging | | `--help` | Show help | @@ -498,7 +499,8 @@ link-target transfer remains incomplete. | | `--ca ` | CA file for peer verification. | | `--destination-root ` | Confine received files to this server-side root; defaults to the current directory. | -| `--allow-delete` | Permit client delete manifests. Deletion is refused by default. | +| `--allow-delete` | Permit client delete manifests. Deletion is refused by default. This also gates `--force` (which can recursively replace/remove a destination directory tree). | +| `--allow-super` | Standalone/`--stdio` only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. No effect when not root. Daemon modules opt in per module with `client owner = yes`. | | `-v`, `--verbose` | Enable debug logging. | | `--help` | Print server usage. | diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index a51bbff..616aefc 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`. | `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) | | `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | | `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | -| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | +| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone/`--stdio` receiver now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (an unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | | `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | | `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path | | `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | | `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes | | `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) | -| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | +| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone/`--stdio` server refuses it by default too and only honors it after the operator passes `--allow-super`, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener and SSH `--stdio` server honor these for their single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | **Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`, `-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new. @@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved - **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time. - **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. -- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. +- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (a root standalone listener and SSH `--stdio` server honor them for their single operator-authorized root only when started with `--allow-super`). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay. - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$$$$`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). **The legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there. @@ -662,7 +662,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved | `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) | | `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way | | `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation | -| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump | +| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. `--force` is deletion authority and is therefore gated by the server `--allow-delete` policy exactly like `--delete`/`--delete-missing-args`: without it the receiver clears the flag, so a client cannot use `--force` to recursively replace or remove a destination directory tree. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump | ## 16. Batch Operations @@ -832,9 +832,9 @@ These are the last compatibility items and the closing phase toward rsync flag p **Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root. -`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). +`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). A privileged (root) standalone/`--stdio` server instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity; a non-root server is unchanged. On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). -`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. +`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (a root standalone listener or SSH-launched `--stdio` server, which each serve one operator-authorized root, honors these requests only when started with `--allow-super`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. **Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity. diff --git a/src/server/server.c b/src/server/server.c index 708fe7f..b977023 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -37,6 +37,12 @@ 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: standalone/--stdio 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. */ +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; @@ -191,8 +197,12 @@ static bool tls_client_identity_allowed(SSL* ssl) { 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) && + /* X509_NAME_get_text_by_NID truncates an over-long CN to the buffer size; a + * returned length at the buffer bound means the CN was silently shortened, so + * a required-name prefix could be matched by a longer CN with extra suffix. + * Reject any result that reached the bound. */ + bool allowed = length >= 0 && (size_t)length < sizeof(common_name) - 1 && + (size_t)length == required_length && required_length < sizeof(common_name) && credentials_secure_equal(common_name, required_client_cn, required_length); X509_free(certificate); return allowed; @@ -592,6 +602,20 @@ 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/--stdio 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; 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 +775,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 +1041,11 @@ 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/--stdio only: keep super-user activities\n"); + printf(" enabled for a root receiver. Without it a root\n"); + printf(" standalone server forces SUPER_MODE_OFF, so client\n"); + printf(" --devices/--write-devices/--super and ownership\n"); + printf(" requests are refused/skipped. 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 +1170,7 @@ int main(int argc, char* argv[]) { trust_sender = opts.trust_sender; allow_unauthenticated = opts.allow_unauthenticated; server_no_super = opts.no_super; + server_allow_super = opts.allow_super; server_iconv_spec = opts.iconv_spec; signal(SIGINT, cleanup); signal(SIGTERM, cleanup); diff --git a/src/server/server_cli.c b/src/server/server_cli.c index 794a97a..3cdaa75 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -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,16 @@ 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 standalone/--stdio server; daemon modules opt in per " + "module with 'client owner = yes'"); + return -1; + } if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) { set_error(err, err_size, "--iterations requires --hash-credentials"); return -1; diff --git a/src/server/server_cli.h b/src/server/server_cli.h index ab19a7f..8965def 100644 --- a/src/server/server_cli.h +++ b/src/server/server_cli.h @@ -45,6 +45,14 @@ 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: standalone/--stdio only 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. 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 diff --git a/tests/conftest.py b/tests/conftest.py index d4dd89d..e5619ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,13 @@ def shared_server(): Under pytest-xdist this session fixture is instantiated once per worker process, so each worker gets its own server on an ephemeral port.""" server = ServerManager() - server.start() + # --allow-super keeps the historical permissive super mode for a root + # receiver: the integration suite's root-only ownership/device/copy-as tests + # exercise that opted-in configuration. The secure default (a root + # standalone server without --allow-super forces SUPER_MODE_OFF) is covered + # explicitly by TestStandaloneSuperDefault in test_features.py. Non-root + # runs are unaffected by the flag. + server.start(extra_args=["--allow-super"]) yield server server.stop() diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 96372cb..381fd06 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -1561,8 +1561,33 @@ class TestDelete: assert not missing, f"Missing: {missing}" assert not mismatches, f"Mismatch: {mismatches}" - -class TestProgress: + @pytest.mark.ci + def test_force_cannot_replace_directory_without_allow_delete(self): + """C2: --force is deletion authority (an incoming file may recursively + remove a non-empty destination directory tree). A server started without + --allow-delete must clear it, so the operator's delete policy cannot be + bypassed with --force.""" + source = os.path.join(TEST_DATA_DIR, "force_src") + dest = os.path.join(TEST_DATA_DIR, "force_dst") + clean_dir(source) + clean_dir(dest) + with open(os.path.join(source, "blocker"), "wb") as f: + f.write(b"incoming file\n") + received = get_dest_received_dir(dest, source) + blocker = os.path.join(received, "blocker") + os.makedirs(blocker) + nested = os.path.join(blocker, "nested.txt") + with open(nested, "w") as f: + f.write("survivor") + # Deliberately NO --allow-delete. + server = ServerManager() + server.start() + try: + run_client(source, dest, flags=["--force"], port=server.port) + finally: + server.stop() + assert os.path.isdir(blocker), "unauthorized --force removed a destination directory" + assert os.path.exists(nested), "unauthorized --force removed a nested file" def test_progress_output(self, shared_server): clean_dir(DEST_DIR) result, dur = run_client( @@ -4578,6 +4603,61 @@ class TestSuperPrivilege: f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}" +class TestStandaloneSuperDefault: + """C3: a privileged (root) STANDALONE server without --allow-super forces + SUPER_MODE_OFF, so a client cannot make it create device nodes, write raw + devices, apply ownership, or use --copy-as. The shared_server fixture opts in + with --allow-super to keep the historical behavior available to the existing + root-only tests; these tests start their own un-opted server.""" + + @pytest.mark.ci + def test_copy_as_refused_without_allow_super(self): + """--copy-as is a client-chosen-ownership request and must be refused by + a standalone server that did not opt in with --allow-super (on a non-root + receiver it is refused for lack of privilege either way).""" + source = os.path.join(TEST_DATA_DIR, "super_default_src") + dest = os.path.join(TEST_DATA_DIR, "super_default_dst") + clean_dir(source) + clean_dir(dest) + with open(os.path.join(source, "f.txt"), "wb") as f: + f.write(b"no copy-as\n") + server = ServerManager() + server.start() # deliberately no --allow-super + try: + result, _ = run_client(source, dest, + flags=["--preserve", "--copy-as=@65534:@65534"], + port=server.port) + finally: + server.stop() + assert result.returncode != 0, ( + "standalone server accepted --copy-as without --allow-super" + ) + + @pytest.mark.skipif(os.geteuid() != 0, reason="root can create the source device node") + def test_devices_skipped_without_allow_super(self): + """Root standalone server without --allow-super must skip device-node + creation even for a client --devices request (the run still succeeds and + the regular file transfers).""" + source = os.path.join(TEST_DATA_DIR, "super_default_dev_src") + dest = os.path.join(TEST_DATA_DIR, "super_default_dev_dst") + clean_dir(source) + clean_dir(dest) + with open(os.path.join(source, "plain.txt"), "wb") as f: + f.write(b"regular\n") + os.mknod(os.path.join(source, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3)) + server = ServerManager() + server.start() # deliberately no --allow-super + try: + result, _ = run_client(source, dest, flags=["--devices"], port=server.port) + finally: + server.stop() + assert result.returncode == 0, f"exit {result.returncode}: {(result.stderr or '')[:200]}" + received = get_dest_received_dir(dest, source) + assert not os.path.lexists(os.path.join(received, "null")), ( + "root standalone server created a device node without --allow-super" + ) + + class TestHardLinks: """-H/--hard-links: source files sharing an inode are re-created as hard links to one another on the destination (dedup preserved, first copy diff --git a/tests/test_server_cli.c b/tests/test_server_cli.c index c057ebd..04f031b 100644 --- a/tests/test_server_cli.c +++ b/tests/test_server_cli.c @@ -32,6 +32,28 @@ static void test_server_cli_defaults() { EXPECT_FALSE(opts.allow_delete); EXPECT_FALSE(opts.allow_unauthenticated); EXPECT_FALSE(opts.no_super); + EXPECT_FALSE(opts.allow_super); + server_cli_options_free(&opts); +} + +/* C3: --allow-super is the standalone/--stdio opt-in for a privileged receiver; + * it never combines with --no-super, and daemon modules use their own per-module + * `client owner = yes` opt-in instead. */ +static void test_server_cli_allow_super() { + const char* args[] = {"fastsync-server", "--allow-super", "--destination-root", "/srv"}; + ServerCliOptions opts; + EXPECT_EQ_INT(parse_ok(args, 4, &opts), 0); + EXPECT_TRUE(opts.allow_super); + server_cli_options_free(&opts); + + char err[256]; + const char* a1[] = {"s", "--allow-super", "--no-super"}; + EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1); + EXPECT_TRUE(strstr(err, "mutually exclusive") != NULL); + + const char* a2[] = {"s", "--daemon", "--config=/tmp/x.conf", "--allow-super"}; + EXPECT_EQ_INT(server_cli_parse(4, (char**)a2, &opts, err, sizeof(err)), -1); + EXPECT_TRUE(strstr(err, "client owner") != NULL); server_cli_options_free(&opts); } @@ -224,5 +246,6 @@ void test_server_cli() { test_server_cli_password_and_early_input(); test_server_cli_password_requires_daemon(); test_server_cli_no_super(); + test_server_cli_allow_super(); test_server_cli_help(); } From 825ba6975349952b0270a166c044224b4e0b6163 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 14 Sep 2026 17:16:01 +0200 Subject: [PATCH 6/6] fix(server): reject --allow-super with --stdio, fix module host-list append Re-review findings on the C3/C4 hardening branch: - --stdio is the SSH transport whose remote argv is composed by the client (including via --remote-option), so accepting --allow-super there let a client defeat the C3 secure default for a root receiver. Reject it at CLI parse time (standalone TCP only) and force the process-global flag off for --stdio as defense in depth. Correct the help text and README/RSYNC_COMPAT: the --stdio argv is client-composed, super stays off, and a forced command is needed if the default must hold. - daemon_conf: the per-module 'hosts allow'/'hosts deny' call sites passed module_name and replace in the wrong order, so multiple lines replaced instead of appended and the empty-value error omitted the module name. Pass (module->name, false) like the global keys; add a unit test for two per-module allow/deny lines appending. - tls: read the client CN via ASN1_STRING_to_UTF8 so an exactly-required-length name is accepted and only actual over-length CNs are rejected. --- README.md | 10 ++++-- RSYNC_COMPAT.md | 10 +++--- src/server/server.c | 75 ++++++++++++++++++++++++---------------- src/server/server_cli.c | 16 +++++++-- src/server/server_cli.h | 17 +++++---- src/shared/daemon_conf.c | 4 +-- tests/test_daemon_conf.c | 23 ++++++++++++ tests/test_server_cli.c | 13 +++++-- 8 files changed, 118 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 9ab5b00..26d1725 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ transfer is never aborted. | `--ca ` | TLS CA certificate file for verification (PEM) | | `--destination-root ` | Authorized destination root (default: `.`) | | `--allow-delete` | Permit manifest deletion | -| `--allow-super` | Standalone/`--stdio` only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. No effect when not root. | +| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. **Rejected with `--stdio`** (the SSH remote argv is client-composed, so a client could otherwise pass it and defeat the secure default; operators exposing `fastsync-server --stdio` over SSH must use a forced command if the default must hold). No effect when not root. | | `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. | | `-v, --verbose` | Enable debug logging | | `--help` | Show help | @@ -305,6 +305,12 @@ The remote host must have `fastsync-server` available in `PATH`, or use working directory, so use a destination below that directory unless the remote server is otherwise configured with a matching authorized root. +The remote `--stdio` server argv is composed by the client, so it must never +be trusted to opt a root receiver into super-user activities: `--allow-super` +is rejected with `--stdio` and super stays off on that path. Operators +exposing `fastsync-server --stdio` over SSH must use a forced command (e.g. an +`authorized_keys` `command=` entry) if the default must hold. + ```bash ssh user@host 'mkdir -p destination' ./build/client /path/to/source user@host:destination @@ -500,7 +506,7 @@ link-target transfer remains incomplete. | | `--destination-root ` | Confine received files to this server-side root; defaults to the current directory. | | `--allow-delete` | Permit client delete manifests. Deletion is refused by default. This also gates `--force` (which can recursively replace/remove a destination directory tree). | -| `--allow-super` | Standalone/`--stdio` only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. No effect when not root. Daemon modules opt in per module with `client owner = yes`. | +| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. Rejected with `--stdio` (the SSH remote argv is client-composed; use a forced command if the default must hold). No effect when not root. Daemon modules opt in per module with `client owner = yes`. | | `-v`, `--verbose` | Enable debug logging. | | `--help` | Print server usage. | diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 616aefc..f25945a 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`. | `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) | | `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | | `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | -| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone/`--stdio` receiver now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (an unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | +| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone TCP listener now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (the flag is **rejected with `--stdio`**, whose remote argv is composed by the client and must never defeat the secure default; operators exposing `fastsync-server --stdio` over SSH need a forced command if the default must hold. An unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | | `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | | `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path | | `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | | `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes | | `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) | -| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone/`--stdio` server refuses it by default too and only honors it after the operator passes `--allow-super`, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener and SSH `--stdio` server honor these for their single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | +| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone TCP listener refuses it by default too and only honors it after the operator passes `--allow-super` (the flag is rejected with `--stdio`, where the client-composed remote argv could otherwise defeat the default; a forced command is required if the default must hold), and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener honors these for its single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | **Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`, `-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new. @@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved - **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time. - **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. -- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (a root standalone listener and SSH `--stdio` server honor them for their single operator-authorized root only when started with `--allow-super`). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. +- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (a root standalone TCP listener honors them for its single operator-authorized root only when started with `--allow-super`; the flag is rejected with `--stdio`, whose client-composed remote argv must never opt back into super mode). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay. - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$$$$`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). **The legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there. @@ -832,9 +832,9 @@ These are the last compatibility items and the closing phase toward rsync flag p **Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root. -`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). A privileged (root) standalone/`--stdio` server instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity; a non-root server is unchanged. On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). +`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). A privileged (root) standalone TCP listener instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity (the flag is rejected with `--stdio`, whose client-composed remote argv must never defeat the default; use a forced command if the default must hold); a non-root server is unchanged. On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes). -`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (a root standalone listener or SSH-launched `--stdio` server, which each serve one operator-authorized root, honors these requests only when started with `--allow-super`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. +`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (a root standalone TCP listener, which serves one operator-authorized root, honors these requests only when started with `--allow-super`; the flag is rejected with `--stdio`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. **Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity. diff --git a/src/server/server.c b/src/server/server.c index b977023..c9432f5 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -37,11 +37,14 @@ 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: standalone/--stdio 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. */ +/* --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 @@ -193,17 +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); - /* X509_NAME_get_text_by_NID truncates an over-long CN to the buffer size; a - * returned length at the buffer bound means the CN was silently shortened, so - * a required-name prefix could be matched by a longer CN with extra suffix. - * Reject any result that reached the bound. */ - bool allowed = length >= 0 && (size_t)length < sizeof(common_name) - 1 && - (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; } @@ -602,15 +613,17 @@ 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/--stdio 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; an - * unprivileged receiver is unaffected (the kernel refuses the confined - * attempts) and the daemon path keeps its per-module `client owner = yes` - * gate. */ + /* 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) @@ -1041,11 +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/--stdio only: keep super-user activities\n"); - printf(" enabled for a root receiver. Without it a root\n"); - printf(" standalone server forces SUPER_MODE_OFF, so client\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. No effect when not root\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"); @@ -1170,7 +1185,9 @@ int main(int argc, char* argv[]) { trust_sender = opts.trust_sender; allow_unauthenticated = opts.allow_unauthenticated; server_no_super = opts.no_super; - server_allow_super = opts.allow_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); diff --git a/src/server/server_cli.c b/src/server/server_cli.c index 3cdaa75..4400106 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -267,8 +267,20 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, } if (opts->allow_super && opts->daemon_mode) { set_error(err, err_size, - "--allow-super is for a standalone/--stdio server; daemon modules opt in per " - "module with 'client owner = yes'"); + "--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) { diff --git a/src/server/server_cli.h b/src/server/server_cli.h index 8965def..ea7d625 100644 --- a/src/server/server_cli.h +++ b/src/server/server_cli.h @@ -45,13 +45,16 @@ 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: standalone/--stdio only 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. Non-root - * receivers are unaffected (the kernel refuses the confined attempts). The - * daemon path instead uses the per-module `client owner = yes` opt-in. */ + /* --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 diff --git a/src/shared/daemon_conf.c b/src/shared/daemon_conf.c index 0e30cb3..88b836f 100644 --- a/src/shared/daemon_conf.c +++ b/src/shared/daemon_conf.c @@ -461,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; } diff --git a/tests/test_daemon_conf.c b/tests/test_daemon_conf.c index 9b8f412..5d0a46d 100644 --- a/tests/test_daemon_conf.c +++ b/tests/test_daemon_conf.c @@ -517,7 +517,30 @@ static void test_daemon_conf_limits_and_hosts_parse() { free(path); EXPECT_NULL(rejected); EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL); + /* The diagnostic must name the offending module. */ + EXPECT_TRUE(strstr(err, "module 'm'") != NULL); } + + /* Per-module host lists APPEND across lines like the global ones. (A swapped + * store_host_list call passed the module name as `replace`, so each line + * silently replaced the previous one and only the last survived.) */ + EXPECT_EQ_INT(write_conf("[m]\npath = /x\n" + "hosts allow = 127.0.0.1\n" + "hosts allow = 10.0.0.0/8\n" + "hosts deny = 192.168.0.1\n" + "hosts deny = 2001:db8::/32\n", + &path), + 0); + conf = daemon_conf_load(path, err, sizeof(err)); + free(path); + EXPECT_NOT_NULL(conf); + EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 2); + EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1"); + EXPECT_EQ_STR(conf->modules[0].hosts_allow[1], "10.0.0.0/8"); + EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 2); + EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "192.168.0.1"); + EXPECT_EQ_STR(conf->modules[0].hosts_deny[1], "2001:db8::/32"); + daemon_conf_free(conf); } static void test_daemon_hosts_allowed() { diff --git a/tests/test_server_cli.c b/tests/test_server_cli.c index 04f031b..5c2cca9 100644 --- a/tests/test_server_cli.c +++ b/tests/test_server_cli.c @@ -36,9 +36,10 @@ static void test_server_cli_defaults() { server_cli_options_free(&opts); } -/* C3: --allow-super is the standalone/--stdio opt-in for a privileged receiver; - * it never combines with --no-super, and daemon modules use their own per-module - * `client owner = yes` opt-in instead. */ +/* C3: --allow-super is the locally-launched standalone TCP opt-in for a + * privileged receiver; it never combines with --no-super, is refused with + * --stdio (whose client-composed remote argv must not defeat the default), and + * daemon modules use their own per-module `client owner = yes` opt-in instead. */ static void test_server_cli_allow_super() { const char* args[] = {"fastsync-server", "--allow-super", "--destination-root", "/srv"}; ServerCliOptions opts; @@ -54,6 +55,12 @@ static void test_server_cli_allow_super() { const char* a2[] = {"s", "--daemon", "--config=/tmp/x.conf", "--allow-super"}; EXPECT_EQ_INT(server_cli_parse(4, (char**)a2, &opts, err, sizeof(err)), -1); EXPECT_TRUE(strstr(err, "client owner") != NULL); + + /* The SSH/--stdio receiver argv is composed by the client, so --allow-super + * must be rejected there and the C3 secure default stays in force. */ + const char* a3[] = {"s", "--stdio", "--allow-super", "--destination-root", "/srv"}; + EXPECT_EQ_INT(server_cli_parse(5, (char**)a3, &opts, err, sizeof(err)), -1); + EXPECT_TRUE(strstr(err, "--stdio") != NULL); server_cli_options_free(&opts); }