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

- H3: a daemon module without 'client owner = yes' now also has super-user
  device activity forced off (char/block mknod, --write-devices), so a root
  daemon can no longer be made to create/write raw devices under AUTO.  The
  entries are skipped, preserving ordinary -a pushes.
- H1/H2: propagate a failed required --copy-as chown from symlink metadata
  restore and implicitly-created parent directories, so the entry (and run)
  reports failure instead of a wrong-owner success.
- Docs/help/headers updated for A2/A3 and the device clamp; startup warning
  spells out the client-owner risk.
- Tests: daemon device clamp (skipped without opt-in, created with opt-in),
  updated --super/--fake-super expectations.
This commit is contained in:
2026-09-12 14:18:03 +02:00
parent e3840c8326
commit b216ed31fb
11 changed files with 123 additions and 38 deletions
+54
View File
@@ -16,8 +16,10 @@ import hashlib
import os
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import time
import pytest
@@ -244,6 +246,21 @@ def _tree_file_count(root):
return sum(len(files) for _, _, files in os.walk(root)) if os.path.exists(root) else 0
def _can_mknod():
"""True when this process may create a char device (needs root/CAP_MKNOD)."""
probe = os.path.join(tempfile.gettempdir(), "._fastsync_mknod_probe_%d" % os.getpid())
try:
os.mknod(probe, stat.S_IFCHR | 0o600, os.makedev(1, 3))
os.unlink(probe)
return True
except (OSError, AttributeError):
try:
os.unlink(probe)
except OSError:
pass
return False
class TestDaemonModuleSelection:
@pytest.mark.ci
def test_module_transfer(self, daemon):
@@ -382,6 +399,43 @@ class TestDaemonRejection:
assert not missing, f"missing: {missing[:5]}"
assert not mismatches, f"mismatch: {mismatches[:5]}"
def _device_source(self, name):
src = os.path.join(TEST_DATA_DIR, name)
shutil.rmtree(src, ignore_errors=True)
os.makedirs(src)
with open(os.path.join(src, "f.txt"), "wb") as fh:
fh.write(b"device gate\n")
os.mknod(os.path.join(src, "null"), stat.S_IFCHR | 0o666, os.makedev(1, 3))
return src
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
def test_devices_skipped_without_owner_opt_in(self, daemon):
"""H3: a non-opted daemon module must not create device nodes even under
the default AUTO super mode (a root daemon would otherwise let any client
mknod arbitrary devices). An ordinary -a push still succeeds; the device
entry is skipped."""
src = self._device_source("devsrc_noowner")
os.makedirs(os.path.join(FILES_MODULE, "devskip"), exist_ok=True)
result, _ = run_client(src, "127.0.0.1::files/devskip", port=daemon.port, flags=["-a"])
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(os.path.join(FILES_MODULE, "devskip"), src)
node = os.path.join(received, "null")
assert not os.path.exists(node) or not stat.S_ISCHR(os.stat(node).st_mode), \
"non-opted daemon module created a device node"
@pytest.mark.skipif(not _can_mknod(), reason="device nodes need root/CAP_MKNOD")
def test_devices_created_with_owner_opt_in(self, daemon):
"""Control: an opted-in module (`client owner = yes`) may create device
nodes under -a, proving the clamp is specific to non-opted modules."""
src = self._device_source("devsrc_owner")
os.makedirs(os.path.join(OWNER_MODULE, "devok"), exist_ok=True)
result, _ = run_client(src, "127.0.0.1::owner/devok", port=daemon.port, flags=["-a"])
assert result.returncode == 0, result.stderr or result.stdout
received = get_dest_received_dir(os.path.join(OWNER_MODULE, "devok"), src)
node = os.path.join(received, "null")
assert os.path.exists(node) and stat.S_ISCHR(os.stat(node).st_mode), \
"opted-in daemon module did not create the device node"
@pytest.mark.daemon_detach
def test_real_detach_path(self):
"""--daemon WITHOUT --no-detach double-forks a real background daemon;