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
+91
View File
@@ -14,6 +14,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
@@ -994,6 +995,94 @@ static void test_inplace_overwrite_truncates_shorter_payload() {
rmdir(root);
}
/* B2: --inplace must refuse an existing non-regular destination entry. A FIFO
would block open(O_WRONLY) forever and a device node would be written
directly, bypassing the --write-devices/super gate. Forked with an alarm so
a regression is a prompt failure instead of a hung suite. */
static void test_inplace_refuses_fifo_destination() {
const char* root = "test_inplace_fifo_tmp";
const char* path = "test_inplace_fifo_tmp/fifo";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("fifo");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISFIFO(st.st_mode)); /* left untouched */
unlink(path);
rmdir(root);
}
/* B2: an existing char device must not be written by --inplace. mknod needs
privilege, so a non-root run skips gracefully. /dev/null's (1:3) rdev makes
the negative case harmless if it ever regresses. */
static void test_inplace_refuses_device_destination() {
const char* root = "test_inplace_dev_tmp";
const char* path = "test_inplace_dev_tmp/dev";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (mknod(path, S_IFCHR | 0600, makedev(1, 3)) != 0) {
rmdir(root);
return; /* no privilege to create a device node: skip */
}
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("dev");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISCHR(st.st_mode)); /* still a device, not replaced */
unlink(path);
rmdir(root);
}
/* Explicit directory entries (--dirs) create the directory under the receive
root through the same save funnel, creating parents as needed, and reject
traversal the same way a file path does. */
@@ -1606,4 +1695,6 @@ void test_file() {
test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination();
}