fix(docker): chmod the settings temp file before chowning it

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.
This commit is contained in:
Léo
2026-08-16 03:53:42 +02:00
parent 3cd6cdb638
commit 54d794e8de
2 changed files with 44 additions and 1 deletions
+6 -1
View File
@@ -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)
+38
View File
@@ -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
):