Run the long-lived process as the configured non-root user (#31)

* Run the long-lived process as the configured non-root user

start.py used to stay root for its whole life; only the individual
seaf-cli/seaf-daemon subprocess calls were unprivileged, via a per-call
runuser wrapper. The orchestrating Python process itself, including the
RPC socket to seaf-daemon and the watch_status() loop, ran as root the
entire time.

main() now drops privileges in-process (dsc.misc.drop_privileges, using
os.initgroups/setgid/setuid) right after setup_uid()/create_dir() -
which still need root to remap the seafile user's UID/GID and chown
/dsc - and before SeafileClient is constructed. Since the process is
already the target user by the time any seaf-cli call happens, runuser
is removed from user_cmd(); it also could not be invoked from an
already non-root process.

Verified against the real image, not just mocked unit tests: a running
container's PID 1 shows Uid: 1000 1000 1000 1000 in /proc/1/status, and
tests/smoke.sh gained a check exercising setup_uid/create_dir/
drop_privileges together against the image's real /etc/passwd.

* Isolate os.environ mutation in drop_privileges test

drop_privileges() writes HOME/USER/LOGNAME directly to os.environ, and
the test asserting that didn't restore them, leaking state into later
tests. monkeypatch.delenv registers the keys so its teardown restores
them regardless of who wrote to os.environ.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
This commit is contained in:
Maksim ShulgaandMaxim Shulga authored and GitHub committed 2026-08-03 17:44:34 +00:00
1 parent b0dfc1ad74
commit 6d32460b13
5 files changed
+94 -14

No files matched your search

+44 -5
View File
@@ -2,15 +2,15 @@
import argparse
import json
import os
import subprocess
import pytest
from dsc import client as client_module
from dsc import const
from dsc.errors import ConfigError, DaemonError
from dsc.client import SeafileClient
from dsc.misc import hide_secrets, user_cmd
from dsc.misc import hide_secrets, user_cmd, drop_privileges
# Values that would be interpreted by a shell if a command were ever joined
# into a string and handed to ``su -c``.
@@ -56,9 +56,11 @@ def calls(monkeypatch):
return recorded
def test_user_cmd_uses_runuser_without_a_shell():
def test_user_cmd_returns_the_command_unchanged():
# The process itself runs as the seafile user after drop_privileges(),
# so no per-call re-targeting is needed any more.
argv = user_cmd(["seaf-cli", "status"])
assert argv == ["runuser", "-u", const.DEFAULT_USERNAME, "--", "seaf-cli", "status"]
assert argv == ["seaf-cli", "status"]
def test_user_cmd_never_invokes_a_shell():
@@ -74,6 +76,43 @@ def test_user_cmd_rejects_a_string():
user_cmd("seaf-cli status")
class FakePwInfo:
pw_dir = "/dsc"
@pytest.fixture
def fake_pwnam(monkeypatch):
monkeypatch.setattr("pwd.getpwnam", lambda name: FakePwInfo())
def test_drop_privileges_sets_groups_before_gid_before_uid(monkeypatch, fake_pwnam):
"""Order matters: root is needed for each step, and is gone after setuid."""
calls = []
monkeypatch.setattr("os.initgroups", lambda user, gid: calls.append(("initgroups", user, gid)))
monkeypatch.setattr("os.setgid", lambda gid: calls.append(("setgid", gid)))
monkeypatch.setattr("os.setuid", lambda uid: calls.append(("setuid", uid)))
drop_privileges(1000, 1000)
assert [c[0] for c in calls] == ["initgroups", "setgid", "setuid"]
def test_drop_privileges_sets_the_target_environment(monkeypatch, fake_pwnam):
monkeypatch.setattr("os.initgroups", lambda user, gid: None)
monkeypatch.setattr("os.setgid", lambda gid: None)
monkeypatch.setattr("os.setuid", lambda uid: None)
# drop_privileges() writes os.environ directly, not through monkeypatch,
# so register the keys with monkeypatch first to have it restore them.
for key in ("HOME", "USER", "LOGNAME"):
monkeypatch.delenv(key, raising=False)
drop_privileges(1000, 1000)
assert os.environ["USER"] == "seafile"
assert os.environ["LOGNAME"] == "seafile"
assert os.environ["HOME"]
@pytest.mark.parametrize("secret", SHELL_METACHARACTERS)
def test_sync_passes_the_token_as_one_unquoted_argument(client, calls, secret):
client._SeafileClient__token = secret
@@ -154,7 +193,7 @@ def test_configure_passes_option_values_as_arguments(client, calls, monkeypatch)
client.configure(args, check_for_daemon=False)
argvs = [argv for argv, _ in calls]
prefix = ["runuser", "-u", const.DEFAULT_USERNAME, "--", "seaf-cli", "config"]
prefix = ["seaf-cli", "config"]
assert prefix + ["-k", "upload_limit"] in argvs
assert prefix + ["-k", "upload_limit", "-v", "100"] in argvs
# An option that seaf-cli does not know about is never forwarded.