fix(p8h): restore daemon --super refusal, race-free secret-file check, remaining log escapes

- server_module_gate: refuse client-chosen ownership against the ORIGINAL config
  so an explicit --super is still refused under an operator --no-super veto
  (the veto must not turn a refusal into an accept).
- credentials: open-then-fstat the exact secret inode, require current-user
  ownership and no group/other bits, but continue to allow process-substitution
  FIFOs; removes the stat->fopen TOCTOU.
- file.c preallocate + protocol.c send-string debug logs escape attacker paths.
- usage/RSYNC_COMPAT updated for --old-args no-op and secret-file rules.
This commit is contained in:
2026-09-12 15:50:14 +02:00
parent e1bb2e9233
commit 108fee1e41
6 changed files with 69 additions and 43 deletions
+1 -1
View File
@@ -629,7 +629,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` | | `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` | | `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` | | `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server refuse to load a `--password-file`/`--early-input` that grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat | | `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) | | `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding. **Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
+2 -2
View File
@@ -246,8 +246,8 @@ void print_usage(void) {
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n"); printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
printf(" --fastsync-server-path <path>\n"); printf(" --fastsync-server-path <path>\n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n"); printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf( printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
" --old-args Disable safe SSH command argument quoting (legacy compatibility)\n"); printf(" remote server path is always safely quoted now)\n");
printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n"); printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
printf(" (repeatable; each value is single-quote-escaped on the remote\n"); printf(" (repeatable; each value is single-quote-escaped on the remote\n");
printf(" command line; empty values and values with control characters\n"); printf(" command line; empty values and values with control characters\n");
+5 -4
View File
@@ -257,10 +257,11 @@ static const char* server_module_gate(const Config* config, void* context) {
standalone/SSH server has a single operator-authorized root and keeps standalone/SSH server has a single operator-authorized root and keeps
honoring these. */ honoring these. */
if (!module->client_owner) { if (!module->client_owner) {
/* Ownership: refuse the whole transfer up front (a clear failure). Evaluated /* Ownership: refuse the whole transfer up front (a clear failure).
against the effective copy (so an operator --no-super has already Evaluated against the ORIGINAL config so an explicit --super is refused
neutralized an explicit --super), exactly as before. */ even when an operator --no-super veto already forced the effective copy
if (identity_ownership_requested(&effective)) { to OFF (the veto must not silently convert a refusal into an accept). */
if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities " "daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing", "(no `client owner = yes` opt-in); refusing",
+42 -27
View File
@@ -2,6 +2,7 @@
#include "utils.h" #include "utils.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h>
#include <openssl/evp.h> #include <openssl/evp.h>
#include <stdarg.h> #include <stdarg.h>
#include <stdint.h> #include <stdint.h>
@@ -9,6 +10,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h>
/* One store entry: a username and its password's SHA-256 hex digest. The /* One store entry: a username and its password's SHA-256 hex digest. The
* plaintext password never appears here (and never on the daemon host). */ * plaintext password never appears here (and never on the daemon host). */
@@ -36,21 +38,44 @@ static bool is_comment_char(char c) {
return c == '#' || c == ';'; return c == '#' || c == ';';
} }
/* A --password-file / --early-input carries plaintext or credential material /* Open a --password-file / --early-input after verifying the EXACT inode we
* and must not be accessible to group or other, mirroring the TLS private-key * will read: it must be owned by the effective user and grant no group/other
* check in transport_tls.c. Reject any group/other permission bit (including * permission bit (mode 0600), mirroring the TLS private-key check. We open by
* execute) with a clear error. A stat failure is left for the caller's fopen * path and then fstat the resulting fd (rather than stat()ing the path first
* to report, so a missing file keeps its existing "cannot open" message. */ * and reopening it), so the permission decision is made on the same inode that
static bool secret_file_is_private(const char* path, char* err, size_t err_size) { * is read and cannot be raced by swapping the path between check and open.
struct stat st; * The path may be a process-substitution pipe (`<(...)` -> /dev/fd/N), so
if (stat(path, &st) != 0) * regular files and FIFOs are accepted when the ownership/mode checks pass.
return true; *
if (!S_ISREG(st.st_mode) || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) { * Returns a FILE* the caller must fclose, or NULL with `err` filled. */
set_error(err, err_size, static FILE* secret_file_open(const char* path, char* err, size_t err_size) {
"refusing to read secret file '%s': permissions must be owner-only (0600)", path); int fd = open(path, O_RDONLY | O_CLOEXEC);
return false; if (fd < 0) {
set_error(err, err_size, "cannot open secret file '%s': %s", path, strerror(errno));
return NULL;
} }
return true; struct stat st;
if (fstat(fd, &st) != 0) {
set_error(err, err_size, "cannot stat secret file '%s': %s", path, strerror(errno));
close(fd);
return NULL;
}
bool is_readable_kind = S_ISREG(st.st_mode) || S_ISFIFO(st.st_mode);
if (!is_readable_kind || st.st_uid != geteuid() || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) {
set_error(err, err_size,
"refusing to read secret file '%s': it must be owned by the current user and "
"owner-only (0600), not accessible to group/other",
path);
close(fd);
return NULL;
}
FILE* fp = fdopen(fd, "r");
if (!fp) {
set_error(err, err_size, "cannot read secret file '%s': %s", path, strerror(errno));
close(fd);
return NULL;
}
return fp;
} }
/* Trim leading/trailing ASCII space and tab in place; returns the new start. */ /* Trim leading/trailing ASCII space and tab in place; returns the new start. */
@@ -142,14 +167,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
if (!path) if (!path)
return store; return store;
if (!secret_file_is_private(path, err, err_size)) { FILE* fp = secret_file_open(path, err, err_size);
credentials_free(store);
return NULL;
}
FILE* fp = fopen(path, "r");
if (!fp) { if (!fp) {
set_error(err, err_size, "cannot open credential file '%s': %s", path, strerror(errno));
credentials_free(store); credentials_free(store);
return NULL; return NULL;
} }
@@ -328,13 +347,9 @@ int credentials_read_secret_file(const char* path, char** user_out, char** passw
set_error(err, err_size, "no --password-file path"); set_error(err, err_size, "no --password-file path");
return -1; return -1;
} }
if (!secret_file_is_private(path, err, err_size)) FILE* fp = secret_file_open(path, err, err_size);
if (!fp)
return -1; return -1;
FILE* fp = fopen(path, "r");
if (!fp) {
set_error(err, err_size, "cannot open password file '%s': %s", path, strerror(errno));
return -1;
}
int line_no = 0; int line_no = 0;
char line[CREDENTIAL_MAX_LINE + 2]; char line[CREDENTIAL_MAX_LINE + 2];
+12 -6
View File
@@ -930,9 +930,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0; int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) { if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size); prealloc_rc = preallocate_fd(fd, data_size);
if (prealloc_rc != 0) if (prealloc_rc != 0) {
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, char* escaped_path = output_escape(path, log_get_8_bit_output());
strerror(prealloc_rc)); log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
escaped_path ? escaped_path : "<allocation failed>", strerror(prealloc_rc));
free(escaped_path);
}
} }
if (prealloc_rc == 0) { if (prealloc_rc == 0) {
/* posix_fallocate does not guarantee the fd's file offset is left /* posix_fallocate does not guarantee the fd's file offset is left
@@ -1037,9 +1040,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0; int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) { if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size); prealloc_rc = preallocate_fd(fd, data_size);
if (prealloc_rc != 0) if (prealloc_rc != 0) {
log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path, char* escaped_path = output_escape(path, log_get_8_bit_output());
strerror(prealloc_rc)); log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
escaped_path ? escaped_path : "<allocation failed>", strerror(prealloc_rc));
free(escaped_path);
}
} }
if (prealloc_rc == 0) { if (prealloc_rc == 0) {
lseek(fd, 0, SEEK_SET); lseek(fd, 0, SEEK_SET);
+7 -3
View File
@@ -430,10 +430,14 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b
return false; return false;
if (!protocol_send_n_data(session, data, size)) if (!protocol_send_n_data(session, data, size))
return false; return false;
if (redact) if (redact) {
log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>"); log_debug_message(LOG_DEBUG_PROTO, "Send String: <redacted>");
else } else {
log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", data); char* escaped_data = output_escape(data, log_get_8_bit_output());
log_debug_message(LOG_DEBUG_PROTO, "Send String: %s",
escaped_data ? escaped_data : "<allocation failed>");
free(escaped_data);
}
return true; return true;
} }