From 54d794e8deb1e7629921f91813139b05c807a48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Sun, 16 Aug 2026 03:53:42 +0200 Subject: [PATCH] fix(docker): chmod the settings temp file before chowning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to searxng:searxng, so every retained settings file belongs to that user by the second boot. Chowning the temporary file first left root unable to chmod it, so the migration exited 1 and `set -eu` killed the container before `exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus blocked on its healthcheck. Swap the two calls so the chmod lands while the temporary file is still root-owned, and cover the ordering with a test that refuses the chmod once the chown has happened, the way the kernel does. --- scripts/migrate_searxng_settings.py | 7 ++++- tests/test_searxng_settings_migration.py | 38 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_searxng_settings.py b/scripts/migrate_searxng_settings.py index 9eceba520..4b58e2efc 100644 --- a/scripts/migrate_searxng_settings.py +++ b/scripts/migrate_searxng_settings.py @@ -120,8 +120,13 @@ def migrate_settings(path: Path) -> bool: ) temporary = Path(temporary_name) try: - os.fchown(fd, source_stat.st_uid, source_stat.st_gid) + # chmod before chown: the Compose cap set is `cap_drop: ALL` plus + # CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary + # file belongs to searxng:searxng — which every retained settings file + # does, because searxng's entrypoint chowns /etc/searxng — root can no + # longer chmod it and the migration dies with EPERM. os.fchmod(fd, stat.S_IMODE(source_stat.st_mode)) + os.fchown(fd, source_stat.st_uid, source_stat.st_gid) with os.fdopen(fd, "wb") as handle: fd = -1 handle.write(updated) diff --git a/tests/test_searxng_settings_migration.py b/tests/test_searxng_settings_migration.py index 1453e5b2a..208b57ba9 100644 --- a/tests/test_searxng_settings_migration.py +++ b/tests/test_searxng_settings_migration.py @@ -245,6 +245,44 @@ def test_invalid_utf8_is_not_replaced(tmp_path): assert after.st_ino == before.st_ino +def test_temporary_file_is_chmodded_before_it_is_chowned(tmp_path, monkeypatch): + # The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/ + # DAC_OVERRIDE and carries no FOWNER, and searxng's entrypoint chowns + # /etc/searxng to searxng:searxng, so every retained settings file is owned + # by that user. Chowning the temporary file first therefore makes the chmod + # that follows fail with EPERM, and `set -eu` in the Compose entrypoint + # turns that into a container that never starts. The guard below refuses + # the chmod once the chown has landed, the way the kernel does. + migration = _load_migration_module() + settings = tmp_path / "settings.yml" + settings.write_bytes(b"server:\n secret_key: retained\n") + settings.chmod(0o640) + calls = [] + real_fchmod = migration.os.fchmod + real_fchown = migration.os.fchown + + def guarded_fchmod(fd, mode): + if "fchown" in calls: + raise PermissionError(1, "Operation not permitted") + calls.append("fchmod") + return real_fchmod(fd, mode) + + def recording_fchown(fd, uid, gid): + calls.append("fchown") + return real_fchown(fd, uid, gid) + + monkeypatch.setattr(migration.os, "fchmod", guarded_fchmod) + monkeypatch.setattr(migration.os, "fchown", recording_fchown) + + assert migration.migrate_settings(settings) is True + + assert calls == ["fchmod", "fchown"] + assert stat.S_IMODE(settings.stat().st_mode) == 0o640 + assert settings.read_bytes() == ( + b"use_default_settings: true\nserver:\n secret_key: retained\n" + ) + + def test_replace_failure_preserves_original_and_removes_temporary_file( tmp_path, monkeypatch ):