fix(p8-security): enforce daemon ownership policy, gate fake-super replay, drop implicit numeric-ids, make copy-as failures per-entry
A1: daemon refuses every client-chosen ownership/super-user request (--numeric-ids/--chown/--usermap/--groupmap/--fake-super/--copy-as/--super) unless the selected module opts in with 'client owner = yes'. A2: fake-super owner replay requires an explicit ownership identity policy. A3: --super no longer implies --numeric-ids (ownership stays opt-in). A5: a failed --copy-as chown marks the entry failed instead of reporting success with the wrong owner.
This commit is contained in:
+8
-7
@@ -404,18 +404,19 @@ typedef struct Config {
|
||||
|
||||
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
||||
* policy for super-user activities confined below the authorized receive
|
||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing behavior: a
|
||||
* privileged operation is only attempted when the receiver is ALREADY root
|
||||
* (geteuid() == 0). SUPER_MODE_ON (--super) PERMITS the receiver to attempt
|
||||
* those activities (ownership application, char/block device-node creation)
|
||||
* even when it is not root -- the attempt is then confined exactly as before
|
||||
* and simply fails/skips if the kernel refuses it. SUPER_MODE_OFF
|
||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort
|
||||
* behavior: the confined super-user operation is ALWAYS attempted and an
|
||||
* unprivileged attempt is refused by the kernel and skipped per entry.
|
||||
* SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block
|
||||
* device-node creation, --write-devices); it does NOT imply --numeric-ids and
|
||||
* never enables ownership application on its own. SUPER_MODE_OFF
|
||||
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
||||
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
||||
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||
* --super only permits an attempt that is already confined. Crosses the wire
|
||||
* as a trailing int so the receiver can enforce the policy. See
|
||||
* privilege_super_permitted() in identity.h. */
|
||||
* privilege_super_permitted() and identity_ownership_requested() in
|
||||
* identity.h. */
|
||||
int super_mode;
|
||||
|
||||
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
||||
|
||||
@@ -170,6 +170,17 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
||||
module->read_only = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "client owner")) {
|
||||
bool parsed;
|
||||
if (!parse_bool_value(value, &parsed)) {
|
||||
set_error(err, err_size,
|
||||
"module '%s': 'client owner' must be yes/no (or true/false/1/0), got '%s'",
|
||||
module->name, value);
|
||||
return false;
|
||||
}
|
||||
module->client_owner = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "auth users")) {
|
||||
char* list = str_dup(value);
|
||||
if (!list) {
|
||||
|
||||
@@ -24,12 +24,16 @@
|
||||
* selects this module to this path (file_open_secure_parent /
|
||||
* has_path_traversal / path_is_within all keep the existing confinement, just
|
||||
* per-module). There is never any client-chosen root: a module path always
|
||||
* stays confined. A daemon also REFUSES a client --copy-as outright, because
|
||||
* there is no per-module opt-in for client-chosen ownership (unlike the
|
||||
* standalone/SSH server, which honors it for its single operator-authorized
|
||||
* root); the operator-level --no-super veto additionally forces super-user
|
||||
* activities off for every daemon connection. See server_module_gate in
|
||||
* server.c and RSYNC_COMPAT.md.
|
||||
* stays confined. A daemon REFUSES every client-chosen ownership / super-user
|
||||
* request by default -- --numeric-ids, --chown, --usermap/--groupmap,
|
||||
* --fake-super, --copy-as and an explicit --super -- because there is no
|
||||
* per-module opt-in unless the operator adds one. An operator opts a single
|
||||
* module in with `client owner = yes` (DaemonModule.client_owner), which allows
|
||||
* that client to choose ownership within that module's root (the standalone/SSH
|
||||
* server honors such requests for its single operator-authorized root). The
|
||||
* operator-level --no-super veto additionally forces super-user activities off
|
||||
* for every daemon connection, even an opted-in module. See server_module_gate
|
||||
* in server.c and RSYNC_COMPAT.md.
|
||||
*
|
||||
* `auth_users` is honored by Wave B daemon authentication: a module that
|
||||
* declares auth users accepts a connection only when the presented username is
|
||||
@@ -41,6 +45,11 @@ typedef struct DaemonModule {
|
||||
char* name; /* module name, as the client requests it */
|
||||
char* path; /* module root (daemon-side authorized root) */
|
||||
bool read_only; /* `read only = yes/no`; default no */
|
||||
bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in
|
||||
that lets this module's clients choose ownership
|
||||
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
||||
--copy-as) and request explicit --super super-user
|
||||
activities. Without it the daemon refuses all of them. */
|
||||
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
||||
int auth_user_count;
|
||||
} DaemonModule;
|
||||
|
||||
@@ -468,13 +468,16 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
||||
every entry (a char/block node path is already privilege-gated above). The
|
||||
no-follow helper changes the node's own ownership without dereferencing it;
|
||||
it is a no-op unless an identity policy is active. */
|
||||
bool owner_ok = true;
|
||||
if (identity_active_enabled())
|
||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
owner_ok = identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
close(parent_fd);
|
||||
free(leaf);
|
||||
free(destination);
|
||||
return FILE_SAVE_WRITTEN;
|
||||
/* A failed required --copy-as ownership marks the node as failed; every other
|
||||
* identity policy stays best-effort. */
|
||||
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
/* --write-devices (receiver): write the received data directly into an EXISTING
|
||||
@@ -626,8 +629,9 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
||||
char* leaf = NULL;
|
||||
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
||||
if (parent_fd >= 0) {
|
||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid);
|
||||
if (!identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||
(int32_t)file->metadata->gid))
|
||||
ok = false;
|
||||
close(parent_fd);
|
||||
}
|
||||
free(leaf);
|
||||
|
||||
+41
-31
@@ -128,29 +128,29 @@ bool privilege_super_mode_permitted(int mode) {
|
||||
return mode != SUPER_MODE_OFF;
|
||||
}
|
||||
|
||||
/* --super with NO explicit identity policy implies raw numeric-id preservation,
|
||||
* exactly as if --numeric-ids had been given. An explicit usermap/groupmap/
|
||||
* --chown/--numeric-ids always wins: identity_resolve_targets() checks those
|
||||
* before the numeric fallback, and this predicate is false whenever any of them
|
||||
* is present. In AUTO (the default) no implication is made, preserving the
|
||||
* opt-in-only behavior. */
|
||||
static bool identity_super_implies_numeric(void) {
|
||||
return g_identity.super_mode == SUPER_MODE_ON && !g_identity.numeric_ids &&
|
||||
!g_identity.chown_uid_set && !g_identity.chown_gid_set && g_identity.usermap_count == 0 &&
|
||||
g_identity.groupmap_count == 0;
|
||||
}
|
||||
|
||||
bool identity_active_enabled(void) {
|
||||
/* numeric_ids is included: this set only gates identity_apply_ownership,
|
||||
which runs only when metadata is present (a -M/--preserve transfer). A
|
||||
standalone --numeric-ids (no ownership-affecting flag) carries no
|
||||
metadata, never reaches identity_apply_ownership, and therefore correctly
|
||||
stays inert; combined with -M it activates raw-id application. --super
|
||||
with no explicit identity policy acts like --numeric-ids here. */
|
||||
stays inert; combined with -M it activates raw-id application. --super /
|
||||
--no-super does NOT enable ownership: it only permits or forbids the
|
||||
already-requested super-user activities, so a --super with no explicit
|
||||
identity flag must never silently apply client-chosen ownership. */
|
||||
return g_identity.set &&
|
||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set ||
|
||||
identity_super_implies_numeric());
|
||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set);
|
||||
}
|
||||
|
||||
bool identity_ownership_requested(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
/* Every value that makes the receiver act on a client-chosen owner, plus an
|
||||
* explicit --super (super-user device-node activities). Pure config, so the
|
||||
* daemon gate can evaluate it before identity_set_active(). */
|
||||
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
||||
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
||||
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
||||
}
|
||||
|
||||
bool identity_copy_as_active(void) {
|
||||
@@ -613,7 +613,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
||||
} else if (g_identity.chown_uid_set) {
|
||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||
set_uid = true;
|
||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||
} else if (g_identity.numeric_ids) {
|
||||
uid = (uid_t)source_uid;
|
||||
set_uid = true;
|
||||
} else {
|
||||
@@ -637,7 +637,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
||||
} else if (g_identity.chown_gid_set) {
|
||||
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||
set_gid = true;
|
||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
||||
} else if (g_identity.numeric_ids) {
|
||||
gid = (gid_t)source_gid;
|
||||
set_gid = true;
|
||||
} else {
|
||||
@@ -676,9 +676,11 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
||||
* --copy-as is different: the whole point of the flag is that the target
|
||||
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
||||
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
||||
* restricted root, root-squash, or a read-only mount) the run is silently
|
||||
* producing the WRONG ownership, so surface it at ERROR. It stays
|
||||
* non-fatal: never abort the multithreaded receiver mid-transfer. */
|
||||
* restricted root, root-squash, or a read-only mount) the run would be
|
||||
* silently producing the WRONG ownership, so surface it at ERROR. The
|
||||
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather
|
||||
* than as written; the receiver never claims a --copy-as success it did not
|
||||
* achieve, but a single entry failure does not abort the whole run. */
|
||||
if (errno == EPERM || errno == EACCES) {
|
||||
if (identity_copy_as_active())
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
@@ -695,35 +697,43 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
||||
}
|
||||
}
|
||||
|
||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||
/* Ownership application is OFF unless the client requested an identity flag.
|
||||
* This is the controlled gate: a default (or plain -M) transfer never changes
|
||||
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
||||
* additionally forbids it even when the receiver is root. */
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
||||
return;
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return;
|
||||
if (fchown(fd, uid, gid) != 0)
|
||||
return true;
|
||||
if (fchown(fd, uid, gid) != 0) {
|
||||
identity_log_chown_failure("file", uid, gid);
|
||||
/* A required --copy-as ownership that did not land is a per-entry failure;
|
||||
* every other policy stays best-effort (rsync parity). */
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid) {
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
||||
return;
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
||||
return;
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return;
|
||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0)
|
||||
return true;
|
||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
identity_log_chown_failure("no-follow entry", uid, gid);
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+24
-7
@@ -73,23 +73,40 @@ void identity_clear_active(void);
|
||||
|
||||
/* True when any ownership-affecting identity option is present in the active
|
||||
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
||||
* requests none of them, preserving FastSync's existing behavior. */
|
||||
* requests none of them, preserving FastSync's existing behavior. --super /
|
||||
* --no-super alone does NOT enable ownership; an explicit identity flag
|
||||
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) is required. */
|
||||
bool identity_active_enabled(void);
|
||||
|
||||
/* Pure, config-only predicate: true when the client requested ANY
|
||||
* client-chosen ownership or super-user activity (--numeric-ids, --chown,
|
||||
* --usermap/--groupmap, --copy-as, --fake-super, or an explicit --super). Used
|
||||
* by the daemon module gate to decide whether a module's per-module opt-in is
|
||||
* required; it never reads the per-connection snapshot. */
|
||||
bool identity_ownership_requested(const Config* config);
|
||||
|
||||
/* Apply the negotiated ownership to an already-written file descriptor.
|
||||
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
||||
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw),
|
||||
* then a best-effort name lookup on the receiver's own databases (skipped when
|
||||
* the transmitted id has no name on this system). Only calls fchown() when the
|
||||
* result differs from the current value; EPERM/EACCES are logged and ignored,
|
||||
* never fatal (rsync parity: the transfer must not abort). */
|
||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||
* result differs from the current value.
|
||||
*
|
||||
* Returns false ONLY when an active --copy-as ownership application failed: its
|
||||
* forced ownership is REQUIRED, so the caller must treat the entry as failed
|
||||
* rather than reporting success with the wrong owner. For every other identity
|
||||
* policy an fchown EPERM/EACCES is logged and ignored and true is returned
|
||||
* (rsync parity: the transfer must not abort). A no-op when no identity policy
|
||||
* is active returns true. */
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||
|
||||
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
|
||||
* usermap/groupmap/chown/numeric-ids policy but applies it with
|
||||
* usermap/groupmap/chown/numeric-ids/copy-as policy but applies it with
|
||||
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
|
||||
* without ever dereferencing it. A no-op unless an identity flag is active. */
|
||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
* without ever dereferencing it. A no-op unless an identity flag is active.
|
||||
* The return value follows identity_apply_ownership(): false only when an
|
||||
* active --copy-as application failed. */
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid);
|
||||
|
||||
/* Receiver-side wire validation of the resolved identity fields. */
|
||||
|
||||
@@ -409,10 +409,14 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
||||
--groupmap / --chown). identity_apply_ownership is the controlled,
|
||||
privilege-gated path: it consults the negotiated policy, resolves the
|
||||
target ids, and applies them via an fd-relative fchown() that is confined
|
||||
to the just-written file (EPERM/EACCES are logged, never fatal). With no
|
||||
identity flag set it is a no-op, so a default or plain -M transfer keeps
|
||||
FastSync's existing behavior of never applying client ownership. */
|
||||
identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid);
|
||||
to the just-written file (EPERM/EACCES are logged, never fatal) -- EXCEPT
|
||||
for an active --copy-as, whose forced ownership is REQUIRED: a failure
|
||||
marks this entry as failed instead of reporting a wrong-owner write as
|
||||
success. With no identity flag set it is a no-op, so a default or plain -M
|
||||
transfer keeps FastSync's existing behavior of never applying client
|
||||
ownership. */
|
||||
if (!identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid))
|
||||
ok = false;
|
||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
||||
if (metadata->atime_valid) {
|
||||
|
||||
+7
-2
@@ -340,7 +340,12 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
||||
* mirroring the normal metadata identity path; other errors are logged) and
|
||||
* still applies mode/mtime where permitted.
|
||||
*
|
||||
* The OWNER leg additionally honors two policies:
|
||||
* The OWNER leg additionally honors three policies:
|
||||
* - an explicit ownership identity policy must be active (numeric-ids /
|
||||
* chown / usermap / groupmap / copy-as). --fake-super on its own only
|
||||
* RECORDS the source owner; replaying that owner as a live chown without an
|
||||
* explicit ownership opt-in would be an un-gated client-chosen-ownership
|
||||
* primitive.
|
||||
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
||||
* root receiver, exactly like the normal metadata identity path.
|
||||
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
||||
@@ -370,7 +375,7 @@ bool fake_super_restore_fd(int fd) {
|
||||
not hidden. --no-super suppresses the owner leg even for root, and an
|
||||
active --copy-as is authoritative so its forced owner must not be
|
||||
overwritten by the recorded source owner. */
|
||||
if (privilege_super_permitted() && !identity_copy_as_active() &&
|
||||
if (identity_active_enabled() && privilege_super_permitted() && !identity_copy_as_active() &&
|
||||
fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES)
|
||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s",
|
||||
strerror(errno));
|
||||
|
||||
Reference in New Issue
Block a user