fix(receiver): non-blocking receiver opens, inplace type gate, dry-run/B4/B5/B6

Address confirmed receiver security findings B1-B6:

B1 (HIGH): add O_NONBLOCK to the three receiver read-opens that opened an
existing destination/basis entry before the S_ISREG gate
(incremental_check_open_destination, basis_open_regular, hardlink_read_source)
so a client-planted FIFO can no longer block the receive thread forever while
the post-open type gate still rejects it.

B2 (HIGH/MED): --inplace now fstatat(AT_SYMLINK_NOFOLLOW)-probes the target and
refuses any existing non-regular entry, opens with O_NONBLOCK, and re-checks
S_ISREG on the opened fd.  This stops a FIFO from hanging the open and stops a
char/block device from being written directly (bypassing --write-devices).

B3 (MED): under --dry-run the incremental quick-skip no longer reads/hashes the
destination file for --checksum/--delta; it decides from metadata only and
reports would-transfer when the comparison is inconclusive, closing the
read-only-module content-hash oracle.

B4 (LOW): xattr_name_appliable() now gates the two system.posix_acl_* names on
preserve_acls (--acls), not the derived use_xattrs (--xattrs OR --acls).  The
receiver drops (never applies) ACL entries when -A was not negotiated while
keeping user.* working for -X.

B5 (INFO): receive_manifest_section() charges a per-entry overhead against
MAX_MANIFEST_BYTES and the aggregate entry count across all three sections is
capped at MAX_MANIFEST_ENTRIES.

B6 (MED): data_charge_session() reserves decompressed/chunk-copy bytes against
the owning ProtocolSession (MAX_CONNECTION_MEMORY) and records them on the Data
so data_destroy() releases them via the Data.owner path.  Applied to the
whole-file/append/delta decompression sites and chunk_deserialize() per-file
copies; a missing session owner degrades to the previous uncharged behavior.

Tests: FIFO destination/basis non-hang (with alarm), --inplace FIFO/device
refusal, dry-run no-read oracle test plus updated metadata-only dry-run tests,
ACL-without--acls drop, manifest total-entry cap, and chunk session charging.
This commit is contained in:
2026-09-14 16:19:26 +02:00
parent df887c73b1
commit a2370433b2
12 changed files with 635 additions and 63 deletions
+60 -10
View File
@@ -17,7 +17,7 @@
static void run_recv_helper(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok)
_exit(1);
if (!list) {
@@ -64,7 +64,7 @@ static void test_xattr_wire_roundtrip() {
static void run_recv_must_fail(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
FileXattrList* list = xattr_receive(fd, &ok, false);
/* A NULL list with ok==0 is the expected rejection. */
if (ok == 0 && list == NULL)
_exit(0);
@@ -148,15 +148,64 @@ static void test_xattr_count_bound() {
/* The captured list on a plain file reflects only whitelisted namespaces
* (Linux only; skipped when the filesystem has no xattr support). */
static void test_xattr_capture_and_appliable() {
EXPECT_FALSE(xattr_name_appliable(NULL));
EXPECT_FALSE(xattr_name_appliable(""));
EXPECT_FALSE(xattr_name_appliable("security.selinux"));
EXPECT_FALSE(xattr_name_appliable("trusted.blob"));
EXPECT_TRUE(xattr_name_appliable("user.foo"));
EXPECT_FALSE(xattr_name_appliable(NULL, false));
EXPECT_FALSE(xattr_name_appliable("", false));
EXPECT_FALSE(xattr_name_appliable("security.selinux", false));
EXPECT_FALSE(xattr_name_appliable("trusted.blob", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", true));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default"));
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", false));
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", true));
/* B4: the ACL names require --acls; -X alone must not authorize them. */
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_access", false));
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_default", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default", true));
}
/* B4: a `-X`-only receiver (preserve_acls false) must NOT apply an incoming
* ACL xattr, while a user.* attribute in the same block still survives. The
* ACL entry is dropped, not applied (and the -X transfer is not failed). */
static void run_recv_drops_acl_keeps_user(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok || list == NULL)
_exit(1);
bool saw_user = false;
for (int i = 0; i < list->count; i++) {
if (strcmp(list->items[i].name, "system.posix_acl_access") == 0)
_exit(1); /* ACL must have been dropped */
if (strcmp(list->items[i].name, "user.keep") == 0)
saw_user = true;
}
xattr_list_free(list);
_exit(saw_user ? 0 : 1);
}
static void test_xattr_receive_drops_acl_without_preserve_acls() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
run_recv_drops_acl_keeps_user(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "system.posix_acl_access", "\x02\x00\x00\x00", 4));
EXPECT_TRUE(xattr_list_append(list, "user.keep", "yes", 3));
xattr_send(p[1], list);
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
/* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply
@@ -360,6 +409,7 @@ void test_xattr() {
test_xattr_reject_oversized_value();
test_xattr_count_bound();
test_xattr_capture_and_appliable();
test_xattr_receive_drops_acl_without_preserve_acls();
test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore();
test_fake_super_owner_gate();