diff --git a/archivebox/config/permissions.py b/archivebox/config/permissions.py index c26c005c..c374ee09 100644 --- a/archivebox/config/permissions.py +++ b/archivebox/config/permissions.py @@ -4,6 +4,7 @@ import os import platform import pwd import socket +import stat import subprocess import sys from contextlib import contextmanager, suppress @@ -164,6 +165,8 @@ ROOT_HANDOFF_NAMES = ( "index.sqlite3-wal", "archive", "cache", + "custom_plugins", + "custom_templates", "lib", "logs", "personas", @@ -208,6 +211,16 @@ def root_should_handoff_data_dir( return is_root and account_uid is not None and (not data_dir_owner_exists or data_dir_uid in (0, account_uid)) +def root_parent_can_grant_group_traversal(*, parent_uid: int, parent_gid: int, parent_mode: int, account_gid: int) -> bool: + """Return whether adding archivebox traversal preserves existing parent access.""" + + if parent_uid != 0 or parent_mode & stat.S_IXOTH: + return False + if parent_gid == account_gid: + return not bool(parent_mode & stat.S_IXGRP) + return not bool(parent_mode & stat.S_IRWXG) + + def handoff_root_owned_data_dir() -> None: account_uid = ARCHIVEBOX_ACCOUNT.pw_uid if ARCHIVEBOX_ACCOUNT is not None else None if not root_should_handoff_data_dir( @@ -218,7 +231,27 @@ def handoff_root_owned_data_dir() -> None: ): return - for path in root_data_dir_handoff_paths(DATA_DIR, sys.argv): + handoff_paths = root_data_dir_handoff_paths(DATA_DIR, sys.argv) + if not handoff_paths: + return + + # A root user's collection commonly lives below /root, which the archivebox + # account cannot traverse after EUID is dropped. Grant only that group the + # missing execute bit on root-owned parents; never walk collection contents. + for parent in DATA_DIR.resolve().parents: + if parent == Path("/"): + continue + parent_stat = parent.stat(follow_symlinks=False) + if root_parent_can_grant_group_traversal( + parent_uid=parent_stat.st_uid, + parent_gid=parent_stat.st_gid, + parent_mode=parent_stat.st_mode, + account_gid=ARCHIVEBOX_ACCOUNT.pw_gid, + ): + os.chown(parent, -1, ARCHIVEBOX_ACCOUNT.pw_gid, follow_symlinks=False) + os.chmod(parent, stat.S_IMODE(parent_stat.st_mode) | stat.S_IXGRP, follow_symlinks=False) + + for path in handoff_paths: try: os.chown(path, ARCHIVEBOX_ACCOUNT.pw_uid, ARCHIVEBOX_ACCOUNT.pw_gid, follow_symlinks=False) except (FileNotFoundError, PermissionError): diff --git a/archivebox/tests/test_permissions.py b/archivebox/tests/test_permissions.py index a07b79f6..790d984a 100644 --- a/archivebox/tests/test_permissions.py +++ b/archivebox/tests/test_permissions.py @@ -1,6 +1,11 @@ from pathlib import Path -from archivebox.config.permissions import is_root_identity, root_should_handoff_data_dir, select_archivebox_user +from archivebox.config.permissions import ( + is_root_identity, + root_parent_can_grant_group_traversal, + root_should_handoff_data_dir, + select_archivebox_user, +) def test_root_identity_includes_real_or_effective_root(): @@ -115,16 +120,20 @@ def test_existing_collection_handoff_is_bounded_to_known_top_level_paths(tmp_pat database = tmp_path / "index.sqlite3" archive = tmp_path / "archive" + custom_plugins = tmp_path / "custom_plugins" + custom_templates = tmp_path / "custom_templates" nested = archive / "large-existing-snapshot" errors_log = tmp_path / "logs" / "errors.log" database.touch() nested.mkdir(parents=True) + custom_plugins.mkdir() + custom_templates.mkdir() errors_log.parent.mkdir() errors_log.touch() paths = root_data_dir_handoff_paths(tmp_path, ["archivebox", "status"]) - assert paths == (tmp_path, database, archive, errors_log.parent, errors_log) + assert paths == (tmp_path, database, archive, custom_plugins, custom_templates, errors_log.parent, errors_log) assert nested not in paths assert all(path == tmp_path or path.parent in (tmp_path, errors_log.parent) for path in paths) @@ -139,3 +148,11 @@ def test_root_handoff_never_selects_filesystem_root(): from archivebox.config.permissions import root_data_dir_handoff_paths assert root_data_dir_handoff_paths(Path("/"), ["archivebox", "init"]) == () + + +def test_root_private_parent_grants_only_archivebox_group_traversal(): + assert root_parent_can_grant_group_traversal(parent_uid=0, parent_gid=0, parent_mode=0o700, account_gid=911) + assert not root_parent_can_grant_group_traversal(parent_uid=0, parent_gid=0, parent_mode=0o701, account_gid=911) + assert not root_parent_can_grant_group_traversal(parent_uid=0, parent_gid=911, parent_mode=0o710, account_gid=911) + assert not root_parent_can_grant_group_traversal(parent_uid=0, parent_gid=100, parent_mode=0o750, account_gid=911) + assert not root_parent_can_grant_group_traversal(parent_uid=1000, parent_gid=1000, parent_mode=0o700, account_gid=911)