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:
@@ -200,8 +200,9 @@ class TestDeviceSpecial:
|
||||
assert not os.path.lexists(os.path.join(received, "chardev")), (
|
||||
"a receiver without CAP_MKNOD must skip the device node, not create it"
|
||||
)
|
||||
assert "cannot create device node" in (out + err), (
|
||||
f"receiver did not log the documented CAP_MKNOD skip: out={out!r} err={err!r}"
|
||||
assert ("cannot create device node" in (out + err)
|
||||
or "device-node creation is not permitted" in (out + err)), (
|
||||
f"receiver did not log the documented device skip: out={out!r} err={err!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="requires root to create device nodes")
|
||||
@@ -4119,6 +4120,68 @@ class TestIdentityMapping:
|
||||
f"--chown not applied: uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
|
||||
class TestSuperPrivilege:
|
||||
"""P7 Wave E: --super / --no-super control the receiver's already-confined
|
||||
super-user activities (ownership application, char/block device nodes).
|
||||
FastSync never elevates, so on an unprivileged receiver --super only
|
||||
permits a confined attempt (which then skips); --no-super forbids the
|
||||
activity even for root."""
|
||||
|
||||
def _seed(self, tag):
|
||||
source = os.path.join(TEST_DATA_DIR, f"super_{tag}_source")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"super_{tag}_dest")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
with open(os.path.join(source, "f.txt"), "wb") as f:
|
||||
f.write(b"super privilege\n")
|
||||
return source, dest
|
||||
|
||||
def test_super_and_no_super_transfer_successfully(self, shared_server):
|
||||
"""Both flags parse and the transfer completes normally regardless of
|
||||
the receiver's privilege level."""
|
||||
for flag in ("--super", "--no-super"):
|
||||
source, dest = self._seed(flag.strip("-"))
|
||||
result, _ = run_client(source, dest, flags=[flag], port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"{flag} exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
with open(os.path.join(received, "f.txt"), "rb") as f:
|
||||
assert f.read() == b"super privilege\n"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_no_super_suppresses_ownership_as_root(self, shared_server):
|
||||
"""As root the default gate would apply a raw numeric id; --no-super
|
||||
must suppress that ownership application entirely."""
|
||||
source, dest = self._seed("nosuper")
|
||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--preserve", "--numeric-ids", "--no-super"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) != (12345, 12346), \
|
||||
f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_super_applies_ownership_as_root(self, shared_server):
|
||||
"""Control/proof the flag is not inert for root: --super with no explicit
|
||||
identity policy treats ownership as raw numeric ids (as --numeric-ids),
|
||||
applying the very ownership --no-super suppressed."""
|
||||
source, dest = self._seed("super")
|
||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--preserve", "--super"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
||||
f"--super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
||||
class TestProtocol:
|
||||
@pytest.mark.ci
|
||||
def test_protocol_current_version_accepted(self, shared_server):
|
||||
"""--protocol=2.17.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
"""--protocol=2.18.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
transfer completes normally."""
|
||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.17.0"],
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.18.0"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||
@@ -118,7 +118,7 @@ class TestProtocol:
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
for bad in ("2.15.0", "2.16.0", "216", "31"):
|
||||
for bad in ("2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||
|
||||
+42
-3
@@ -223,7 +223,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
||||
"--dest-dir", "/dst", "--protocol=2.17.0"};
|
||||
"--dest-dir", "/dst", "--protocol=2.18.0"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||
@@ -233,7 +233,7 @@ static void test_parse_args_protocol_accept_current() {
|
||||
cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||
"/dst", "--protocol", "2.17.0"};
|
||||
"/dst", "--protocol", "2.18.0"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
||||
@@ -243,7 +243,8 @@ static void test_parse_args_protocol_accept_current() {
|
||||
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
||||
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
||||
static void test_parse_args_protocol_rejects_other_versions() {
|
||||
static const char* const bad_versions[] = {"2.16", "2.15.0", "2.16.0", "216", "31", "abc", ""};
|
||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
|
||||
"216", "31", "abc", ""};
|
||||
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
@@ -331,6 +332,43 @@ static void test_parse_args_fake_super() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* P7 Wave E: --super / --no-super set the receiver-side privilege tri-state
|
||||
* (they take no argument). The default is AUTO, the last of either flag wins,
|
||||
* and a malformed inline value ("--super=x") is rejected rather than silently
|
||||
* treated as --super. */
|
||||
static void test_parse_args_super() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_AUTO);
|
||||
char* argv_on[] = {"fastsync", "--super", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_on, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_ON);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* argv_off[] = {"fastsync", "--no-super", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_off, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_OFF);
|
||||
config_delete(cfg);
|
||||
|
||||
/* Tri-state, not a boolean pair: the last flag wins. */
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* argv_both[] = {"fastsync", "--super", "--no-super", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv_both, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->super_mode, SUPER_MODE_OFF);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A malformed inline value is a hard unknown-option error. */
|
||||
cfg = config_create();
|
||||
positional_count = 0;
|
||||
char* argv_bad[] = {"fastsync", "--super=x", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_bad, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Test parse_args with valid SSH port (long form; -p is now rsync --perms) */
|
||||
static void test_parse_args_valid_port() {
|
||||
Config* cfg = config_create();
|
||||
@@ -3081,6 +3119,7 @@ void test_client_cli() {
|
||||
test_parse_args_missing_argument_diagnostic();
|
||||
test_parse_args_xattrs_acls();
|
||||
test_parse_args_fake_super();
|
||||
test_parse_args_super();
|
||||
test_parse_args_partial_progress();
|
||||
test_parse_args_itemize_changes();
|
||||
test_parse_args_list_only();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "test_config.h"
|
||||
#include "config.h"
|
||||
#include "identity.h"
|
||||
#include "multiprocessing.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
@@ -1669,6 +1670,89 @@ static void test_config_receive_rejects_invalid_iconv_spec() {
|
||||
}
|
||||
}
|
||||
|
||||
/* P7 Wave E: the --super / --no-super tri-state crosses the config wire
|
||||
unchanged (AUTO/ON/OFF), so the receiver can enforce the privilege policy. */
|
||||
static void test_config_super_mode_wire_roundtrip() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
int modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF};
|
||||
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv = config_receive(p[0]);
|
||||
bool ok = recv != NULL && recv->super_mode == modes[i];
|
||||
config_delete(recv);
|
||||
close(p[0]);
|
||||
_exit(ok ? 0 : 1);
|
||||
} else {
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
send_cfg->send_directory = str_dup("/src");
|
||||
send_cfg->receive_root_directory = str_dup("/dst");
|
||||
send_cfg->super_mode = modes[i];
|
||||
bool sent = config_send(p[1], send_cfg);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
close(p[1]);
|
||||
config_delete(send_cfg);
|
||||
EXPECT_TRUE(sent);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* An out-of-range super_mode value on the wire must be refused on receive
|
||||
(never silently clamped or accepted). */
|
||||
static void test_config_receive_rejects_invalid_super_mode() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->super_mode = 99;
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
|
||||
/* A negative value is equally invalid. */
|
||||
c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->super_mode = -1;
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* P7 Wave E: privilege_super_permitted() maps the super_mode tri-state. OFF
|
||||
forbids super-user activities even for root; ON permits them; AUTO follows
|
||||
the effective uid. */
|
||||
static void test_privilege_super_permitted_modes() {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->super_mode = SUPER_MODE_OFF;
|
||||
identity_set_active(c);
|
||||
EXPECT_FALSE(privilege_super_permitted());
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
identity_set_active(c);
|
||||
EXPECT_TRUE(privilege_super_permitted());
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
identity_set_active(c);
|
||||
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0);
|
||||
config_delete(c);
|
||||
|
||||
/* After clearing, the neutral default is AUTO (root-following), never a
|
||||
stale snapshot from a previous connection. */
|
||||
identity_clear_active();
|
||||
EXPECT_EQ_INT(privilege_super_permitted() ? 1 : 0, geteuid() == 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
void test_config() {
|
||||
test_config_lifecycle();
|
||||
test_config_ssh_dest();
|
||||
@@ -1715,8 +1799,11 @@ void test_config() {
|
||||
test_config_iconv_spec_wire_roundtrip();
|
||||
test_config_iconv_spec_empty_canonicalizes_to_null();
|
||||
test_config_receive_rejects_invalid_iconv_spec();
|
||||
test_config_super_mode_wire_roundtrip();
|
||||
test_config_receive_rejects_invalid_super_mode();
|
||||
test_config_receive_with_validate_rejects();
|
||||
}
|
||||
test_privilege_super_permitted_modes();
|
||||
test_config_delete_timing_early_helper();
|
||||
test_config_is_remote_dest();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user