fix(p8-security): enforce daemon ownership policy, gate fake-super replay, drop implicit numeric-ids, make copy-as failures per-entry

A1: daemon refuses every client-chosen ownership/super-user request
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/--copy-as/--super)
unless the selected module opts in with 'client owner = yes'.
A2: fake-super owner replay requires an explicit ownership identity policy.
A3: --super no longer implies --numeric-ids (ownership stays opt-in).
A5: a failed --copy-as chown marks the entry failed instead of reporting
success with the wrong owner.
This commit is contained in:
2026-09-12 14:00:55 +02:00
parent 58409cee10
commit e3840c8326
15 changed files with 337 additions and 142 deletions
+53 -34
View File
@@ -41,6 +41,7 @@ FILES_MODULE = os.path.join(MODULE_ROOT, "files")
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
OWNER_MODULE = os.path.join(MODULE_ROOT, "owner")
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
@@ -149,7 +150,8 @@ def _config_port(config_path):
@pytest.fixture(scope="module", autouse=True)
def daemon_env():
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, DETACH_MODULE):
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
DETACH_MODULE):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(d, exist_ok=True)
generate_test_files(SOURCE_DIR, full=False)
@@ -184,7 +186,11 @@ def daemon_env():
"[team]\n"
"path = %s\n"
"auth users = alice,bob\n"
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE))
"\n"
"[owner]\n"
"path = %s\n"
"client owner = yes\n"
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE))
# A dedicated config for the fail-closed startup check: an auth-required
# module with no credential store must refuse to start. Its own free port
@@ -320,49 +326,62 @@ class TestDaemonRejection:
assert result.returncode != 0
assert _tree_file_count(AUTH_MODULE) == 0
def test_copy_as_refused_by_daemon(self, daemon):
"""P7 Wave E: a daemon refuses client-chosen ownership (--copy-as)
outright. There is no per-module opt-in, so even a root daemon must not
honor an arbitrary client-selected owner. The refusal happens at the
config handshake, before any data lands."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
before_files = self._tree_files()
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
flags=["--copy-as=@65534:@65534"])
assert result.returncode != 0, "the daemon must refuse --copy-as"
assert self._tree_files() == before_files, \
"--copy-as refusal wrote under the module root"
time.sleep(0.3)
with open(log_path, "rb") as f:
f.seek(before)
tail = f.read().decode("utf-8", "replace")
assert "copy-as is refused by the daemon" in tail, (
f"daemon did not log the copy-as refusal: {tail[-400:]!r}"
)
def test_super_refused_by_daemon(self, daemon):
"""P7 Wave E: --super (SUPER_MODE_ON) implies raw numeric-id ownership
with no explicit identity flag, so a daemon refuses it for the same
reason it refuses --copy-as: there is no per-module opt-in for
client-chosen ownership. The refusal happens at the config handshake,
def _assert_ownership_refused(self, daemon, module, flags):
"""A daemon module without `client owner = yes` refuses every
client-chosen ownership / super-user request at the config handshake,
before any data lands."""
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
before_files = self._tree_files()
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
flags=["--super", "--preserve"])
assert result.returncode != 0, "the daemon must refuse --super"
result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags)
assert result.returncode != 0, f"the daemon must refuse {flags}"
assert self._tree_files() == before_files, \
"--super refusal wrote under the module root"
f"{flags} refusal wrote under the module root"
time.sleep(0.3)
with open(log_path, "rb") as f:
f.seek(before)
tail = f.read().decode("utf-8", "replace")
assert "super is refused by the daemon" in tail, (
f"daemon did not log the --super refusal: {tail[-400:]!r}"
assert "client-chosen ownership" in tail, (
f"daemon did not log the ownership refusal: {tail[-400:]!r}"
)
def test_copy_as_refused_by_daemon(self, daemon):
"""P7 Wave E hardening: a daemon refuses client-chosen ownership
(--copy-as) outright unless the module opts in with `client owner = yes`,
so even a root daemon must not honor an arbitrary client-selected owner
by default. The refusal happens at the config handshake, before any data
lands."""
self._assert_ownership_refused(daemon, "files", ["--copy-as=@65534:@65534"])
def test_super_refused_by_daemon(self, daemon):
"""An explicit --super is a super-user activity request, so a daemon
module refuses it unless it opts in with `client owner = yes`. The
refusal happens at the config handshake, before any data lands."""
self._assert_ownership_refused(daemon, "files", ["--super", "--preserve"])
def test_numeric_ids_refused_by_daemon(self, daemon):
"""P7 Wave E hardening (A1): the daemon ownership gate must cover the
pre-existing identity flags too, not only --copy-as/--super. A module
without `client owner = yes` refuses --numeric-ids at the handshake."""
self._assert_ownership_refused(daemon, "files", ["--numeric-ids", "--preserve"])
def test_chown_refused_by_daemon(self, daemon):
"""--chown is client-chosen ownership too and must be refused by a
non-opted-in module."""
self._assert_ownership_refused(daemon, "files", ["--chown=@65534:@65534", "--preserve"])
def test_owner_opt_in_allows_numeric_ids(self, daemon):
"""A module that opts in with `client owner = yes` accepts the
client-chosen ownership flags (here --numeric-ids); the transfer
succeeds and lands inside that module root."""
result, _ = run_client(SOURCE_DIR, "127.0.0.1::owner", port=daemon.port,
flags=["--numeric-ids", "--preserve"])
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(OWNER_MODULE, SOURCE_DIR)
mismatches, missing = verify_transfer(SOURCE_DIR, received)
assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}"
@pytest.mark.daemon_detach
def test_real_detach_path(self):
"""--daemon WITHOUT --no-detach double-forks a real background daemon;