Release v2.26.0 #284

Merged
TapTap merged 210 commits from dev into main 2026-09-18 19:05:52 +02:00
19 changed files with 537 additions and 58 deletions
Showing only changes of commit e79d2b47b0 - Show all commits
+9 -1
View File
@@ -178,6 +178,7 @@ transfer is never aborted.
| `--ca <path>` | TLS CA certificate file for verification (PEM) | | `--ca <path>` | TLS CA certificate file for verification (PEM) |
| `--destination-root <path>` | Authorized destination root (default: `.`) | | `--destination-root <path>` | Authorized destination root (default: `.`) |
| `--allow-delete` | Permit manifest deletion | | `--allow-delete` | Permit manifest deletion |
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. **Rejected with `--stdio`** (the SSH remote argv is client-composed, so a client could otherwise pass it and defeat the secure default; operators exposing `fastsync-server --stdio` over SSH must use a forced command if the default must hold). No effect when not root. |
| `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. | | `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. |
| `-v, --verbose` | Enable debug logging | | `-v, --verbose` | Enable debug logging |
| `--help` | Show help | | `--help` | Show help |
@@ -304,6 +305,12 @@ The remote host must have `fastsync-server` available in `PATH`, or use
working directory, so use a destination below that directory unless the working directory, so use a destination below that directory unless the
remote server is otherwise configured with a matching authorized root. remote server is otherwise configured with a matching authorized root.
The remote `--stdio` server argv is composed by the client, so it must never
be trusted to opt a root receiver into super-user activities: `--allow-super`
is rejected with `--stdio` and super stays off on that path. Operators
exposing `fastsync-server --stdio` over SSH must use a forced command (e.g. an
`authorized_keys` `command=` entry) if the default must hold.
```bash ```bash
ssh user@host 'mkdir -p destination' ssh user@host 'mkdir -p destination'
./build/client /path/to/source user@host:destination ./build/client /path/to/source user@host:destination
@@ -498,7 +505,8 @@ link-target transfer remains incomplete. |
| `--ca <path>` | CA file for peer verification. | | `--ca <path>` | CA file for peer verification. |
| `--destination-root <path>` | Confine received files to this server-side root; | `--destination-root <path>` | Confine received files to this server-side root;
defaults to the current directory. | defaults to the current directory. |
| `--allow-delete` | Permit client delete manifests. Deletion is refused by default. | | `--allow-delete` | Permit client delete manifests. Deletion is refused by default. This also gates `--force` (which can recursively replace/remove a destination directory tree). |
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. Rejected with `--stdio` (the SSH remote argv is client-composed; use a forced command if the default must hold). No effect when not root. Daemon modules opt in per module with `client owner = yes`. |
| `-v`, `--verbose` | Enable debug logging. | | `-v`, `--verbose` | Enable debug logging. |
| `--help` | Print server usage. | | `--help` | Print server usage. |
+6 -6
View File
@@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) | | `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | | `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** | | `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates | | `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone TCP listener now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (the flag is **rejected with `--stdio`**, whose remote argv is composed by the client and must never defeat the secure default; operators exposing `fastsync-server --stdio` over SSH need a forced command if the default must hold. An unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front | | `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path | | `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) | | `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
| `--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, 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** | | `--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; a privileged (root) standalone TCP listener refuses it by default too and only honors it after the operator passes `--allow-super` (the flag is rejected with `--stdio`, where the client-composed remote argv could otherwise defeat the default; a forced command is required if the default must hold), 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 (a root standalone listener honors these for its single operator-authorized root only when started with `--allow-super`). `--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.
@@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
- **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time. - **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time.
- **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start. - **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start.
- **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). 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. - **`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 (a root standalone TCP listener honors them for its single operator-authorized root only when started with `--allow-super`; the flag is rejected with `--stdio`, whose client-composed remote argv must never opt back into super mode). 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` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `<store_path>.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay. - **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. 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". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `<store_path>.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay.
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `<store_path>.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there. - **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). 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 legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `<store_path>.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there.
@@ -662,7 +662,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) | | `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way | | `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
| `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation | | `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump | | `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. `--force` is deletion authority and is therefore gated by the server `--allow-delete` policy exactly like `--delete`/`--delete-missing-args`: without it the receiver clears the flag, so a client cannot use `--force` to recursively replace or remove a destination directory tree. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
## 16. Batch Operations ## 16. Batch Operations
@@ -832,9 +832,9 @@ 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. `--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). `--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). A privileged (root) standalone TCP listener instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity (the flag is rejected with `--stdio`, whose client-composed remote argv must never defeat the default; use a forced command if the default must hold); a non-root server is unchanged. 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 (a root standalone TCP listener, which serves one operator-authorized root, honors these requests only when started with `--allow-super`; the flag is rejected with `--stdio`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity. **Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
+60 -6
View File
@@ -37,6 +37,15 @@ static bool allow_unauthenticated;
* root), so no super-user activity is attempted and any client --copy-as is * root), so no super-user activity is attempted and any client --copy-as is
* refused. Set once in main before the accept loop / stdio handler. */ * refused. Set once in main before the accept loop / stdio handler. */
static bool server_no_super; static bool server_no_super;
/* --allow-super: locally-launched standalone TCP opt-in that preserves the
* historical permissive super mode for a root receiver. When false, a
* privileged standalone receiver forces SUPER_MODE_OFF for every connection
* (C3), so a client cannot make it create device nodes / write raw devices /
* apply client-chosen ownership. It is REJECTED for --stdio (the SSH remote
* argv is composed by the client, so it must never be able to opt a root
* receiver back into super mode); the --stdio path always keeps the secure
* default. */
static bool server_allow_super;
static const char* required_client_cn; static const char* required_client_cn;
/* --iconv CONVERT_SPEC the server was itself started with (borrowed argv /* --iconv CONVERT_SPEC the server was itself started with (borrowed argv
* pointer). Its LOCAL half may override the local charset the client assumed; * pointer). Its LOCAL half may override the local charset the client assumed;
@@ -187,13 +196,25 @@ static bool tls_client_identity_allowed(SSL* ssl) {
X509* certificate = SSL_get1_peer_certificate(ssl); X509* certificate = SSL_get1_peer_certificate(ssl);
if (!certificate) if (!certificate)
return false; return false;
char common_name[256];
int length = X509_NAME_get_text_by_NID(X509_get_subject_name(certificate), NID_commonName,
common_name, sizeof(common_name));
size_t required_length = strlen(required_client_cn); size_t required_length = strlen(required_client_cn);
bool allowed = length >= 0 && (size_t)length == required_length && bool allowed = false;
required_length < sizeof(common_name) && X509_NAME* subject = X509_get_subject_name(certificate);
credentials_secure_equal(common_name, required_client_cn, required_length); int index = subject ? X509_NAME_get_index_by_NID(subject, NID_commonName, -1) : -1;
if (index >= 0) {
X509_NAME_ENTRY* entry = X509_NAME_get_entry(subject, index);
ASN1_STRING* data = entry ? X509_NAME_ENTRY_get_data(entry) : NULL;
/* Convert the CN to UTF-8 to get its FULL byte length: unlike
* X509_NAME_get_text_by_NID (which truncates an over-long CN to the buffer
* and reports the truncated length), ASN1_STRING_to_UTF8 never truncates, so
* an exactly-required-length CN is accepted while an over-long one cannot be
* prefix-matched by a shorter required name. */
unsigned char* utf8 = NULL;
int cn_length = data ? ASN1_STRING_to_UTF8(&utf8, data) : -1;
if (cn_length >= 0 && (size_t)cn_length == required_length)
allowed = credentials_secure_equal((const char*)utf8, required_client_cn, required_length);
if (utf8)
OPENSSL_free(utf8);
}
X509_free(certificate); X509_free(certificate);
return allowed; return allowed;
} }
@@ -592,6 +613,22 @@ static const char* server_module_gate(const Config* config, void* context) {
if (gate_ctx) if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF; gate_ctx->super_mode_override = SUPER_MODE_OFF;
} }
/* C3: a privileged (root) STANDALONE receiver defaults to SUPER_MODE_OFF.
* Without this a client --devices/--write-devices/--super would let a root
* server create arbitrary device nodes and write raw devices, and
* client-chosen ownership (--numeric-ids/--chown/--usermap/--groupmap) would
* be applied, with no operator opt-in. The operator must pass --allow-super
* to restore the historical permissive behavior; the flag is rejected for
* --stdio, whose client-composed argv must never defeat this default (an
* operator exposing `fastsync-server --stdio` over SSH needs a forced command
* to keep the permissive behavior). An unprivileged receiver is unaffected
* (the kernel refuses the confined attempts) and the daemon path keeps its
* per-module `client owner = yes` gate. */
if (g_daemon_conf == NULL && geteuid() == 0 && !server_allow_super) {
effective.super_mode = SUPER_MODE_OFF;
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the /* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
ownership of every written entry to the requested ids, which needs a ownership of every written entry to the requested ids, which needs a
privileged (root) receiver. An unprivileged receiver REFUSES the whole privileged (root) receiver. An unprivileged receiver REFUSES the whole
@@ -751,6 +788,13 @@ void handler(int file_descriptor) {
goto done; goto done;
} }
config->use_delete = config->use_delete && allow_delete; config->use_delete = config->use_delete && allow_delete;
/* --force (receiver-side) is deletion authority too: it lets an incoming
* regular file recursively remove a non-empty destination directory tree, and
* lets --delete-missing-args remove a non-empty directory mirror. Without
* the operator's --allow-delete it must be inert, exactly like --delete and
* --delete-missing-args, so a client cannot use --force to bypass the delete
* policy. */
config->force_delete = config->force_delete && allow_delete;
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion /* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
now that the client's full CONVERT_SPEC has been received and validated, now that the client's full CONVERT_SPEC has been received and validated,
before any received file name is decoded. The server's own --iconv (if before any received file name is decoded. The server's own --iconv (if
@@ -1010,6 +1054,13 @@ static void print_server_usage(void) {
printf(" --no-super Operator veto: never attempt super-user activities\n"); printf(" --no-super Operator veto: never attempt super-user activities\n");
printf(" (ownership, device nodes) even as root, and refuse\n"); printf(" (ownership, device nodes) even as root, and refuse\n");
printf(" any client --copy-as/--super request\n"); printf(" any client --copy-as/--super request\n");
printf(" --allow-super Standalone TCP listener only: keep super-user\n");
printf(" activities enabled for a root receiver. Without it a\n");
printf(" root standalone server forces SUPER_MODE_OFF, so client\n");
printf(" --devices/--write-devices/--super and ownership\n");
printf(" requests are refused/skipped. Never honored with\n");
printf(" --stdio (the SSH remote argv is client-composed, so\n");
printf(" super stays off there); no effect when not root\n");
printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n"); printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n");
printf(" conversion: received names are translated to this\n"); printf(" conversion: received names are translated to this\n");
printf(" charset (the wire charset still comes from the\n"); printf(" charset (the wire charset still comes from the\n");
@@ -1134,6 +1185,9 @@ int main(int argc, char* argv[]) {
trust_sender = opts.trust_sender; trust_sender = opts.trust_sender;
allow_unauthenticated = opts.allow_unauthenticated; allow_unauthenticated = opts.allow_unauthenticated;
server_no_super = opts.no_super; server_no_super = opts.no_super;
/* --stdio rejects --allow-super at parse time; force it off here as well so
* this process-global policy cannot be re-enabled by a future caller. */
server_allow_super = opts.allow_super && !opts.stdio_mode;
server_iconv_spec = opts.iconv_spec; server_iconv_spec = opts.iconv_spec;
signal(SIGINT, cleanup); signal(SIGINT, cleanup);
signal(SIGTERM, cleanup); signal(SIGTERM, cleanup);
+24
View File
@@ -179,6 +179,8 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
opts->trust_sender = true; opts->trust_sender = true;
} else if (arg_is(argv[i], "--no-super")) { } else if (arg_is(argv[i], "--no-super")) {
opts->no_super = true; opts->no_super = true;
} else if (arg_is(argv[i], "--allow-super")) {
opts->allow_super = true;
} else if (arg_is(argv[i], "--allow-unauthenticated")) { } else if (arg_is(argv[i], "--allow-unauthenticated")) {
opts->allow_unauthenticated = true; opts->allow_unauthenticated = true;
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) { } else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
@@ -259,6 +261,28 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err,
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio"); set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
return -1; return -1;
} }
if (opts->allow_super && opts->no_super) {
set_error(err, err_size, "--allow-super and --no-super are mutually exclusive");
return -1;
}
if (opts->allow_super && opts->daemon_mode) {
set_error(err, err_size,
"--allow-super is for a locally-launched standalone TCP server; daemon modules opt "
"in per module with 'client owner = yes'");
return -1;
}
/* --stdio is the SSH transport: the remote server argv is composed by the
* CLIENT (directly and via --remote-option), so a client could otherwise pass
* --allow-super to a root --stdio receiver and defeat the C3 secure default.
* Never honor it there; the super mode stays forced OFF. An operator who
* must keep the historical permissive behavior over SSH has to launch the
* receiver through a forced command, not via client-composed argv. */
if (opts->allow_super && opts->stdio_mode) {
set_error(err, err_size,
"--allow-super is not accepted with --stdio (the remote argv is client-composed; "
"use a forced command if the default must hold)");
return -1;
}
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) { if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
set_error(err, err_size, "--iterations requires --hash-credentials"); set_error(err, err_size, "--iterations requires --hash-credentials");
return -1; return -1;
+11
View File
@@ -45,6 +45,17 @@ typedef struct ServerCliOptions {
* device-node creation) even when running as root. Applies to --stdio and * device-node creation) even when running as root. Applies to --stdio and
* --daemon alike; also makes the server refuse any client --copy-as. */ * --daemon alike; also makes the server refuse any client --copy-as. */
bool no_super; /* --no-super */ bool no_super; /* --no-super */
/* --allow-super: locally-launched standalone TCP listener opt-in that keeps
* the historical permissive behavior for a PRIVILEGED (root) receiver.
* Without it a root standalone server forces SUPER_MODE_OFF, so a client
* --devices / --write-devices / --super / ownership request cannot make it
* create device nodes, write raw devices, or apply client-chosen ownership.
* It is rejected for --stdio: that path's remote argv is composed by the
* client (directly and via --remote-option), so it must never opt a root
* receiver back into super mode. Non-root receivers are unaffected (the
* kernel refuses the confined attempts). The daemon path instead uses the
* per-module `client owner = yes` opt-in. */
bool allow_super; /* --allow-super */
/* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The /* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The
* client's full spec rides the wire config frame anyway; when the server is * client's full spec rides the wire config frame anyway; when the server is
* started with its own --iconv, its LOCAL half overrides the local charset * started with its own --iconv, its LOCAL half overrides the local charset
+23 -6
View File
@@ -613,19 +613,36 @@ int config_parse_transport_dest(Config* config) {
int daemon_ret = config_parse_daemon_dest(config); int daemon_ret = config_parse_daemon_dest(config);
if (daemon_ret != 0) if (daemon_ret != 0)
return daemon_ret; return daemon_ret;
config_parse_ssh_dest(config); /* 0 for a local destination (nothing parsed) or a valid SSH destination;
return 0; * -1 (already logged) for an injection-shaped user@host. */
return config_parse_ssh_dest(config);
} }
void config_parse_ssh_dest(Config* config) { int config_parse_ssh_dest(Config* config) {
if (!config || !config->receive_root_directory)
return 0;
if (!config_is_remote_dest(config->receive_root_directory)) if (!config_is_remote_dest(config->receive_root_directory))
return; return 0;
const char* dest = config->receive_root_directory;
const char* colon = strchr(dest, ':');
/* The user@host token is passed to ssh in option position, so a user or host
* beginning with '-' would be consumed by ssh as an option (argument
* injection: e.g. "-oProxyCommand=..."). An empty host is likewise not a
* valid destination. Validate before any wire/argv construction. */
const char* at = memchr(dest, '@', (size_t)(colon - dest));
const char* host = at ? at + 1 : dest;
size_t host_len = (size_t)(colon - host);
size_t user_len = at ? (size_t)(at - dest) : 0;
if (host_len == 0 || host[0] == '-' || (user_len > 0 && dest[0] == '-'))
return daemon_dest_parse_error("invalid remote destination user@host (must not be empty or "
"start with '-')",
dest);
config->transport = TRANSPORT_SSH; config->transport = TRANSPORT_SSH;
config->ssh_destination = str_dup(config->receive_root_directory); config->ssh_destination = str_dup(dest);
const char* colon = strchr(config->receive_root_directory, ':');
char* path = str_dup(colon + 1); char* path = str_dup(colon + 1);
free(config->receive_root_directory); free(config->receive_root_directory);
config->receive_root_directory = path; config->receive_root_directory = path;
return 0;
} }
void config_burn_auth(Config* config) { void config_burn_auth(Config* config) {
+4 -1
View File
@@ -851,7 +851,10 @@ bool config_send(int file_descriptor, const Config* config);
bool config_send_wire_block(int file_descriptor, const Config* config); bool config_send_wire_block(int file_descriptor, const Config* config);
Config* config_receive(int file_descriptor); Config* config_receive(int file_descriptor);
bool config_is_remote_dest(const char* s); bool config_is_remote_dest(const char* s);
void config_parse_ssh_dest(Config* config); /* Parse a single-colon host:path SSH destination (0 = not an SSH destination or
* parsed successfully, -1 = rejected, e.g. a user@host beginning with '-'; the
* reason is logged). */
int config_parse_ssh_dest(Config* config);
/* A ConfigValidateFunc may return this sentinel to tell /* A ConfigValidateFunc may return this sentinel to tell
* config_receive_with_validate that the callback ALREADY sent a terminal status * config_receive_with_validate that the callback ALREADY sent a terminal status
+2 -2
View File
@@ -158,13 +158,13 @@ static int hex_value(char c) {
* digits. Such a line is refused loudly (and never accepted) so an operator * digits. Such a line is refused loudly (and never accepted) so an operator
* cannot keep a replayable bearer digest in place after the protocol bump. */ * cannot keep a replayable bearer digest in place after the protocol bump. */
static bool secret_is_legacy_hex(const char* s) { static bool secret_is_legacy_hex(const char* s) {
if (!s) if (!s || strlen(s) != 64)
return false; return false;
for (int i = 0; i < 64; i++) { for (int i = 0; i < 64; i++) {
if (hex_value(s[i]) < 0) if (hex_value(s[i]) < 0)
return false; return false;
} }
return s[64] == '\0'; return true;
} }
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) { bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz) {
+24 -2
View File
@@ -133,6 +133,7 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false; return false;
} }
char* save = NULL; char* save = NULL;
int added = 0;
for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) { for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) {
if (!host_pattern_valid(token)) { if (!host_pattern_valid(token)) {
if (module_name) if (module_name)
@@ -163,8 +164,20 @@ static bool store_host_list(char*** list, int* count, const char* value, const c
return false; return false;
} }
(*list)[(*count)++] = dup; (*list)[(*count)++] = dup;
added++;
} }
free(copy); free(copy);
/* A present key with an empty (or separator-only) value would otherwise
* install a zero-length list, i.e. no ACL at all: a strict-parse config must
* never silently turn a restrictive directive into "allow everyone". */
if (added == 0) {
if (module_name)
set_error(err, err_size, "module '%s': '%s' must list at least one host pattern", module_name,
key);
else
set_error(err, err_size, "'%s' must list at least one host pattern", key);
return false;
}
return true; return true;
} }
@@ -403,6 +416,7 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false; return false;
} }
char* save = NULL; char* save = NULL;
int added = 0;
for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) { for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) {
const char* user = trim_ws(token); const char* user = trim_ws(token);
if (*user == '\0') if (*user == '\0')
@@ -430,8 +444,16 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
return false; return false;
} }
module->auth_users[module->auth_user_count++] = dup; module->auth_users[module->auth_user_count++] = dup;
added++;
} }
free(list); free(list);
/* An empty/separator-only value must not silently disable authentication:
* the key's presence is an explicit request for an allow-list. */
if (added == 0) {
set_error(err, err_size, "module '%s': 'auth users' must list at least one user",
module->name);
return false;
}
return true; return true;
} }
if (key_equals(key, "max connections")) if (key_equals(key, "max connections"))
@@ -439,10 +461,10 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
"max connections", module->name, err, err_size); "max connections", module->name, err, err_size);
if (key_equals(key, "hosts allow")) if (key_equals(key, "hosts allow"))
return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow", return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow",
false, module->name, err, err_size); module->name, false, err, err_size);
if (key_equals(key, "hosts deny")) if (key_equals(key, "hosts deny"))
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny", return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
false, module->name, err, err_size); module->name, false, err, err_size);
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name); set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
return false; return false;
} }
+19 -3
View File
@@ -75,6 +75,15 @@ static int parse_remote_dest(const char* dest, RemoteDest* r) {
memcpy(r->host, dest, host_len); memcpy(r->host, dest, host_len);
r->host[host_len] = '\0'; r->host[host_len] = '\0';
} }
/* The user@host token is handed to ssh in option position. Reject anything
* that ssh would consume as an option (a leading '-') or an empty host, so a
* crafted destination can never inject an ssh option such as
* -oProxyCommand=... . This mirrors config_parse_ssh_dest's validation and
* is defense-in-depth for callers that bypass it. */
if (r->host[0] == '\0' || r->host[0] == '-' || (r->user[0] != '\0' && r->user[0] == '-')) {
remote_dest_destroy(r);
return -1;
}
return 0; return 0;
} }
@@ -216,10 +225,10 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
nwords = 1; nwords = 1;
} }
/* Fixed tail: three -o pairs (6) + optional -p/value (2) + user@host + /* Fixed tail: three -o pairs (6) + optional -p/value (2) + the "--" end of
* remote command + terminating NULL. */ * options marker + user@host + remote command + terminating NULL. */
int port_extra = (port > 0 && port != 22) ? 2 : 0; int port_extra = (port > 0 && port != 22) ? 2 : 0;
size_t total = (size_t)nwords + 6 + (size_t)port_extra + 3; size_t total = (size_t)nwords + 6 + (size_t)port_extra + 4;
char** argv = calloc(total, sizeof(char*)); char** argv = calloc(total, sizeof(char*));
if (!argv) { if (!argv) {
for (int i = 0; i < nwords; i++) for (int i = 0; i < nwords; i++)
@@ -253,6 +262,13 @@ char** ssh_build_client_argv(const char* rsh_command, int port, const char* user
goto fail_argv; goto fail_argv;
ac++; ac++;
} }
/* End of options: guarantees the user@host token that follows is treated as
* the destination and never re-interpreted as an ssh option, even if every
* caller-side validation were bypassed. */
argv[ac] = str_dup("--");
if (!argv[ac])
goto fail_argv;
ac++;
argv[ac] = str_dup(userhost); argv[ac] = str_dup(userhost);
if (!argv[ac]) if (!argv[ac])
goto fail_argv; goto fail_argv;
+76 -15
View File
@@ -4,7 +4,9 @@
#include "transport_tcp.h" #include "transport_tcp.h"
#include "utils.h" #include "utils.h"
#include <arpa/inet.h> #include <arpa/inet.h>
#include <fcntl.h>
#include <openssl/err.h> #include <openssl/err.h>
#include <openssl/pem.h>
#include <openssl/ssl.h> #include <openssl/ssl.h>
#include <signal.h> #include <signal.h>
#include <stdio.h> #include <stdio.h>
@@ -34,6 +36,59 @@ static void log_ssl_errors(void) {
} }
} }
/* Load the TLS private key through an already-opened, no-follow descriptor so
* the owner/mode policy is checked on the SAME file object that is loaded: an
* attacker cannot swap the path between a stat() and a later open() (TOCTOU).
* The exact-owner / 0600 policy is preserved and group/other execute bits are
* rejected as well. Ownership of the descriptor passes to the BIO and is
* released exactly once by BIO_free() (BIO_CLOSE). */
static bool load_private_key_secure(SSL_CTX* ctx, const char* key) {
int fd = open(key, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to open private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
return false;
}
struct stat key_stat;
if (fstat(fd, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH | S_IXGRP | S_IXOTH))) {
log_message(LOG_LEVEL_ERROR,
"TLS private key must be a regular file owned by the current user and private "
"(mode 0600)");
close(fd);
return false;
}
BIO* bio = BIO_new_fd(fd, BIO_CLOSE);
if (!bio) {
close(fd);
log_message(LOG_LEVEL_ERROR, "Failed to read private key");
return false;
}
EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
BIO_free(bio); /* releases fd via BIO_CLOSE */
if (!pkey) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
int use_ok = SSL_CTX_use_PrivateKey(ctx, pkey);
EVP_PKEY_free(pkey);
if (use_ok != 1) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to use private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
return false;
}
return true;
}
static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key, static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key,
const char* ca_path) { const char* ca_path) {
if (!is_server && !ca_path) { if (!is_server && !ca_path) {
@@ -56,12 +111,21 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#ifdef SSL_OP_NO_RENEGOTIATION #ifdef SSL_OP_NO_RENEGOTIATION
SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION); SSL_CTX_set_options(ctx, SSL_OP_NO_RENEGOTIATION);
#endif #endif
/* Let the server's own preference order decide the negotiated cipher rather
* than the client's, so a client cannot steer both peers into a weaker (but
* still offered) suite. */
SSL_CTX_set_options(ctx, SSL_OP_CIPHER_SERVER_PREFERENCE);
if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) { if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
if (SSL_CTX_set_cipher_list(ctx, "HIGH:!aNULL:!eNULL:!MD5:!RC4:!3DES") != 1) { /* TLS 1.2 and below: an AEAD-only suite list. "HIGH" still includes CBC
* suites (Lucky13/POODLE-adjacent MAC-then-encrypt constructions), so restrict
* the list to ECDHE key agreement with an AEAD record cipher (AES-GCM or
* ChaCha20-Poly1305). A NULL/weak/3DES cipher is never selectable. */
if (SSL_CTX_set_cipher_list(ctx, "ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!eNULL:!MD5:!RC4:!3DES") !=
1) {
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
@@ -79,13 +143,6 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
#endif #endif
if (cert && key) { if (cert && key) {
struct stat key_stat;
if (stat(key, &key_stat) != 0 || !S_ISREG(key_stat.st_mode) || key_stat.st_uid != geteuid() ||
(key_stat.st_mode & (S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH))) {
log_message(LOG_LEVEL_ERROR, "TLS private key must be owned by the current user and private");
SSL_CTX_free(ctx);
return NULL;
}
if (SSL_CTX_use_certificate_file(ctx, cert, SSL_FILETYPE_PEM) <= 0) { if (SSL_CTX_use_certificate_file(ctx, cert, SSL_FILETYPE_PEM) <= 0) {
char* escaped = output_escape(cert, false); char* escaped = output_escape(cert, false);
log_message(LOG_LEVEL_ERROR, "Failed to load certificate: %s", log_message(LOG_LEVEL_ERROR, "Failed to load certificate: %s",
@@ -95,12 +152,7 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
if (SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) <= 0) { if (!load_private_key_secure(ctx, key)) {
char* escaped = output_escape(key, false);
log_message(LOG_LEVEL_ERROR, "Failed to load private key: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
log_ssl_errors();
SSL_CTX_free(ctx); SSL_CTX_free(ctx);
return NULL; return NULL;
} }
@@ -144,7 +196,16 @@ static SSL* wrap_fd_with_ssl(int fd, SSL_CTX* ctx, bool is_server, const char* h
// Enable hostname verification for client connections when a hostname is provided. // Enable hostname verification for client connections when a hostname is provided.
// Must be done before SSL_connect to take effect during the handshake. // Must be done before SSL_connect to take effect during the handshake.
if (!is_server && hostname) { if (!is_server && hostname) {
if (SSL_set1_host(ssl, hostname) != 1) { /* An IP-literal host must be verified against the certificate's IP SAN
* (X509_check_ip_asc), not as a DNS name: SSL_set1_host would look for a
* DNS SAN that a legitimate IP-SAN certificate never carries. */
struct in_addr ipv4;
struct in6_addr ipv6;
bool is_ip_literal =
inet_pton(AF_INET, hostname, &ipv4) == 1 || inet_pton(AF_INET6, hostname, &ipv6) == 1;
int set_ok = is_ip_literal ? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), hostname)
: SSL_set1_host(ssl, hostname);
if (set_ok != 1) {
SSL_free(ssl); SSL_free(ssl);
return NULL; return NULL;
} }
+7 -1
View File
@@ -16,7 +16,13 @@ def shared_server():
Under pytest-xdist this session fixture is instantiated once per worker Under pytest-xdist this session fixture is instantiated once per worker
process, so each worker gets its own server on an ephemeral port.""" process, so each worker gets its own server on an ephemeral port."""
server = ServerManager() server = ServerManager()
server.start() # --allow-super keeps the historical permissive super mode for a root
# receiver: the integration suite's root-only ownership/device/copy-as tests
# exercise that opted-in configuration. The secure default (a root
# standalone server without --allow-super forces SUPER_MODE_OFF) is covered
# explicitly by TestStandaloneSuperDefault in test_features.py. Non-root
# runs are unaffected by the flag.
server.start(extra_args=["--allow-super"])
yield server yield server
server.stop() server.stop()
+82 -2
View File
@@ -1592,8 +1592,33 @@ class TestDelete:
assert not missing, f"Missing: {missing}" assert not missing, f"Missing: {missing}"
assert not mismatches, f"Mismatch: {mismatches}" assert not mismatches, f"Mismatch: {mismatches}"
@pytest.mark.ci
class TestProgress: def test_force_cannot_replace_directory_without_allow_delete(self):
"""C2: --force is deletion authority (an incoming file may recursively
remove a non-empty destination directory tree). A server started without
--allow-delete must clear it, so the operator's delete policy cannot be
bypassed with --force."""
source = os.path.join(TEST_DATA_DIR, "force_src")
dest = os.path.join(TEST_DATA_DIR, "force_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "blocker"), "wb") as f:
f.write(b"incoming file\n")
received = get_dest_received_dir(dest, source)
blocker = os.path.join(received, "blocker")
os.makedirs(blocker)
nested = os.path.join(blocker, "nested.txt")
with open(nested, "w") as f:
f.write("survivor")
# Deliberately NO --allow-delete.
server = ServerManager()
server.start()
try:
run_client(source, dest, flags=["--force"], port=server.port)
finally:
server.stop()
assert os.path.isdir(blocker), "unauthorized --force removed a destination directory"
assert os.path.exists(nested), "unauthorized --force removed a nested file"
def test_progress_output(self, shared_server): def test_progress_output(self, shared_server):
clean_dir(DEST_DIR) clean_dir(DEST_DIR)
result, dur = run_client( result, dur = run_client(
@@ -4630,6 +4655,61 @@ class TestSuperPrivilege:
f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}" f"--no-super must suppress fake-super's owner replay: uid={st.st_uid} gid={st.st_gid}"
class TestStandaloneSuperDefault:
"""C3: a privileged (root) STANDALONE server without --allow-super forces
SUPER_MODE_OFF, so a client cannot make it create device nodes, write raw
devices, apply ownership, or use --copy-as. The shared_server fixture opts in
with --allow-super to keep the historical behavior available to the existing
root-only tests; these tests start their own un-opted server."""
@pytest.mark.ci
def test_copy_as_refused_without_allow_super(self):
"""--copy-as is a client-chosen-ownership request and must be refused by
a standalone server that did not opt in with --allow-super (on a non-root
receiver it is refused for lack of privilege either way)."""
source = os.path.join(TEST_DATA_DIR, "super_default_src")
dest = os.path.join(TEST_DATA_DIR, "super_default_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as f:
f.write(b"no copy-as\n")
server = ServerManager()
server.start() # deliberately no --allow-super
try:
result, _ = run_client(source, dest,
flags=["--preserve", "--copy-as=@65534:@65534"],
port=server.port)
finally:
server.stop()
assert result.returncode != 0, (
"standalone server accepted --copy-as without --allow-super"
)
@pytest.mark.skipif(os.geteuid() != 0, reason="root can create the source device node")
def test_devices_skipped_without_allow_super(self):
"""Root standalone server without --allow-super must skip device-node
creation even for a client --devices request (the run still succeeds and
the regular file transfers)."""
source = os.path.join(TEST_DATA_DIR, "super_default_dev_src")
dest = os.path.join(TEST_DATA_DIR, "super_default_dev_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "plain.txt"), "wb") as f:
f.write(b"regular\n")
os.mknod(os.path.join(source, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3))
server = ServerManager()
server.start() # deliberately no --allow-super
try:
result, _ = run_client(source, dest, flags=["--devices"], port=server.port)
finally:
server.stop()
assert result.returncode == 0, f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
assert not os.path.lexists(os.path.join(received, "null")), (
"root standalone server created a device node without --allow-super"
)
class TestHardLinks: class TestHardLinks:
"""-H/--hard-links: source files sharing an inode are re-created as hard """-H/--hard-links: source files sharing an inode are re-created as hard
links to one another on the destination (dedup preserved, first copy links to one another on the destination (dedup preserved, first copy
+29
View File
@@ -87,6 +87,34 @@ static void test_config_ssh_dest_no_user() {
config_delete(cfg); config_delete(cfg);
} }
/* C1: the user@host token is passed to ssh in option position, so a host or user
* beginning with '-' (e.g. "-oProxyCommand=...") must be rejected before any
* argv is built, and an empty host must be rejected too. */
static void test_config_ssh_dest_rejects_option_injection() {
Config* cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false,
false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
EXPECT_EQ_INT(cfg->transport, TRANSPORT_TCP);
EXPECT_NULL(cfg->ssh_destination);
config_delete(cfg);
cfg =
make_config("1.0", "/src", "-user@host:/dst", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
config_delete(cfg);
cfg = make_config("1.0", "/src", "user@:/dst", true, false, false, false, false, 1, false, 0);
EXPECT_EQ_INT(config_parse_ssh_dest(cfg), -1);
config_delete(cfg);
/* config_parse_transport_dest propagates the rejection (and still returns 1
* for daemon syntax first). */
cfg = make_config("1.0", "/src", "-oProxyCommand=id:/dst", true, false, false, false, false, 1,
false, 0);
EXPECT_EQ_INT(config_parse_transport_dest(cfg), -1);
config_delete(cfg);
}
static void test_config_daemon_dest_parse() { static void test_config_daemon_dest_parse() {
Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false, Config* cfg = make_config("1.0", "/src", "dahost::files/sub/dir", true, false, false, false,
false, 1, false, 0); false, 1, false, 0);
@@ -2789,6 +2817,7 @@ void test_config() {
test_config_ssh_dest(); test_config_ssh_dest();
test_config_ssh_dest_local_path(); test_config_ssh_dest_local_path();
test_config_ssh_dest_no_user(); test_config_ssh_dest_no_user();
test_config_ssh_dest_rejects_option_injection();
test_config_daemon_dest_parse(); test_config_daemon_dest_parse();
test_config_daemon_dest_no_path(); test_config_daemon_dest_no_path();
test_config_daemon_dest_double_slash_normalized(); test_config_daemon_dest_double_slash_normalized();
+29
View File
@@ -473,6 +473,34 @@ static void test_credentials_store_rejects_legacy_hex() {
free(path); free(path);
} }
/* C9: the legacy-hex detector must check the length before indexing 64 bytes, so
* a short secret is never read out of bounds. Such a line is rejected for the
* ordinary "expected verifier" reason, never as legacy. */
static void test_credentials_store_rejects_short_secret() {
char contents[CREDENTIAL_MAX_LINE];
snprintf(contents, sizeof(contents), "alice:%s\n", "abc");
char* path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
char err[512];
const CredentialStore* store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL);
rm_temp(path);
free(path);
char short_hex[64];
memset(short_hex, 'a', 63);
short_hex[63] = '\0';
snprintf(contents, sizeof(contents), "alice:%s\n", short_hex);
path = make_tmp_file(contents);
EXPECT_NOT_NULL(path);
store = credentials_load(path, NULL, err, sizeof(err));
EXPECT_NULL(store);
EXPECT_TRUE(strstr(err, "legacy unsalted") == NULL);
rm_temp(path);
free(path);
}
static void test_credentials_store_duplicate_rejected() { static void test_credentials_store_duplicate_rejected() {
char line[CREDENTIAL_MAX_LINE]; char line[CREDENTIAL_MAX_LINE];
EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line))); EXPECT_TRUE(make_store_line("alice", KAT_PASSWORD, CREDENTIAL_MIN_ITERS, line, sizeof(line)));
@@ -1066,6 +1094,7 @@ void test_credentials(void) {
test_credentials_store_parse_valid(); test_credentials_store_parse_valid();
test_credentials_store_parse_rejects_malformed(); test_credentials_store_parse_rejects_malformed();
test_credentials_store_rejects_legacy_hex(); test_credentials_store_rejects_legacy_hex();
test_credentials_store_rejects_short_secret();
test_credentials_store_duplicate_rejected(); test_credentials_store_duplicate_rejected();
test_credentials_store_rejects_nonuniform_iters(); test_credentials_store_rejects_nonuniform_iters();
test_credentials_store_parse_missing_file(); test_credentials_store_parse_missing_file();
+62 -3
View File
@@ -391,6 +391,22 @@ static void test_daemon_conf_auth_users_validated() {
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice"); EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice");
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob"); EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob");
daemon_conf_free(ok_conf); daemon_conf_free(ok_conf);
/* C4: an empty or separator-only `auth users` value is a parse error. It
* would otherwise leave the module with a zero-length allow-list, silently
* disabling the authentication the operator asked for. */
const char* empty_auth[] = {
"[m]\npath = /x\nauth users = \n",
"[m]\npath = /x\nauth users = , ,\n",
"[m]\npath = /x\nauth users = \t\n",
};
for (size_t i = 0; i < sizeof(empty_auth) / sizeof(empty_auth[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_auth[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "'auth users' must list at least one user") != NULL);
}
} }
/* Wave 3 daemon hardening: configurable global/per-module connection caps, /* Wave 3 daemon hardening: configurable global/per-module connection caps,
@@ -475,12 +491,55 @@ static void test_daemon_conf_limits_and_hosts_parse() {
EXPECT_EQ_INT(conf->modules[0].max_connections, 0); EXPECT_EQ_INT(conf->modules[0].max_connections, 0);
daemon_conf_free(conf); daemon_conf_free(conf);
/* An empty hosts list is not an error (no patterns are added). */ /* C4: a present hosts key with an empty/separator-only value must not silently
EXPECT_EQ_INT(write_conf("hosts allow = \n[m]\npath = /x\n", &path), 0); * install a zero-length (allow-everyone) list. */
const char* empty_hosts[] = {
"hosts allow = \n[m]\npath = /x\n",
"hosts deny = \n[m]\npath = /x\n",
"hosts allow = , ,\n[m]\npath = /x\n",
"hosts deny = \t\n[m]\npath = /x\n",
};
for (size_t i = 0; i < sizeof(empty_hosts) / sizeof(empty_hosts[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_hosts[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL);
}
const char* empty_module_hosts[] = {
"[m]\npath = /x\nhosts allow = \n",
"[m]\npath = /x\nhosts deny = ,\n",
};
for (size_t i = 0; i < sizeof(empty_module_hosts) / sizeof(empty_module_hosts[0]); i++) {
EXPECT_EQ_INT(write_conf(empty_module_hosts[i], &path), 0);
const DaemonConf* rejected = daemon_conf_load(path, err, sizeof(err));
free(path);
EXPECT_NULL(rejected);
EXPECT_TRUE(strstr(err, "must list at least one host pattern") != NULL);
/* The diagnostic must name the offending module. */
EXPECT_TRUE(strstr(err, "module 'm'") != NULL);
}
/* Per-module host lists APPEND across lines like the global ones. (A swapped
* store_host_list call passed the module name as `replace`, so each line
* silently replaced the previous one and only the last survived.) */
EXPECT_EQ_INT(write_conf("[m]\npath = /x\n"
"hosts allow = 127.0.0.1\n"
"hosts allow = 10.0.0.0/8\n"
"hosts deny = 192.168.0.1\n"
"hosts deny = 2001:db8::/32\n",
&path),
0);
conf = daemon_conf_load(path, err, sizeof(err)); conf = daemon_conf_load(path, err, sizeof(err));
free(path); free(path);
EXPECT_NOT_NULL(conf); EXPECT_NOT_NULL(conf);
EXPECT_EQ_INT(conf->global.hosts_allow_count, 0); EXPECT_EQ_INT(conf->modules[0].hosts_allow_count, 2);
EXPECT_EQ_STR(conf->modules[0].hosts_allow[0], "127.0.0.1");
EXPECT_EQ_STR(conf->modules[0].hosts_allow[1], "10.0.0.0/8");
EXPECT_EQ_INT(conf->modules[0].hosts_deny_count, 2);
EXPECT_EQ_STR(conf->modules[0].hosts_deny[0], "192.168.0.1");
EXPECT_EQ_STR(conf->modules[0].hosts_deny[1], "2001:db8::/32");
daemon_conf_free(conf); daemon_conf_free(conf);
} }
+30
View File
@@ -32,6 +32,35 @@ static void test_server_cli_defaults() {
EXPECT_FALSE(opts.allow_delete); EXPECT_FALSE(opts.allow_delete);
EXPECT_FALSE(opts.allow_unauthenticated); EXPECT_FALSE(opts.allow_unauthenticated);
EXPECT_FALSE(opts.no_super); EXPECT_FALSE(opts.no_super);
EXPECT_FALSE(opts.allow_super);
server_cli_options_free(&opts);
}
/* C3: --allow-super is the locally-launched standalone TCP opt-in for a
* privileged receiver; it never combines with --no-super, is refused with
* --stdio (whose client-composed remote argv must not defeat the default), and
* daemon modules use their own per-module `client owner = yes` opt-in instead. */
static void test_server_cli_allow_super() {
const char* args[] = {"fastsync-server", "--allow-super", "--destination-root", "/srv"};
ServerCliOptions opts;
EXPECT_EQ_INT(parse_ok(args, 4, &opts), 0);
EXPECT_TRUE(opts.allow_super);
server_cli_options_free(&opts);
char err[256];
const char* a1[] = {"s", "--allow-super", "--no-super"};
EXPECT_EQ_INT(server_cli_parse(3, (char**)a1, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "mutually exclusive") != NULL);
const char* a2[] = {"s", "--daemon", "--config=/tmp/x.conf", "--allow-super"};
EXPECT_EQ_INT(server_cli_parse(4, (char**)a2, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "client owner") != NULL);
/* The SSH/--stdio receiver argv is composed by the client, so --allow-super
* must be rejected there and the C3 secure default stays in force. */
const char* a3[] = {"s", "--stdio", "--allow-super", "--destination-root", "/srv"};
EXPECT_EQ_INT(server_cli_parse(5, (char**)a3, &opts, err, sizeof(err)), -1);
EXPECT_TRUE(strstr(err, "--stdio") != NULL);
server_cli_options_free(&opts); server_cli_options_free(&opts);
} }
@@ -224,5 +253,6 @@ void test_server_cli() {
test_server_cli_password_and_early_input(); test_server_cli_password_and_early_input();
test_server_cli_password_requires_daemon(); test_server_cli_password_requires_daemon();
test_server_cli_no_super(); test_server_cli_no_super();
test_server_cli_allow_super();
test_server_cli_help(); test_server_cli_help();
} }
+29 -10
View File
@@ -66,16 +66,18 @@ static void test_ssh_remote_command_argument_modes() {
free(command); free(command);
} }
/* The build for a single-word argv is [prog, six -o args, user, command]. */ /* The build for a single-word argv is [prog, six -o args, "--", user, command]. */
static void test_ssh_build_client_argv_default_is_ssh() { static void test_ssh_build_client_argv_default_is_ssh() {
char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio"); char** argv = ssh_build_client_argv(NULL, 0, "u@h", "'srv' --stdio");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-o"); EXPECT_EQ_STR(argv[1], "-o");
EXPECT_EQ_STR(argv[7], "u@h"); /* The "--" end-of-options marker precedes the destination token. */
EXPECT_EQ_STR(argv[8], "'srv' --stdio"); EXPECT_EQ_STR(argv[7], "--");
EXPECT_NULL(argv[9]); EXPECT_EQ_STR(argv[8], "u@h");
EXPECT_EQ_STR(argv[9], "'srv' --stdio");
EXPECT_NULL(argv[10]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
@@ -84,7 +86,7 @@ static void test_ssh_build_client_argv_uses_custom_rsh() {
char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc"); char** argv = ssh_build_client_argv("myrsh", 0, "u@h", "rc");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "myrsh"); EXPECT_EQ_STR(argv[0], "myrsh");
EXPECT_NULL(argv[9]); EXPECT_NULL(argv[10]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
@@ -96,21 +98,37 @@ static void test_ssh_build_client_argv_whitespace_command_and_port() {
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
EXPECT_EQ_STR(argv[1], "-p"); EXPECT_EQ_STR(argv[1], "-p");
EXPECT_EQ_STR(argv[2], "2222"); EXPECT_EQ_STR(argv[2], "2222");
EXPECT_NULL(argv[11]); EXPECT_NULL(argv[12]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc"); argv = ssh_build_client_argv("ssh", 2222, "u@h", "rc");
EXPECT_NOT_NULL(argv); EXPECT_NOT_NULL(argv);
EXPECT_EQ_STR(argv[0], "ssh"); EXPECT_EQ_STR(argv[0], "ssh");
/* Flat [prog, -o x6, -p, port, user, command]. */ /* Flat [prog, -o x6, -p, port, "--", user, command]. */
EXPECT_EQ_STR(argv[7], "-p"); EXPECT_EQ_STR(argv[7], "-p");
EXPECT_EQ_STR(argv[8], "2222"); EXPECT_EQ_STR(argv[8], "2222");
EXPECT_EQ_STR(argv[9], "u@h"); EXPECT_EQ_STR(argv[9], "--");
EXPECT_EQ_STR(argv[10], "rc"); EXPECT_EQ_STR(argv[10], "u@h");
EXPECT_NULL(argv[11]); EXPECT_EQ_STR(argv[11], "rc");
EXPECT_NULL(argv[12]);
ssh_free_client_argv(argv); ssh_free_client_argv(argv);
} }
/* C1: a destination host/user beginning with '-' would be parsed by ssh as an
* option (argument injection: -oProxyCommand=...), and an empty host is never
* valid. These are refused before any child is forked, so no Client is
* returned and no command can run. */
static void test_ssh_connect_rejects_option_host() {
/* cppcheck-suppress constVariablePointer */
Client* client = client_connect_ssh("-oProxyCommand=touch /tmp/pwned:/remote", 22, NULL, false,
NULL, false, NULL, 0);
EXPECT_NULL(client);
client = client_connect_ssh("-evil:/remote", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
client = client_connect_ssh("user@:/remote", 22, NULL, false, NULL, false, NULL, 0);
EXPECT_NULL(client);
}
/* --remote-option=OPT appends OPT to the remote command line after " --stdio", /* --remote-option=OPT appends OPT to the remote command line after " --stdio",
* each escaped as its own single-quoted shell word. Metacharacters that could * each escaped as its own single-quoted shell word. Metacharacters that could
* break out of the quoting are neutralized (never injected), matching the * break out of the quoting are neutralized (never injected), matching the
@@ -162,6 +180,7 @@ void test_transport_ssh() {
test_ssh_connect_invalid_dest_empty(); test_ssh_connect_invalid_dest_empty();
test_ssh_connect_malformed(); test_ssh_connect_malformed();
test_ssh_connect_unreachable(); test_ssh_connect_unreachable();
test_ssh_connect_rejects_option_host();
test_ssh_remote_command_argument_modes(); test_ssh_remote_command_argument_modes();
test_ssh_build_client_argv_default_is_ssh(); test_ssh_build_client_argv_default_is_ssh();
test_ssh_build_client_argv_uses_custom_rsh(); test_ssh_build_client_argv_uses_custom_rsh();
+11
View File
@@ -24,6 +24,17 @@ static void test_server_create_tls_without_certs() {
#ifdef SSL_OP_NO_RENEGOTIATION #ifdef SSL_OP_NO_RENEGOTIATION
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0); EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_NO_RENEGOTIATION) != 0);
#endif #endif
/* C5: the server's preference order decides the cipher and the TLS 1.2 list is
* AEAD-only (no CBC/RC4/3DES legacy suites). */
EXPECT_TRUE((SSL_CTX_get_options(ctx) & SSL_OP_CIPHER_SERVER_PREFERENCE) != 0);
STACK_OF(SSL_CIPHER)* ciphers = SSL_CTX_get_ciphers(ctx);
EXPECT_NOT_NULL(ciphers);
for (int i = 0; i < sk_SSL_CIPHER_num(ciphers); i++) {
const char* name = SSL_CIPHER_get_name(sk_SSL_CIPHER_value(ciphers, i));
EXPECT_TRUE(name != NULL && strstr(name, "CBC") == NULL);
EXPECT_TRUE(name != NULL && strstr(name, "RC4") == NULL);
EXPECT_TRUE(name != NULL && strstr(name, "3DES") == NULL);
}
server_delete(&s); server_delete(&s);
EXPECT_NULL(s); EXPECT_NULL(s);
} }