diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run index 5c14ae564a..ed94fdefa6 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate/run @@ -14,11 +14,17 @@ fi # Tell S6-Overlay not to restart this service s6-svc -O . -# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages +# The helper runs as root (-I skips user site-packages). The probe runs as the +# service user when the service drops privileges; a root probe relies on +# resolve_ffmpeg_path refusing runtime-user-writable paths. function set_libva_version() { local ffmpeg_path - ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py) - LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") + local probe_as=() + if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then + probe_as=(s6-setuidgid frigate) + fi + ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py frigate) + LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") export LIBAVFORMAT_VERSION_MAJOR } diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run index 69ef70ae1d..df5055aa33 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc/run @@ -57,10 +57,19 @@ function get_ip_and_port_from_supervisor() { export FRIGATE_GO2RTC_WEBRTC_CANDIDATE_INTERNAL="${ip_address}:${webrtc_port}" } +# The probe runs as the service user when the service drops privileges; a root +# probe relies on resolve_ffmpeg_path refusing runtime-user-writable paths function set_libva_version() { local ffmpeg_path - ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py) - LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+") + local probe_as=() + if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then + probe_as=(s6-setuidgid go2rtc) + fi + ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py go2rtc) + if ! LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+"); then + echo "[WARN] Could not read the libavformat version from ${ffmpeg_path} as the go2rtc user; using the bundled build's version" + LIBAVFORMAT_VERSION_MAJOR=$("${probe_as[@]}" "/usr/lib/ffmpeg/${DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" -version | grep -Po "libavformat\W+\K\d+") + fi export LIBAVFORMAT_VERSION_MAJOR } diff --git a/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py index 9f4d08f2e3..a190387854 100644 --- a/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py +++ b/docker/main/rootfs/usr/local/ffmpeg/get_ffmpeg_path.py @@ -25,4 +25,5 @@ except FileNotFoundError: config: dict[str, Any] = {} path = config.get("ffmpeg", {}).get("path", "default") -print(resolve_ffmpeg_path(path, "ffmpeg")) +service = sys.argv[1] if len(sys.argv) > 1 else "frigate" +print(resolve_ffmpeg_path(path, "ffmpeg", service)) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 2f75e15164..598f43df19 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -103,7 +103,7 @@ if not is_valid_ffmpeg_path(path): print( "[ERROR] ffmpeg path contains spaces, quotes or shell characters, using the default ffmpeg instead." ) -ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg") +ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg", "go2rtc") for key in apply_go2rtc_ffmpeg_config(go2rtc_config, ffmpeg_path): print( diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md index 99703ecd5e..2dd010ff68 100644 --- a/docs/docs/configuration/advanced/system.md +++ b/docs/docs/configuration/advanced/system.md @@ -398,9 +398,9 @@ To do this: 2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`. 3. Restart Frigate and the custom version will be used if the steps above were done correctly. -Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it. +Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. go2rtc runs as its own user, so the build must be world-executable for go2rtc to use it (`ffmpeg:` sources, the birdseye restream and transcoded live streams). The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it. -There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate`. That mode runs Frigate as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored there and the bundled one is used, with a warning in the log. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have. +There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate` or `go2rtc`. That mode runs the listed service as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored by the root service, which uses the bundled one and logs a warning. A service that is not listed keeps using the custom build. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have. ### Custom go2rtc version diff --git a/frigate/test/test_ffmpeg_path_resolution.py b/frigate/test/test_ffmpeg_path_resolution.py index b6128cdf96..02ae196044 100644 --- a/frigate/test/test_ffmpeg_path_resolution.py +++ b/frigate/test/test_ffmpeg_path_resolution.py @@ -1,13 +1,15 @@ """Tests for custom ffmpeg path resolution and the root-mode guard.""" +import os +import tempfile import unittest from unittest.mock import patch from frigate.const import DEFAULT_FFMPEG_VERSION from frigate.util.config import ( _warn_ignored_ffmpeg_path, - frigate_service_is_granular_root, resolve_ffmpeg_path, + service_is_granular_root, ) BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" @@ -21,12 +23,20 @@ class TestConfigFfmpegRootGuard(unittest.TestCase): # the warning is memoized so it fires once per path, not per camera _warn_ignored_ffmpeg_path.cache_clear() - def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"): + def _resolve( + self, + path: str, + *, + euid: int, + env: dict, + binary: str = "ffmpeg", + service: str = "frigate", + ): with ( patch("os.geteuid", return_value=euid), patch.dict("os.environ", env, clear=True), ): - return resolve_ffmpeg_path(path, binary) + return resolve_ffmpeg_path(path, binary, service) def test_custom_path_used_when_service_is_unprivileged(self) -> None: self.assertEqual( @@ -65,6 +75,59 @@ class TestConfigFfmpegRootGuard(unittest.TestCase): f"{CUSTOM}/bin/ffmpeg", ) + def test_custom_path_ignored_for_go2rtc_when_go2rtc_is_a_root_service( + self, + ) -> None: + self.assertEqual( + self._resolve( + CUSTOM, + euid=0, + env={"FRIGATE_ROOT_SERVICES": "go2rtc"}, + service="go2rtc", + ), + BUNDLED, + ) + + def test_go2rtc_keeps_custom_path_when_only_frigate_is_root(self) -> None: + self.assertEqual( + self._resolve( + CUSTOM, + euid=0, + env={"FRIGATE_ROOT_SERVICES": "frigate"}, + service="go2rtc", + ), + f"{CUSTOM}/bin/ffmpeg", + ) + + def test_go2rtc_custom_path_used_in_default_drop_mode(self) -> None: + # the run scripts resolve as root but probe as the service user + self.assertEqual( + self._resolve(CUSTOM, euid=0, env={}, service="go2rtc"), + f"{CUSTOM}/bin/ffmpeg", + ) + + def test_escape_hatch_keeps_go2rtc_custom_path(self) -> None: + self.assertEqual( + self._resolve( + CUSTOM, + euid=0, + env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "go2rtc"}, + service="go2rtc", + ), + f"{CUSTOM}/bin/ffmpeg", + ) + + def test_go2rtc_root_owned_path_is_left_alone(self) -> None: + self.assertEqual( + self._resolve( + "/opt/custom-ffmpeg", + euid=0, + env={"FRIGATE_ROOT_SERVICES": "go2rtc"}, + service="go2rtc", + ), + "/opt/custom-ffmpeg/bin/ffmpeg", + ) + def test_root_services_has_no_effect_under_docker_user(self) -> None: # docker's own user: means the service never had root to keep, which is # also the case for get_ffmpeg_path.py in a --user container @@ -115,15 +178,65 @@ class TestConfigFfmpegRootGuard(unittest.TestCase): ) -class TestFrigateServiceIsGranularRoot(unittest.TestCase): +class TestConfigFfmpegRootGuardSymlinks(unittest.TestCase): + """A root service gets the resolved path, so a link cannot be retargeted.""" + + def setUp(self) -> None: + _warn_ignored_ffmpeg_path.cache_clear() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = os.path.realpath(tmp.name) + self.writable = os.path.join(root, "config") + self.root_owned = os.path.join(root, "opt", "ffmpeg") + os.makedirs(self.writable) + os.makedirs(self.root_owned) + self.link = os.path.join(self.writable, "link") + dirs = patch("frigate.util.config.RUNTIME_USER_WRITABLE_DIRS", (self.writable,)) + dirs.start() + self.addCleanup(dirs.stop) + + def _resolve(self, env: dict, service: str = "frigate") -> str: + with ( + patch("os.geteuid", return_value=0), + patch.dict("os.environ", env, clear=True), + ): + return resolve_ffmpeg_path(self.link, "ffmpeg", service) + + def test_link_to_a_root_owned_tree_resolves_to_its_target(self) -> None: + os.symlink(self.root_owned, self.link) + for service in ("frigate", "go2rtc"): + self.assertEqual( + self._resolve({"FRIGATE_ROOT_SERVICES": service}, service), + f"{self.root_owned}/bin/ffmpeg", + ) + + def test_link_to_a_writable_tree_is_refused(self) -> None: + evil = os.path.join(self.writable, "evil") + os.makedirs(evil) + os.symlink(evil, self.link) + self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED) + + def test_bin_linked_into_a_writable_tree_is_refused(self) -> None: + evil = os.path.join(self.writable, "evil") + os.makedirs(evil) + os.symlink(evil, os.path.join(self.root_owned, "bin")) + os.symlink(self.root_owned, self.link) + self.assertEqual(self._resolve({"FRIGATE_ROOT_SERVICES": "frigate"}), BUNDLED) + + def test_unprivileged_service_keeps_the_path_as_written(self) -> None: + os.symlink(self.root_owned, self.link) + self.assertEqual(self._resolve({}), f"{self.link}/bin/ffmpeg") + + +class TestServiceIsGranularRoot(unittest.TestCase): """Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch.""" - def _check(self, *, euid: int, env: dict) -> bool: + def _check(self, *, euid: int, env: dict, service: str = "frigate") -> bool: with ( patch("os.geteuid", return_value=euid), patch.dict("os.environ", env, clear=True), ): - return frigate_service_is_granular_root() + return service_is_granular_root(service) def test_false_without_any_root_signal(self) -> None: self.assertFalse(self._check(euid=0, env={})) @@ -146,6 +259,12 @@ class TestFrigateServiceIsGranularRoot(unittest.TestCase): self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"}) ) + def test_matches_the_named_service(self) -> None: + env = {"FRIGATE_ROOT_SERVICES": "go2rtc"} + self.assertTrue(self._check(euid=0, env=env, service="go2rtc")) + self.assertFalse(self._check(euid=0, env=env, service="frigate")) + self.assertFalse(self._check(euid=1000, env=env, service="go2rtc")) + def test_substring_of_a_service_name_does_not_match(self) -> None: self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"})) diff --git a/frigate/util/config.py b/frigate/util/config.py index c052d2fb00..634016a331 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -46,14 +46,14 @@ DROPPED_DETECTOR_OPTIONS = { } -# Trees the unprivileged runtime user can write. A root frigate service must -# not execute a binary from any of them; a compromised uid-1000 process could +# Trees the unprivileged runtime user can write. A root service must not +# execute a binary from any of them; a compromised uid-1000 process could # plant one and be root after the next restart. RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp") -def frigate_service_is_granular_root() -> bool: - """Report whether FRIGATE_ROOT_SERVICES runs frigate as root. +def service_is_granular_root(service: str) -> bool: + """Report whether FRIGATE_ROOT_SERVICES runs the given service as root. The escape hatch is excluded: it never sweeps /config and leaves no unprivileged service running, so custom binaries stay as safe as they @@ -66,7 +66,7 @@ def frigate_service_is_granular_root() -> bool: return False entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",") - return any("".join(entry.split()) == "frigate" for entry in entries) + return any("".join(entry.split()) == service for entry in entries) def is_runtime_user_writable(path: str) -> bool: @@ -79,11 +79,12 @@ def is_runtime_user_writable(path: str) -> bool: @cache -def _warn_ignored_ffmpeg_path(path: str) -> None: +def _warn_ignored_ffmpeg_path(path: str, service: str) -> None: """Warn once per path; resolution runs per camera and per binary.""" logger.warning( - "Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs frigate as root and that location is writable by the unprivileged user; using the bundled build", + "Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs %s as root and that location is writable by the unprivileged user; using the bundled build", path, + service, ) @@ -97,16 +98,19 @@ def is_valid_ffmpeg_path(path: str) -> bool: return not path.startswith("/") or _CUSTOM_FFMPEG_PATH.fullmatch(path) is not None -def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str: +def resolve_ffmpeg_path( + path: str, binary: str = "ffmpeg", service: str = "frigate" +) -> str: """Resolve an ffmpeg version alias or custom path to a binary path. A bare version alias that is no longer bundled (for example one that was dropped when the default version changed) falls back to the default bundled version so existing configs keep working across an upgrade or a - revert. Custom install paths (anything absolute) are used as-is, unless - they fail `is_valid_ffmpeg_path` (which falls back to the default), or - they are in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES - makes frigate root; see RUNTIME_USER_WRITABLE_DIRS. + revert. Custom install paths (anything absolute) are used as-is. While + FRIGATE_ROOT_SERVICES makes the service that execs it root, a custom path + is resolved to its real location and refused inside a runtime-user-writable + tree; see RUNTIME_USER_WRITABLE_DIRS. Paths that fail + `is_valid_ffmpeg_path` also fall back to the default. """ if ( path == "default" @@ -116,11 +120,16 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str: version = DEFAULT_FFMPEG_VERSION elif path in INCLUDED_FFMPEG_VERSIONS: version = path + elif not service_is_granular_root(service): + return f"{path}/bin/{binary}" else: - if not (frigate_service_is_granular_root() and is_runtime_user_writable(path)): - return f"{path}/bin/{binary}" + # return the resolved form so a link in the path cannot be retargeted + # after the check + real = os.path.realpath(f"{path}/bin/{binary}") + if is_valid_ffmpeg_path(real) and not is_runtime_user_writable(real): + return real - _warn_ignored_ffmpeg_path(path) + _warn_ignored_ffmpeg_path(path, service) version = DEFAULT_FFMPEG_VERSION return f"/usr/lib/ffmpeg/{version}/bin/{binary}" diff --git a/frigate/util/runtime_deps.py b/frigate/util/runtime_deps.py index 4a899b6259..8cd33e9fcb 100644 --- a/frigate/util/runtime_deps.py +++ b/frigate/util/runtime_deps.py @@ -32,8 +32,8 @@ from pathlib import Path from frigate.const import MODEL_CACHE_DIR from frigate.util.config import ( - frigate_service_is_granular_root, is_runtime_user_writable, + service_is_granular_root, ) from frigate.util.file import FileLock @@ -163,7 +163,7 @@ def _usable_reason() -> str | None: base = user_base() - if frigate_service_is_granular_root() and is_runtime_user_writable(str(base)): + if service_is_granular_root("frigate") and is_runtime_user_writable(str(base)): return ( f"{base} is writable by the unprivileged user while " "FRIGATE_ROOT_SERVICES runs frigate as root"