feat(p7-super): implement --super/--no-super safe-subset privilege gate (protocol 2.18.0)

Add the receiver-side --super / --no-super tri-state (Config->super_mode)
under the safe-subset + clear-refusal privilege model: FastSync never
elevates privileges, it only permits super-user attempts that are already
confined fd-relative below the authorized receive root.

- identity: privilege_super_permitted() gate (OFF=false, ON=true, AUTO follows
  geteuid()==0); identity_apply_ownership/_link become no-ops when not
  permitted; --super with no explicit identity policy implies raw numeric-id
  preservation (explicit usermap/groupmap/chown/numeric-ids still win); warn
  exactly once when --super is requested by a non-root receiver.
- file_receive: gate char/block device-node creation on the gate; FIFO/socket
  handling is unchanged.
- wire: trailing super_mode int after the --iconv spec, validated 0..2 in
  receive_privilege_options and validate_received_config; PROTOCOL_VERSION
  2.17.0 -> 2.18.0; version-sensitive tests and docs updated.
- CLI: --super/--no-super parsed explicitly before the generic --no-* branch
  (malformed --super=x rejected); usage text added.
- tests: config wire round-trip + invalid-value rejection, privilege-gate mode
  unit test, CLI parse test, integration transfer + root-gated ownership
  suppression/appliance tests.
- docs: RSYNC_COMPAT --super row + Wave E note, protocol mentions, README.
This commit is contained in:
2026-09-12 12:01:19 +02:00
parent f64d252faf
commit a785ec13c4
13 changed files with 366 additions and 26 deletions
+14
View File
@@ -848,6 +848,20 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
config->no_motd = true;
continue;
}
/* "--super" / "--no-super" are real rsync option names controlling the
* receiver's super-user activity policy (ownership, device nodes), not a
* Boolean pair for the generic --no-* negation branch: both map onto the
* Config->super_mode tri-state. Handle them explicitly (exact match only,
* so a malformed "--super=x" still falls through to the unknown-option
* error) before the generic negation branch would mis-reject "--no-super". */
if (strcmp(argv[i], "--super") == 0) {
config->super_mode = SUPER_MODE_ON;
continue;
}
if (strcmp(argv[i], "--no-super") == 0) {
config->super_mode = SUPER_MODE_OFF;
continue;
}
if (strncmp(argv[i], "--no-", strlen("--no-")) == 0) {
if (strcmp(argv[i], "--no-delta") == 0)
no_delta = true;
+8
View File
@@ -168,6 +168,14 @@ void print_usage(void) {
printf(" user.fastsync.stat xattr on each written file and\n");
printf(" re-apply it (fd-relative) on a privileged run; the\n");
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
printf(" --super Permit the receiver to attempt super-user activities\n");
printf(" (ownership application, char/block device-node\n");
printf(" creation) within the confined receive root. Never\n");
printf(" elevates privileges and never bypasses confinement;\n");
printf(" with no explicit identity policy, ownership follows\n");
printf(" raw numeric ids (as if --numeric-ids)\n");
printf(" --no-super Forbid those super-user activities even when the\n");
printf(" receiver is running as root\n");
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
printf(" --numeric-ids Do not map uid/gid by name: use the source numeric\n");
printf(" ids directly when applying ownership\n");
+28 -3
View File
@@ -169,6 +169,7 @@ static void config_set_defaults(Config* config) {
config->usermap_count = 0;
config->groupmap = NULL;
config->groupmap_count = 0;
config->super_mode = SUPER_MODE_AUTO;
config->delay_context = NULL;
config->preserve_atimes = false;
config->preserve_crtimes = false;
@@ -256,7 +257,10 @@ static bool validate_received_config(const Config* config) {
unsupported charset name so the run is refused up front instead of
every received file name failing mid-transfer. A NULL spec (iconv
disabled) is always accepted. */
(!config->iconv_spec || charset_spec_valid(config->iconv_spec));
(!config->iconv_spec || charset_spec_valid(config->iconv_spec)) &&
/* --super / --no-super: the received tri-state must be one of the
defined values (AUTO/ON/OFF); anything else is a malformed frame. */
config->super_mode >= SUPER_MODE_AUTO && config->super_mode <= SUPER_MODE_OFF;
}
Config* config_create(void) {
@@ -1185,6 +1189,25 @@ static bool receive_iconv_spec(int fd, Config* c) {
return true;
}
/* --super / --no-super privilege policy (P7 Wave E, protocol 2.18.0). One
* trailing int on the config frame, sent after the --iconv spec and before the
* STATUS_OK ack, so the receiver knows whether it may attempt super-user
* activities (ownership application, char/block device-node creation) that are
* already confined below the authorized receive root. The received value is
* validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by
* validate_received_config). */
static bool send_privilege_options(int fd, const Config* c) {
return send_int(fd, c->super_mode);
}
static bool receive_privilege_options(int fd, Config* c) {
int mode;
if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF)
return false;
c->super_mode = mode;
return true;
}
bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
@@ -1198,7 +1221,7 @@ bool config_send(int file_descriptor, const Config* config) {
!send_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config) ||
!send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) ||
!send_iconv_spec(file_descriptor, config))
!send_iconv_spec(file_descriptor, config) || !send_privilege_options(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -1240,7 +1263,9 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
!receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config) ||
!receive_daemon_module(file_descriptor, config) ||
!receive_daemon_auth(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config))
!receive_daemon_auth(file_descriptor, config) ||
!receive_iconv_spec(file_descriptor, config) ||
!receive_privilege_options(file_descriptor, config))
goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) {
+43 -2
View File
@@ -402,6 +402,22 @@ typedef struct Config {
IdentityMap* groupmap;
int groupmap_count;
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
* policy for super-user activities confined below the authorized receive
* root. SUPER_MODE_AUTO (default) preserves the pre-existing behavior: a
* privileged operation is only attempted when the receiver is ALREADY root
* (geteuid() == 0). SUPER_MODE_ON (--super) PERMITS the receiver to attempt
* those activities (ownership application, char/block device-node creation)
* even when it is not root -- the attempt is then confined exactly as before
* and simply fails/skips if the kernel refuses it. SUPER_MODE_OFF
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
* --super only permits an attempt that is already confined. Crosses the wire
* as a trailing int so the receiver can enforce the policy. See
* privilege_super_permitted() in identity.h. */
int super_mode;
// Receiver-side runtime staging registry for --delay-updates. Never sent
// over the wire and never set on the sender side.
DelayUpdatesContext* delay_context;
@@ -563,8 +579,24 @@ typedef struct Config {
* would desynchronize on the unknown frame, and the strict same-version
* handshake (config_receive rejects a mismatched version before parsing
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
* reaching that state. */
#define PROTOCOL_VERSION "2.17.0"
* reaching that state.
*
* Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
*
* WHY the bump, grounded in the wire: this wave adds the receiver-side
* --super / --no-super privilege policy. The config-frame layout gains a new
* trailing int (Config->super_mode) sent immediately AFTER the --iconv
* CONVERT_SPEC block (send_privilege_options / receive_privilege_options in
* config.c), so the receiver knows whether it may attempt super-user
* activities (ownership application, char/block device-node creation) that are
* already confined below the authorized receive root. Any config-frame layout
* change must bump the protocol version: a peer that does not parse the new
* trailing bytes would desynchronize on the frame boundary, and the strict
* same-version handshake (config_receive rejects a mismatched version before
* parsing anything else) is what keeps a 2.18 client and a 2.17 server from
* ever reaching that state. --super never elevates privileges; it only
* permits a confined attempt, so no new capability is granted. */
#define PROTOCOL_VERSION "2.18.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
@@ -577,6 +609,15 @@ typedef struct Config {
#define IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128
/* --super / --no-super tri-state (Config->super_mode). AUTO preserves the
* pre-existing behavior (a privileged attempt only when already root); ON
* permits confined privileged attempts; OFF forbids them even as root. See the
* Config->super_mode comment above and privilege_super_permitted() in
* identity.h. */
#define SUPER_MODE_AUTO 0
#define SUPER_MODE_ON 1
#define SUPER_MODE_OFF 2
Config* config_create(void);
void config_delete(Config* config);
bool config_send(int file_descriptor, const Config* config);
+12
View File
@@ -18,6 +18,7 @@
#include "delay_updates.h"
#include "delta.h"
#include "file.h"
#include "identity.h"
#include "log.h"
#include "metadata.h"
#include "protocol.h"
@@ -355,6 +356,17 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
if (is_char || is_blk) {
if (!config || !config->preserve_devices)
return FILE_SAVE_SKIPPED;
/* --super / --no-super (P7 Wave E): char/block device-node creation is a
super-user activity. --no-super forbids it even for a root receiver; the
default AUTO only attempts it when already root. Pure FIFO creation is
unprivileged and deliberately NOT gated here. */
if (!privilege_super_permitted()) {
log_message(LOG_LEVEL_WARNING,
"skipping %s: super-user device-node creation is not permitted "
"(--no-super, or the receiver is not privileged)",
file->path);
return FILE_SAVE_SKIPPED;
}
} else if (is_fifo) {
if (!config || !config->preserve_specials)
return FILE_SAVE_SKIPPED;
+48 -9
View File
@@ -27,6 +27,10 @@ typedef struct {
int usermap_count;
IdentityMap* groupmap;
int groupmap_count;
/* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted
* per connection so privilege_super_permitted() can gate super-user
* activities without a Config argument. */
int super_mode;
bool set;
} IdentityActive;
@@ -44,6 +48,7 @@ static void identity_active_reset(void) {
g_identity.chown_uid = 0;
g_identity.chown_gid_set = false;
g_identity.chown_gid = 0;
g_identity.super_mode = SUPER_MODE_AUTO;
g_identity.set = false;
}
@@ -76,6 +81,7 @@ void identity_set_active(const Config* config) {
g_identity.groupmap_count = config->groupmap_count;
}
}
g_identity.super_mode = config->super_mode;
g_identity.set = true;
/* A root receiver would honor any client-supplied ownership request (a
--usermap/--groupmap/--chown, or raw ids under --numeric-ids). Surface
@@ -86,6 +92,37 @@ void identity_set_active(const Config* config) {
"identity mapping active and running as root: client-supplied "
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
"run the daemon as an unprivileged user unless intended");
/* --super explicitly requests super-user activities, but FastSync never
elevates privileges: when the receiver is not already root those confined
attempts cannot succeed. Warn exactly once at activation time (never
abort) so the operator knows the flag is inert on this host. */
if (g_identity.super_mode == SUPER_MODE_ON && geteuid() != 0)
log_message(LOG_LEVEL_WARNING,
"--super requested but the receiver is not privileged; super-user "
"activities (ownership, device nodes) cannot be performed and will "
"be skipped");
}
bool privilege_super_permitted(void) {
if (g_identity.super_mode == SUPER_MODE_OFF)
return false;
if (g_identity.super_mode == SUPER_MODE_ON)
return true;
/* SUPER_MODE_AUTO (the default): only attempt super-user activities when the
receiver is already root. */
return geteuid() == 0;
}
/* --super with NO explicit identity policy implies raw numeric-id preservation,
* exactly as if --numeric-ids had been given. An explicit usermap/groupmap/
* --chown/--numeric-ids always wins: identity_resolve_targets() checks those
* before the numeric fallback, and this predicate is false whenever any of them
* is present. In AUTO (the default) no implication is made, preserving the
* opt-in-only behavior. */
static bool identity_super_implies_numeric(void) {
return g_identity.super_mode == SUPER_MODE_ON && !g_identity.numeric_ids &&
!g_identity.chown_uid_set && !g_identity.chown_gid_set && g_identity.usermap_count == 0 &&
g_identity.groupmap_count == 0;
}
bool identity_active_enabled(void) {
@@ -93,10 +130,11 @@ bool identity_active_enabled(void) {
which runs only when metadata is present (a -M/--preserve transfer). A
standalone --numeric-ids (no ownership-affecting flag) carries no
metadata, never reaches identity_apply_ownership, and therefore correctly
stays inert; combined with -M it activates raw-id application. */
return g_identity.set &&
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0);
stays inert; combined with -M it activates raw-id application. --super
with no explicit identity policy acts like --numeric-ids here. */
return g_identity.set && (g_identity.numeric_ids || g_identity.chown_uid_set ||
g_identity.chown_gid_set || g_identity.usermap_count > 0 ||
g_identity.groupmap_count > 0 || identity_super_implies_numeric());
}
bool identity_wire_valid(const Config* config) {
@@ -388,7 +426,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
} else if (g_identity.chown_uid_set) {
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
set_uid = true;
} else if (g_identity.numeric_ids) {
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
uid = (uid_t)source_uid;
set_uid = true;
} else {
@@ -412,7 +450,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
} else if (g_identity.chown_gid_set) {
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
set_gid = true;
} else if (g_identity.numeric_ids) {
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
gid = (gid_t)source_gid;
set_gid = true;
} else {
@@ -458,8 +496,9 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
/* Ownership application is OFF unless the client requested an identity flag.
* This is the controlled gate: a default (or plain -M) transfer never changes
* ownership, byte-for-byte preserving FastSync's existing behavior. */
if (!identity_active_enabled() || fd < 0)
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
* additionally forbids it even when the receiver is root. */
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
return;
struct stat st;
if (fstat(fd, &st) != 0)
@@ -474,7 +513,7 @@ void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
int32_t source_gid) {
if (!identity_active_enabled() || parent_fd < 0 || !leaf)
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
return;
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
+10
View File
@@ -65,4 +65,14 @@ void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t sour
/* Receiver-side wire validation of the resolved identity fields. */
bool identity_wire_valid(const Config* config);
/* P7 Wave E receiver-side permission gate for super-user activities (ownership
* application and char/block device-node creation). Returns false when the
* active config is --no-super (SUPER_MODE_OFF); true when it is --super
* (SUPER_MODE_ON); and otherwise (SUPER_MODE_AUTO, the default, or before
* identity_set_active() has been called) only when the receiver is ALREADY root
* (geteuid() == 0). This NEVER elevates privileges: it only reports whether an
* attempt that is already confined below the authorized receive root may be
* made. */
bool privilege_super_permitted(void);
#endif