feat(p5-remote-option): --remote-option (probe 2.14.0), --trust-sender
This commit is contained in:
@@ -122,6 +122,8 @@ static void config_set_defaults(Config* config) {
|
||||
config->rsync_path = NULL;
|
||||
config->old_args = false;
|
||||
config->temp_dir = NULL;
|
||||
config->remote_options = NULL;
|
||||
config->remote_option_count = 0;
|
||||
config->basis_dirs = NULL;
|
||||
config->basis_count = 0;
|
||||
config->partial_dir = NULL;
|
||||
@@ -161,6 +163,7 @@ static void config_set_defaults(Config* config) {
|
||||
config->open_noatime = false;
|
||||
config->use_xattrs = false;
|
||||
config->fake_super = false;
|
||||
config->trust_sender = false;
|
||||
}
|
||||
|
||||
static bool valid_wire_bool(int value) {
|
||||
@@ -395,6 +398,13 @@ void config_delete(Config* config) {
|
||||
free(config->rsh_command);
|
||||
free(config->rsync_path);
|
||||
free(config->temp_dir);
|
||||
if (config->remote_options) {
|
||||
for (int i = 0; i < config->remote_option_count; i++)
|
||||
free(config->remote_options[i]);
|
||||
free(config->remote_options);
|
||||
}
|
||||
config->remote_options = NULL;
|
||||
config->remote_option_count = 0;
|
||||
for (int i = 0; i < config->basis_count; i++) {
|
||||
free(config->basis_dirs[i].path);
|
||||
config->basis_dirs[i].path = NULL;
|
||||
|
||||
+44
-1
@@ -230,6 +230,14 @@ typedef struct Config {
|
||||
char* rsync_path;
|
||||
bool old_args;
|
||||
char* temp_dir;
|
||||
/* --remote-option=OPT (Phase 5, long form only): one or more extra command-line
|
||||
* options to append to the REMOTE server invocation over SSH. CLIENT-ONLY:
|
||||
* they are composed into the remote command line by ssh_build_remote_command()
|
||||
* (each valid word is shell-escaped with the same quoting boundary as the
|
||||
* server path), and are NEVER serialized into the binary config frame. They
|
||||
* do NOT cross the wire and are never parsed on the receiver process. */
|
||||
char** remote_options;
|
||||
int remote_option_count;
|
||||
/* Alternate basis directories, ordered by command-line appearance. Each
|
||||
* entry's type selects compare/copy/link behavior on an exact match. These
|
||||
* cross the wire so the receiver can consult them; they are interpreted
|
||||
@@ -350,9 +358,44 @@ typedef struct Config {
|
||||
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
|
||||
* so a later privileged restore could re-apply them. Crosses the wire. */
|
||||
bool fake_super;
|
||||
|
||||
// Phase 5: --trust-sender
|
||||
/* Long-form-only, receiver-local policy. rsync's --trust-sender tells the
|
||||
* receiving side to trust that the sender already produced a sane file list,
|
||||
* relaxing the receiver's own up-front re-validation of every incoming path.
|
||||
* In FastSync the receiver normally double-checks each transmitted file-list
|
||||
* entry (empty / ".." path-traversal rejection) and refuses to materialize a
|
||||
* symlink whose target could escape the receive root. When trust_sender is
|
||||
* set, those redundant list-level re-checks are SKIPPED: the receiving side
|
||||
* trusts the sender's list instead of re-validating it (fewer checks, faster,
|
||||
* potentially unsafe, matching rsync). It is a LOCAL receiver policy and is
|
||||
* NEVER serialized into the config frame (it exists only on the process that
|
||||
* actually receives the file list). Even under trust_sender the low-level
|
||||
* fd-relative confinement primitives (file_open_secure_parent, the O_NOFOLLOW
|
||||
* parent walk, leaf/destination confinement) are deliberately KEPT as a hard
|
||||
* floor, so a hostile sender still cannot write or link outside the
|
||||
* authorized root (see the phase-5 notes in RSYNC_COMPAT.md). Off by
|
||||
* default; only relaxes validation when explicitly requested. */
|
||||
bool trust_sender;
|
||||
} Config;
|
||||
|
||||
#define PROTOCOL_VERSION "2.13.0"
|
||||
/* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: the binary config-frame layout is
|
||||
* UNCHANGED by this wave (neither --remote-option nor --trust-sender adds a
|
||||
* serialized field; see the field comments above). --remote-option is
|
||||
* forwarded to the remote server over the SSH remote-command line
|
||||
* (ssh_build_remote_command) and --trust-sender is a purely local receiver
|
||||
* policy, so there is no new frame byte to negotiate. The bump is still the
|
||||
* correct release marker for Phase 5 because the client-to-server INVOCATION
|
||||
* surface changed: a client that composes remote-options expects a server that
|
||||
* knows how to honor them, and the only safe way to express "this feature set
|
||||
* is one coordinated release" is the strict same-version handshake FastSync
|
||||
* already performs for every release. A 2.14 client against a 2.13 server
|
||||
* fails the version check cleanly up front (rather than the remote server
|
||||
* rejecting an unfamiliar forwarded argv at a confusing later point), which is
|
||||
* exactly what the lockstep convention of this project requires. */
|
||||
#define PROTOCOL_VERSION "2.14.0"
|
||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||
#define MAX_BASIS_DIRS 64
|
||||
|
||||
+27
-1
@@ -363,6 +363,23 @@ bool file_get_keep_dirlinks(void) {
|
||||
return file_keep_dirlinks;
|
||||
}
|
||||
|
||||
/* --trust-sender (Phase 5) receiver process-wide policy: when set, the receiver
|
||||
* trusts the sender's file list and skips its own redundant up-front re-
|
||||
* validation (empty/".." path rejection, escaping-symlink-target containment).
|
||||
* Kept OFF by default; the server's per-connection handler sets it once from the
|
||||
* received config before any receiver/writer threads start (each connection is
|
||||
* its own forked process, so this per-process value never bleeds across
|
||||
* connections). */
|
||||
static bool file_trust_sender = false;
|
||||
|
||||
void file_set_trust_sender(bool enable) {
|
||||
file_trust_sender = enable;
|
||||
}
|
||||
|
||||
bool file_get_trust_sender(void) {
|
||||
return file_trust_sender;
|
||||
}
|
||||
|
||||
/* True when `target` is a lexical symlink target that can never escape the
|
||||
* receive root once created beneath it: relative (not absolute) and containing
|
||||
* no ".." path component. Used by --munge-links' sender-side containment: an
|
||||
@@ -425,7 +442,16 @@ char* file_symlink_munge(const char* target) {
|
||||
* false) so a malicious sender can never materialize a symlink that points
|
||||
* outside the receive root. */
|
||||
bool file_symlink_at_secure(const char* path, const char* target) {
|
||||
if (!path || !target || has_path_traversal(path) || !file_symlink_target_contained(target))
|
||||
/* The link itself (`path`) is always kept below the authorized root. The
|
||||
TARGET may point anywhere: normally only a contained (relative, ".."-free)
|
||||
target is permitted so a malicious sender can never plant a symlink that
|
||||
later dereferences outside the root. Under --trust-sender that target
|
||||
containment check is relaxed (the receiver trusts the sender and copies the
|
||||
link verbatim, matching rsync -l), but path/leaf confinement is never
|
||||
disabled, so the link still cannot be placed outside the tree. */
|
||||
if (!path || !target || has_path_traversal(path))
|
||||
return false;
|
||||
if (!file_trust_sender && !file_symlink_target_contained(target))
|
||||
return false;
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(path, &leaf, true);
|
||||
|
||||
@@ -54,6 +54,15 @@ bool file_symlink_at_secure(const char* path, const char* target);
|
||||
void file_set_keep_dirlinks(bool enable);
|
||||
bool file_get_keep_dirlinks(void);
|
||||
|
||||
/* --trust-sender receiver process-wide policy (Phase 5). When set, the
|
||||
* receiver trusts that the sender already produced a clean file list and skips
|
||||
* its own redundant up-front re-validation of incoming paths (the empty/".."
|
||||
* rejection and the escaping-symlink-target containment). The low-level
|
||||
* fd-relative confinement primitives below are deliberately NOT disabled by
|
||||
* this flag, so a hostile sender still cannot escape the authorized root. */
|
||||
void file_set_trust_sender(bool enable);
|
||||
bool file_get_trust_sender(void);
|
||||
|
||||
/* A configured fd without a canonical identity deliberately rejects paths. */
|
||||
bool file_set_authorized_root(int fd, const char* canonical_path);
|
||||
|
||||
|
||||
@@ -595,8 +595,12 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
could escape the receive root (absolute, or relative-with-"..") is never
|
||||
materialized. It is contained (the entry is skipped) rather than failing
|
||||
the whole transfer, so a hostile sender can inject a broken symlink but
|
||||
can never redirect it outside the root. */
|
||||
if (ok && !file_symlink_target_contained(target))
|
||||
can never redirect it outside the root. --trust-sender deliberately
|
||||
relaxes this receiver-side re-validation: a trusted sender's escaping
|
||||
symlink target is copied verbatim (rsync -l parity). The low-level
|
||||
leaf/destination confinement in file_symlink_at_secure still ensures the
|
||||
link itself is placed inside the authorized root. */
|
||||
if (ok && !file_get_trust_sender() && !file_symlink_target_contained(target))
|
||||
ok = false;
|
||||
if (!ok) {
|
||||
/* Skip the escaping/empty target (contained) rather than abort. */
|
||||
@@ -2076,7 +2080,7 @@ File* file_receive(const Config* config, int file_descriptor) {
|
||||
char* path = receive_str(file_descriptor);
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
if (path[0] == '\0' || has_path_traversal(path)) {
|
||||
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received file path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
@@ -2136,7 +2140,7 @@ File* file_receive_directory(int file_descriptor) {
|
||||
char* path = receive_str(file_descriptor);
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
if (path[0] == '\0' || has_path_traversal(path)) {
|
||||
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received directory path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
@@ -2163,7 +2167,7 @@ File* file_receive_hardlink(int file_descriptor) {
|
||||
char* path = receive_str(file_descriptor);
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
if (path[0] == '\0' || has_path_traversal(path)) {
|
||||
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received hard-link path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
@@ -2182,7 +2186,7 @@ File* file_receive_hardlink(int file_descriptor) {
|
||||
free(path);
|
||||
return NULL;
|
||||
}
|
||||
if (target[0] == '\0' || has_path_traversal(target)) {
|
||||
if (target[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(target))) {
|
||||
char* escaped = output_escape(target, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid hard-link target path: %s",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
@@ -2213,7 +2217,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) {
|
||||
char* path = receive_str(file_descriptor);
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
if (path[0] == '\0' || has_path_traversal(path)) {
|
||||
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received symlink path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
@@ -2268,7 +2272,7 @@ File* file_receive_special(int file_descriptor) {
|
||||
char* path = receive_str(file_descriptor);
|
||||
if (path == NULL)
|
||||
return NULL;
|
||||
if (path[0] == '\0' || has_path_traversal(path)) {
|
||||
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
|
||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received special path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
|
||||
+84
-27
@@ -75,52 +75,108 @@ static int parse_remote_dest(const char* dest, RemoteDest* r) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char* ssh_build_remote_command(const char* server_path, bool old_args) {
|
||||
char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
|
||||
int remote_option_count) {
|
||||
const char* path = server_path ? server_path : "fastsync-server";
|
||||
const char* suffix = " --stdio";
|
||||
|
||||
/* Each --remote-option=OPT is appended after " --stdio" as one shell word,
|
||||
escaped with the SAME single-quote boundary used for the server path. This
|
||||
stays safe even in --old-args mode (which leaves the server path unquoted):
|
||||
remote options are always single-quoted individually, so a value containing
|
||||
shell metacharacters (; & | ` $ ()) can never break out of the quoting to
|
||||
inject an unrelated remote command. Values are already validated at CLI
|
||||
parse time (non-empty, no control characters); this layer only adds the
|
||||
escaping boundary. */
|
||||
size_t path_len = strlen(path);
|
||||
size_t suffix_len = strlen(suffix);
|
||||
|
||||
/* The base command (server path, quoted unless --old-args, then " --stdio"). */
|
||||
size_t command_len;
|
||||
if (old_args) {
|
||||
if (path_len > SIZE_MAX - suffix_len - 1)
|
||||
return NULL;
|
||||
char* command = malloc(path_len + suffix_len + 1);
|
||||
if (!command)
|
||||
command_len = path_len + suffix_len + 1;
|
||||
} else {
|
||||
size_t quote_count = 0;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '\'')
|
||||
quote_count++;
|
||||
if (path_len > SIZE_MAX - suffix_len - 4 ||
|
||||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
|
||||
return NULL;
|
||||
memcpy(command, path, path_len);
|
||||
memcpy(command + path_len, suffix, suffix_len + 1);
|
||||
return command;
|
||||
command_len = path_len + quote_count * 4 + suffix_len + 4;
|
||||
}
|
||||
|
||||
/* Quote the executable as one remote-shell word. This is the default safety boundary. */
|
||||
size_t quote_count = 0;
|
||||
for (const char* p = path; *p; p++)
|
||||
if (*p == '\'')
|
||||
quote_count++;
|
||||
if (path_len > SIZE_MAX - suffix_len - 4 ||
|
||||
quote_count > (SIZE_MAX - path_len - suffix_len - 4) / 4)
|
||||
return NULL;
|
||||
size_t command_len = path_len + quote_count * 4 + suffix_len + 4;
|
||||
char* command = malloc(command_len + 1);
|
||||
/* Add each remote option, escaped as one single-quoted word:
|
||||
" '<body>'", i.e. 1 leading space + 1 open quote + body (len + 3 per
|
||||
embedded single quote) + 1 close quote = len + q*3 + 3 bytes.
|
||||
Defense-in-depth against a non-conforming caller: never forward an empty
|
||||
or control-character value, independent of the CLI validation. */
|
||||
for (int i = 0; i < remote_option_count; i++) {
|
||||
const char* opt = remote_options[i];
|
||||
if (!opt || opt[0] == '\0')
|
||||
return NULL;
|
||||
size_t len = 0, q = 0;
|
||||
for (const char* p = opt; *p; p++) {
|
||||
/* Defense-in-depth: never forward a control character (newline/CR/etc.)
|
||||
that could break the single-quoted shell word regardless of the remote
|
||||
shell, independent of the CLI validation. */
|
||||
if ((unsigned char)*p < 0x20 || (unsigned char)*p == 0x7f)
|
||||
return NULL;
|
||||
if (*p == '\'')
|
||||
q++;
|
||||
len++;
|
||||
}
|
||||
if (len > SIZE_MAX - q * 3 || len + q * 3 + 3 > SIZE_MAX - command_len)
|
||||
return NULL;
|
||||
command_len += len + q * 3 + 3;
|
||||
}
|
||||
command_len += 1; /* NUL */
|
||||
|
||||
char* command = malloc(command_len);
|
||||
if (!command)
|
||||
return NULL;
|
||||
char* out = command;
|
||||
*out++ = '\'';
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '\'') {
|
||||
memcpy(out, "'\\''", 4);
|
||||
out += 4;
|
||||
} else {
|
||||
*out++ = *p;
|
||||
if (old_args) {
|
||||
memcpy(out, path, path_len);
|
||||
out += path_len;
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
out += suffix_len;
|
||||
} else {
|
||||
*out++ = '\'';
|
||||
for (const char* p = path; *p; p++) {
|
||||
if (*p == '\'') {
|
||||
memcpy(out, "'\\''", 4);
|
||||
out += 4;
|
||||
} else {
|
||||
*out++ = *p;
|
||||
}
|
||||
}
|
||||
*out++ = '\'';
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
out += suffix_len;
|
||||
}
|
||||
*out++ = '\'';
|
||||
memcpy(out, suffix, suffix_len + 1);
|
||||
for (int i = 0; i < remote_option_count; i++) {
|
||||
const char* opt = remote_options[i];
|
||||
*out++ = ' ';
|
||||
*out++ = '\'';
|
||||
for (const char* p = opt; *p; p++) {
|
||||
if (*p == '\'') {
|
||||
memcpy(out, "'\\''", 4);
|
||||
out += 4;
|
||||
} else {
|
||||
*out++ = *p;
|
||||
}
|
||||
}
|
||||
*out++ = '\'';
|
||||
}
|
||||
*out = '\0';
|
||||
return command;
|
||||
}
|
||||
|
||||
Client* client_connect_ssh(const char* destination, int port, const char* server_path,
|
||||
bool old_args) {
|
||||
bool old_args, char* const* remote_options, int remote_option_count) {
|
||||
RemoteDest r;
|
||||
if (parse_remote_dest(destination, &r) != 0) {
|
||||
char* escaped = output_escape(destination, false);
|
||||
@@ -190,7 +246,8 @@ Client* client_connect_ssh(const char* destination, int port, const char* server
|
||||
char* ssh_argv[16];
|
||||
int ac = 0;
|
||||
char port_str[16];
|
||||
char* remote_command = ssh_build_remote_command(server_path, old_args);
|
||||
char* remote_command =
|
||||
ssh_build_remote_command(server_path, old_args, remote_options, remote_option_count);
|
||||
if (!remote_command)
|
||||
ssh_child_setup_failed(exec_pipe[1]);
|
||||
ssh_argv[ac++] = "ssh";
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
#include "transport_tcp.h"
|
||||
|
||||
Client* client_connect_ssh(const char* destination, int port, const char* server_path,
|
||||
bool old_args);
|
||||
char* ssh_build_remote_command(const char* server_path, bool old_args);
|
||||
bool old_args, char* const* remote_options, int remote_option_count);
|
||||
char* ssh_build_remote_command(const char* server_path, bool old_args, char* const* remote_options,
|
||||
int remote_option_count);
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user