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
+13 -9
View File
@@ -75,7 +75,7 @@ static void write_best_effort(int fd, const void* data, size_t size) {
}
static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data,
size_t size) {
size_t size, bool preserve_acls) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
return;
@@ -91,7 +91,7 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
shutdown(sv[0], SHUT_WR);
int ok = 0;
FileXattrList* list = xattr_receive(sv[1], &ok);
FileXattrList* list = xattr_receive(sv[1], &ok, preserve_acls);
xattr_list_free(list);
close(sv[0]);
@@ -102,14 +102,18 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (!g_block_ready)
build_canonical_block();
/* Raw bytes as the whole block. */
receive_stream(NULL, 0, data, size);
/* Raw bytes as the whole block. Exercise both the -X-only (no ACLs) and the
* -A (ACL names accepted) receiver gates. */
for (int acls = 0; acls < 2; acls++) {
bool preserve_acls = acls != 0;
receive_stream(NULL, 0, data, size, preserve_acls);
/* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size);
receive_stream(g_block, g_off_value0, data, size);
receive_stream(g_block, g_off_after_count, data, size);
/* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size, preserve_acls);
receive_stream(g_block, g_off_value0, data, size, preserve_acls);
receive_stream(g_block, g_off_after_count, data, size, preserve_acls);
}
return 0;
}