fix(p8-security): close review gaps in the ownership gate and copy-as failure propagation

- H3: a daemon module without 'client owner = yes' now also has super-user
  device activity forced off (char/block mknod, --write-devices), so a root
  daemon can no longer be made to create/write raw devices under AUTO.  The
  entries are skipped, preserving ordinary -a pushes.
- H1/H2: propagate a failed required --copy-as chown from symlink metadata
  restore and implicitly-created parent directories, so the entry (and run)
  reports failure instead of a wrong-owner success.
- Docs/help/headers updated for A2/A3 and the device clamp; startup warning
  spells out the client-owner risk.
- Tests: daemon device clamp (skipped without opt-in, created with opt-in),
  updated --super/--fake-super expectations.
This commit is contained in:
2026-09-12 14:18:03 +02:00
parent e3840c8326
commit b216ed31fb
11 changed files with 123 additions and 38 deletions
+3 -3
View File
@@ -264,7 +264,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues | | `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes | | `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) | | `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner (the receiver never claims a `--copy-as` success it did not achieve), while a single entry failure does not abort the multithreaded run. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** | | `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`, **Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new. `-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
@@ -636,7 +636,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves. - **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused. - **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible. - **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored. - **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". - **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive. - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
@@ -829,7 +829,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
**Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root. **Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root.
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. With `ON` and no explicit identity policy, ownership falls back to raw numeric-id preservation (as `--numeric-ids`); explicit `--usermap`/`--groupmap`/`--chown`/`--numeric-ids` still win. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection and refuses client `--copy-as`/`--super`. `--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes).
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner. `--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
+5 -5
View File
@@ -169,11 +169,11 @@ void print_usage(void) {
printf(" re-apply it (fd-relative) on a privileged run; the\n"); printf(" re-apply it (fd-relative) on a privileged run; the\n");
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n"); printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
printf(" --super Permit the receiver to attempt super-user activities\n"); printf(" --super Permit the receiver to attempt super-user activities\n");
printf(" (ownership application, char/block device-node\n"); printf(" (char/block device-node creation, --write-devices)\n");
printf(" creation) within the confined receive root. Never\n"); printf(" within the confined receive root. Never elevates\n");
printf(" elevates privileges and never bypasses confinement;\n"); printf(" privileges and never bypasses confinement; ownership\n");
printf(" with no explicit identity policy, ownership follows\n"); printf(" is still applied only with an explicit identity flag\n");
printf(" raw numeric ids (as if --numeric-ids)\n"); printf(" (--numeric-ids/--chown/--usermap/--groupmap)\n");
printf(" --no-super Forbid those super-user activities even when the\n"); printf(" --no-super Forbid those super-user activities even when the\n");
printf(" receiver is running as root\n"); printf(" receiver is running as root\n");
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n"); printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
+23 -9
View File
@@ -246,12 +246,25 @@ static const char* server_module_gate(const Config* config, void* context) {
client could force arbitrary ownership inside the module root. The client could force arbitrary ownership inside the module root. The
standalone/SSH server has a single operator-authorized root and keeps standalone/SSH server has a single operator-authorized root and keeps
honoring these. */ honoring these. */
if (!module->client_owner && identity_ownership_requested(effective)) { if (!module->client_owner) {
log_message(LOG_LEVEL_ERROR, /* Ownership: refuse the whole transfer up front (a clear failure). Uses the
"daemon module '%s' refuses client-chosen ownership/super-user activities " original config so an explicit --super is caught even though super_mode is
"(no `client owner = yes` opt-in); refusing", clamped to OFF below. */
config->module); if (identity_ownership_requested(config)) {
return "client-chosen ownership is not permitted by this daemon module"; log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing",
config->module);
return "client-chosen ownership is not permitted by this daemon module";
}
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this clamp a root daemon
would still let a non-opted module create arbitrary device nodes and write
raw devices. Force them off for this connection: those entries are
skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */
effective->super_mode = SUPER_MODE_OFF;
} }
if (module->auth_user_count > 0) { if (module->auth_user_count > 0) {
/* Auth-required module (Wave B): verify the presented credentials against /* Auth-required module (Wave B): verify the presented credentials against
@@ -769,9 +782,10 @@ int main(int argc, char* argv[]) {
for (int i = 0; i < g_daemon_conf->module_count; i++) { for (int i = 0; i < g_daemon_conf->module_count; i++) {
if (g_daemon_conf->modules[i].client_owner) if (g_daemon_conf->modules[i].client_owner)
log_message(LOG_LEVEL_WARNING, log_message(LOG_LEVEL_WARNING,
"daemon module '%s' allows client-chosen ownership " "daemon module '%s' allows client-chosen ownership and super-user device "
"(`client owner = yes`); clients may request arbitrary owner ids within " "activities (`client owner = yes`); clients may request arbitrary owner ids "
"that module root", "and device nodes within that module root -- pair it with `auth users` "
"unless the module is intentionally open to the network",
g_daemon_conf->modules[i].name); g_daemon_conf->modules[i].name);
} }
/* Daemon credential store (Wave B). --password-file and --early-input /* Daemon credential store (Wave B). --password-file and --early-input
+10 -2
View File
@@ -582,8 +582,16 @@ int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs)
(a pre-existing destination directory is left alone, matching (a pre-existing destination directory is left alone, matching
rsync's transferred-entry scope); the helper is a no-op unless an rsync's transferred-entry scope); the helper is a no-op unless an
identity policy is active. */ identity policy is active. */
if (created && identity_copy_as_active()) if (created && identity_copy_as_active() &&
identity_apply_ownership_link(fd, component, 0, 0); !identity_apply_ownership_link(fd, component, 0, 0)) {
/* A REQUIRED --copy-as ownership that cannot be applied to a
directory this walk just created must fail the entry rather than
leave that implicit parent owned by the receiver. */
close(fd);
free(copy);
free(leaf);
return -1;
}
next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
} }
} }
+1 -1
View File
@@ -689,7 +689,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
suppresses the timestamps; ownership stays gated by the identity policy. suppresses the timestamps; ownership stays gated by the identity policy.
A symlink has no children, so this can be applied immediately. */ A symlink has no children, so this can be applied immediately. */
if (ok && config && config->use_metadata) if (ok && config && config->use_metadata)
file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times); ok = file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
free(link_path); free(link_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR; return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
} }
+2 -2
View File
@@ -679,8 +679,8 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
* restricted root, root-squash, or a read-only mount) the run would be * restricted root, root-squash, or a read-only mount) the run would be
* silently producing the WRONG ownership, so surface it at ERROR. The * silently producing the WRONG ownership, so surface it at ERROR. The
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather * 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 * than as written, which becomes a FILE_SAVE_ERROR and fails the transfer
* achieve, but a single entry failure does not abort the whole run. */ * (fail-fast) instead of reporting overall success with the wrong owner. */
if (errno == EPERM || errno == EACCES) { if (errno == EPERM || errno == EACCES) {
if (identity_copy_as_active()) if (identity_copy_as_active())
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
+5 -4
View File
@@ -7,7 +7,7 @@
#include <sys/types.h> #include <sys/types.h>
/* /*
* Identity mapping: --numeric-ids / --usermap / --groupmap / --chown. * Identity mapping: --numeric-ids / --usermap / --groupmap / --chown / --copy-as.
* *
* FastSync transmits uid/gid numerically (int32 on the wire) and, by design, * FastSync transmits uid/gid numerically (int32 on the wire) and, by design,
* NEVER applies client-supplied ownership unless a user explicitly opts in with * NEVER applies client-supplied ownership unless a user explicitly opts in with
@@ -87,9 +87,10 @@ bool identity_ownership_requested(const Config* config);
/* Apply the negotiated ownership to an already-written file descriptor. /* Apply the negotiated ownership to an already-written file descriptor.
* source_uid/source_gid are the transmitted numeric ids. Resolution order: * source_uid/source_gid are the transmitted numeric ids. Resolution order:
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw), * --copy-as (highest priority, forces both ids), then a matching
* then a best-effort name lookup on the receiver's own databases (skipped when * usermap/groupmap rule, then --chown, then --numeric-ids (raw), then a
* the transmitted id has no name on this system). Only calls fchown() when the * 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. * result differs from the current value.
* *
* Returns false ONLY when an active --copy-as ownership application failed: its * Returns false ONLY when an active --copy-as ownership application failed: its
+9 -5
View File
@@ -357,17 +357,20 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata,
} }
} }
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
bool omit_link_times) { bool omit_link_times) {
if (path == NULL || metadata == NULL) if (path == NULL || metadata == NULL)
return; return true;
char* leaf = NULL; char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, false); int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0) if (parent_fd < 0)
return; return !identity_copy_as_active();
/* Ownership (only when the identity policy is active) via lchown semantics: /* Ownership (only when the identity policy is active) via lchown semantics:
fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. */ fchownat with AT_SYMLINK_NOFOLLOW never dereferences the link. A failed
identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid, (int32_t)metadata->gid); REQUIRED --copy-as ownership marks the entry failed; every other policy is
best-effort. */
bool owned = identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid,
(int32_t)metadata->gid);
/* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns /* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns
EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly
ignore the unsupported case so the transfer never fails over it. */ ignore the unsupported case so the transfer never fails over it. */
@@ -392,6 +395,7 @@ void file_restore_symlink_metadata(const char* path, const FileMetadata* metadat
} }
close(parent_fd); close(parent_fd);
free(leaf); free(leaf);
return owned;
} }
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) { bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) {
+4 -2
View File
@@ -44,8 +44,10 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
* under the authorized root. `omit_link_times` (-J/--omit-link-times) * under the authorized root. `omit_link_times` (-J/--omit-link-times)
* suppresses the timestamps; the link's mode/ownership are still attempted * suppresses the timestamps; the link's mode/ownership are still attempted
* (ownership stays gated by the identity policy and by default is not applied). * (ownership stays gated by the identity policy and by default is not applied).
* A null metadata or an unfollowable parent is a harmless no-op. */ * A null metadata or an unfollowable parent is a harmless no-op. Returns false
void file_restore_symlink_metadata(const char* path, const FileMetadata* metadata, * only when a REQUIRED --copy-as ownership application failed, so the caller can
* report the entry as failed instead of claiming a wrong-owner success. */
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
bool omit_link_times); bool omit_link_times);
/* Compare timestamps using rsync's whole-second modification window. */ /* Compare timestamps using rsync's whole-second modification window. */
+7 -5
View File
@@ -89,11 +89,13 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
/* --fake-super replay: parse the FAKESUPER_XATTR record previously written on /* --fake-super replay: parse the FAKESUPER_XATTR record previously written on
* `fd` by fake_super_store_fd and re-apply uid/gid/mode/mtime fd-relative. * `fd` by fake_super_store_fd and re-apply uid/gid/mode/mtime fd-relative.
* Best-effort: absence of the xattr or a malformed record is a silent no-op * Best-effort: absence of the xattr or a malformed record is a silent no-op
* that never fails the transfer; fchown is applied only when permitted (a * that never fails the transfer. The OWNER leg is applied only when an explicit
* non-root EPERM/EACCES is skipped silently, matching FastSync's identity * ownership identity policy is active (numeric-ids/chown/usermap/groupmap/
* philosophy), and the mode is sanitized exactly like the normal metadata path * copy-as), when super-user activities are permitted, and when --copy-as is not
* (group/other write bits never granted). Returns true when the xattr was * authoritative; a non-root EPERM/EACCES is skipped silently, matching
* present and parsed. */ * FastSync's identity philosophy. The mode is sanitized exactly like the normal
* metadata path (group/other write bits never granted). Returns true when the
* xattr was present and parsed. */
bool fake_super_restore_fd(int fd); bool fake_super_restore_fd(int fd);
#endif #endif
+54
View File
@@ -16,8 +16,10 @@ import hashlib
import os import os
import shutil import shutil
import signal import signal
import stat
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
import pytest import pytest
@@ -244,6 +246,21 @@ def _tree_file_count(root):
return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0 return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0
def _can_mknod():
"""True when this process may create a char device (needs root/CAP_MKNOD)."""
probe = os.path.join(tempfile.gettempdir(), "._fastsync_mknod_probe_%d" % os.getpid())
try:
os.mknod(probe, stat.S_IFCHR | 0o600, os.makedev(1, 3))
os.unlink(probe)
return True
except (OSError, AttributeError):
try:
os.unlink(probe)
except OSError:
pass
return False
class TestDaemonModuleSelection: class TestDaemonModuleSelection:
@pytest.mark.ci @pytest.mark.ci
def test_module_transfer(self, daemon): def test_module_transfer(self, daemon):
@@ -382,6 +399,43 @@ class TestDaemonRejection:
assert not missing, f"missing: {missing[:5]}" assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}" assert not mismatches, f"mismatch: {mismatches[:5]}"
def _device_source(self, name):
src = os.path.join(TEST_DATA_DIR, name)
shutil.rmtree(src, ignore_errors=True)
os.makedirs(src)
with open(os.path.join(src, "f.txt"), "wb") as fh:
fh.write(b"device gate\n")
os.mknod(os.path.join(src, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3))
return src
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
def test_devices_skipped_without_owner_opt_in(self, daemon):
"""H3: a non-opted daemon module must not create device nodes even under
the default AUTO super mode (a root daemon would otherwise let any client
mknod arbitrary devices). An ordinary -a push still succeeds; the device
entry is skipped."""
src = self._device_source("devsrc_noowner")
os.makedirs(os.path.join(FILES_MODULE, "devskip"), exist_ok=True)
result, _ = run_client(src, "127.0.0.1::files/devskip", port=daemon.port, flags=["-a"])
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(os.path.join(FILES_MODULE, "devskip"), src)
node = os.path.join(received, "null")
assert not os.path.exists(node) or not stat.S_ISCHR(os.stat(node).st_mode), \
"non-opted daemon module created a device node"
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
def test_devices_created_with_owner_opt_in(self, daemon):
"""Control: an opted-in module (`client owner = yes`) may create device
nodes under -a, proving the clamp is specific to non-opted modules."""
src = self._device_source("devsrc_owner")
os.makedirs(os.path.join(OWNER_MODULE, "devok"), exist_ok=True)
result, _ = run_client(src, "127.0.0.1::owner/devok", port=daemon.port, flags=["-a"])
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(os.path.join(OWNER_MODULE, "devok"), src)
node = os.path.join(received, "null")
assert os.path.exists(node) and stat.S_ISCHR(os.stat(node).st_mode), \
"opted-in daemon module did not create the device node"
@pytest.mark.daemon_detach @pytest.mark.daemon_detach
def test_real_detach_path(self): def test_real_detach_path(self):
"""--daemon WITHOUT --no-detach double-forks a real background daemon; """--daemon WITHOUT --no-detach double-forks a real background daemon;