fix(parity): rsync 3.4.1 symlink and special-node semantics (#287, #288)

#287:
- --safe-links: keep safe in-tree links AS symlinks and skip unsafe
  (absolute or ".."-escaping) ones, mirroring rsync's unsafe_symlink().
  Skipped links are recorded as delete-protected so --delete does not
  remove their destination mirror (no silent data loss).
- --copy-unsafe-links: preserve safe links as symlinks and dereference
  only unsafe ones.
- --munge-links: receiver-side rewrite storing /rsyncd-munged/-prefixed
  targets (rsync parity), replacing the no-op #SYMLINK sender prefix.
- -l: store the target verbatim, including absolute and ".." targets
  (rsync -l parity); the old receiver containment silently dropped them.

#288:
- --specials: recreate unix-domain sockets via mknod(S_IFSOCK), which
  Linux permits unprivileged; keep EEXIST/EPERM skip behavior.
- --copy-devices: copy a device's content into a regular file when
  requested; skip unrequested non-regular entries like rsync's default.
This commit is contained in:
2026-09-15 21:57:48 +02:00
parent 23552e823d
commit ea4ab661b4
8 changed files with 573 additions and 250 deletions
+71 -20
View File
@@ -415,10 +415,69 @@ bool file_get_trust_sender(void) {
return file_trust_sender;
}
/* True when `target` is a lexical symlink target that can never escape the
* receive root once created beneath it: relative (not absolute) and containing
* no ".." path component. Used by --munge-links' sender-side containment: an
* escaping target is never transmitted (the entry is skipped/contained). */
/* rsync 3.4.1 unsafe_symlink(): true when `target` (the link's destination
* string) points outside the transfer tree rooted at the symlink's own
* location. `link_path` is the symlink's path relative to the top of the
* transfer (including its name). This is a purely lexical test matching
* rsync's util1.c: absolute/empty targets are always unsafe; leading "../"
* components are counted against the symlink's own directory depth; a ".."
* that would climb above the transfer root is unsafe. rsync 3.4.1 additionally
* rejects any INTERNAL "/../" component and a trailing "/..". */
bool file_symlink_unsafe(const char* target, const char* link_path) {
if (!target || target[0] == '\0' || target[0] == '/')
return true;
const char* rest = target;
while (strncmp(rest, "../", 3) == 0) {
rest += 3;
while (*rest == '/')
rest++;
}
if (strstr(rest, "/../") != NULL)
return true;
size_t target_len = strlen(target);
if (target_len > 3 && strcmp(&target[target_len - 3], "/..") == 0)
return true;
int depth = 0;
const char* name;
const char* slash;
const char* src = link_path ? link_path : "";
for (name = src; (slash = strchr(name, '/')) != NULL; name = slash + 1) {
if (*name == '.' && (name[1] == '/' || (name[1] == '.' && name[2] == '/'))) {
if (name[1] == '.')
depth = 0;
} else {
depth++;
}
while (slash[1] == '/')
slash++;
}
if (*name == '.' && name[1] == '.' && name[2] == '\0')
depth = 0;
for (name = target; (slash = strchr(name, '/')) != NULL; name = slash + 1) {
if (*name == '.' && (name[1] == '/' || (name[1] == '.' && name[2] == '/'))) {
if (name[1] == '.') {
if (--depth < 0)
return true;
}
} else {
depth++;
}
while (slash[1] == '/')
slash++;
}
if (*name == '.' && name[1] == '.' && name[2] == '\0')
depth--;
return depth < 0;
}
/* Strict lexical helper: true when `target` is relative (not absolute) and
* contains no ".." component at all, so it can never escape the directory it
* is created in. This is stricter than rsync's unsafe_symlink() (which allows
* an in-tree ".."); the scanner/receiver use file_symlink_unsafe()/--safe-links
* for rsync parity, and this helper is retained for callers that want the
* ".."-free guarantee. */
bool file_symlink_target_contained(const char* target) {
if (!target || target[0] == '\0' || target[0] == '/')
return false;
@@ -449,8 +508,9 @@ bool file_symlink_unmunge(char* target) {
return true;
}
/* Owned copy of `target` prefixed with SYMLINK_MUNGE_PREFIX (the sender-side
* --munge-links rewriting). Returns NULL on allocation failure. */
/* Owned copy of `target` prefixed with SYMLINK_MUNGE_PREFIX (the receiver-side
* --munge-links rewriting, matching rsync's receiver). Returns NULL on
* allocation failure. */
char* file_symlink_munge(const char* target) {
if (!target)
return NULL;
@@ -471,23 +531,14 @@ char* file_symlink_munge(const char* target) {
* the target is ever followed. The final component is never dereferenced: an
* existing non-directory entry at `path` is unlinked by name before the link is
* placed; an existing directory there is left untouched (returns false, so a
* caller can treat it as a collision). As a receiver-side trust-boundary
* invariant, `target` must be file_symlink_target_contained() (relative and
* ".."-free): an absolute or escaping target is rejected outright (returns
* false) so a malicious sender can never materialize a symlink that points
* outside the receive root. */
* caller can treat it as a collision). The link VALUE `target` is copied
* verbatim, matching rsync -l (which stores absolute and ".."-bearing targets
* as-is); target policy is the caller's job -- the scanner applies
* --safe-links/--copy-unsafe-links, and the receiver applies --munge-links.
* The PLACEMENT path is always confined below the authorized root. */
bool file_symlink_at_secure(const char* path, const char* target) {
/* The link itself (`path`) is always kept below the authorized root. The
TARGET may point anywhere: normally only a contained (relative, ".."-free)
target is permitted so a malicious sender can never plant a symlink that
later dereferences outside the root. Under --trust-sender that target
containment check is relaxed (the receiver trusts the sender and copies the
link verbatim, matching rsync -l), but path/leaf confinement is never
disabled, so the link still cannot be placed outside the tree. */
if (!path || !target || has_path_traversal(path))
return false;
if (!file_trust_sender && !file_symlink_target_contained(target))
return false;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, true);
if (parent_fd < 0)
+15 -6
View File
@@ -44,12 +44,20 @@ int file_open_for_read(const char* path);
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse);
/* Symlink trust-boundary helpers (Phase 4, symlink wave). --munge-links
* sender-side marker: every transmitted symlink target is prefixed with this
* while the flag is on; the receiver strips it to restore the real target. */
#define SYMLINK_MUNGE_PREFIX "#SYMLINK/"
/* Symlink trust-boundary helpers (Phase 4, symlink wave; rsync parity).
* --munge-links is a RECEIVER-side rewrite: rsync prefixes every stored symlink
* target with this marker, making the link unusable while the referenced
* directory does not exist. A SENDER receiving a munged source strips it back
* off before transmitting (so a munged tree round-trips through the receiver's
* re-munging). */
#define SYMLINK_MUNGE_PREFIX "/rsyncd-munged/"
char* file_symlink_munge(const char* target);
/* rsync 3.4.1 unsafe_symlink(): true when `target` escapes the transfer tree
* rooted at `link_path` (the symlink's transfer-relative path incl. its name).
* Absolute/empty targets and targets climbing above the transfer root (via
* "..") are unsafe, as are internal "/../" components and trailing "/..". */
bool file_symlink_unsafe(const char* target, const char* link_path);
/* True when a lexical target is relative and contains no ".." component, so it
* can never escape the receive root once created beneath it. */
bool file_symlink_target_contained(const char* target);
@@ -57,8 +65,9 @@ bool file_symlink_target_contained(const char* target);
* returns true when a marker was removed. */
bool file_symlink_unmunge(char* target);
/* Create a symlink at `path` -> `target`, confined below the authorized root
* (O_NOFOLLOW parent walk, symlinkat; the target is never followed). Returns
* false when a directory already occupies `path`. */
* (O_NOFOLLOW parent walk, symlinkat; the target is never followed). The link
* value is copied verbatim (rsync -l); only the placement path is confined.
* Returns false when a directory already occupies `path`. */
bool file_symlink_at_secure(const char* path, const char* target);
/* --keep-dirlinks (-K) receiver process-wide policy: allow an in-root existing
* symlink-to-directory to be followed as a directory. */
+26 -33
View File
@@ -358,14 +358,6 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
log_message(LOG_LEVEL_ERROR, "Special node has no device/FIFO/socket mode");
return FILE_SAVE_ERROR;
}
if (is_sock) {
/* No standard filesystem call recreates a socket; best-effort unsupported. */
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "socket not recreated: %s (unsupported; skipped)",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
if (is_char || is_blk) {
if (!config || !config->preserve_devices)
return FILE_SAVE_SKIPPED;
@@ -383,7 +375,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
} else if (is_fifo) {
} else if (is_fifo || is_sock) {
/* FIFOs and unix sockets are recreated by --specials. mknod(S_IFSOCK)
works unprivileged on Linux (the node carries no live socket), so unlike
a socket bound to a live fd it can be materialized. */
if (!config || !config->preserve_specials)
return FILE_SAVE_SKIPPED;
}
@@ -433,9 +428,12 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
} else if (is_blk) {
create_mode = S_IFBLK;
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
} else if (is_sock) {
create_mode = S_IFSOCK;
} else {
create_mode = S_IFIFO;
}
const char* node_kind = (is_char || is_blk) ? "device" : (is_fifo ? "FIFO" : "socket");
/* The creation permission bits come from the source only under -p/--perms;
* otherwise a safe default (0644, group/other write never granted) keeps an
* unprivileged no--p run from materializing a world-writable node. */
@@ -450,7 +448,7 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) == 0 &&
((is_char && S_ISCHR(st.st_mode)) || (is_blk && S_ISBLK(st.st_mode)) ||
(is_fifo && S_ISFIFO(st.st_mode)))) {
(is_fifo && S_ISFIFO(st.st_mode)) || (is_sock && S_ISSOCK(st.st_mode)))) {
close(parent_fd);
free(leaf);
free(destination);
@@ -458,7 +456,7 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
}
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "refusing to replace existing entry with %s: %s (skipped)",
is_fifo ? "FIFO" : "device", escaped_path ? escaped_path : "<allocation failed>");
node_kind, escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
} else if (errno == EPERM || errno == EACCES) {
/* Missing CAP_MKNOD / parent write permission: the environment cannot
@@ -467,14 +465,12 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
log_message(LOG_LEVEL_WARNING,
"skipping %s: cannot create %s node (%s)\n"
" --devices/--specials node creation needs privilege (CAP_MKNOD)",
escaped_path ? escaped_path : "<allocation failed>", is_fifo ? "FIFO" : "device",
strerror(errno));
escaped_path ? escaped_path : "<allocation failed>", node_kind, strerror(errno));
free(escaped_path);
} else {
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)",
is_fifo ? "FIFO" : "device", escaped_path ? escaped_path : "<allocation failed>",
strerror(errno));
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)", node_kind,
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
close(parent_fd);
@@ -710,26 +706,23 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
char* link_path = path_cat(root_directory, file->path);
if (!link_path)
return FILE_SAVE_ERROR;
/* Restore the real target by stripping the sender's --munge-links marker.
Only unmunge when the policy was negotiated: a plain -l run must preserve
a source symlink whose target genuinely begins with the marker verbatim. */
/* The link value is stored verbatim (rsync -l parity: absolute and
".."-bearing targets are preserved; the scanner's --safe-links /
--copy-unsafe-links decide which links are sent at all). --munge-links
is a RECEIVER-side rewrite: the stored target is prefixed with
/rsyncd-munged/, making the link unusable while the referenced directory
does not exist -- exactly as rsync's receiver munges. Only the link's
own placement path is confined below the receive root. */
bool munge = config && config->munge_links;
char* target = str_dup(file->symlink_target);
bool ok = target != NULL;
if (ok && config && config->munge_links)
file_symlink_unmunge(target);
/* Receiver-side trust boundary (independent of the sender): a target that
could escape the receive root (absolute, or relative-with-"..") is never
materialized. It is contained (the entry is skipped) rather than failing
the whole transfer, so a hostile sender can inject a broken symlink but
can never redirect it outside the root. --trust-sender deliberately
relaxes this receiver-side re-validation: a trusted sender's escaping
symlink target is copied verbatim (rsync -l parity). The low-level
leaf/destination confinement in file_symlink_at_secure still ensures the
link itself is placed inside the authorized root. */
if (ok && !file_get_trust_sender() && !file_symlink_target_contained(target))
ok = false;
if (ok && munge) {
char* munged = file_symlink_munge(target);
free(target);
target = munged;
ok = target != NULL;
}
if (!ok) {
/* Skip the escaping/empty target (contained) rather than abort. */
free(target);
free(link_path);
return FILE_SAVE_SKIPPED;