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 4a193dad13..db11bc3576 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 @@ -11,6 +11,13 @@ if [[ "$(id -u)" -eq 0 ]]; then fi fi +# Root via FRIGATE_ROOT_SERVICES only; the escape hatch sweeps nothing and +# leaves no unprivileged service, so /config/go2rtc stays as safe as pre-drop. +granular_root=0 +if [[ "$runs_as_root" -eq 1 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then + granular_root=1 +fi + # Logs should be sent to stdout so that s6 can collect them function get_ip_and_port_from_supervisor() { @@ -88,10 +95,10 @@ fi readonly config_path="/config" -# frigate owns /config after the sweep, so a /config/go2rtc override must not -# be honored while this service runs as root -if [[ "$runs_as_root" -eq 1 && -x "${config_path}/go2rtc" ]]; then - echo "[WARN] Ignoring '${config_path}/go2rtc' because this service is running as root; using the embedded binary" +# the sweep hands /config to uid 1000, so a root service must not exec from it +if [[ "$granular_root" -eq 1 && -x "${config_path}/go2rtc" ]]; then + echo "[WARN] Ignoring '${config_path}/go2rtc' because FRIGATE_ROOT_SERVICES runs this service as root and /config is owned by the runtime user; using the embedded binary" + echo "[WARN] Use FRIGATE_RUN_AS_ROOT=true instead if you need both a custom go2rtc build and root" readonly binary_path="/usr/local/go2rtc/bin/go2rtc" elif [[ -x "${config_path}/go2rtc" ]]; then readonly binary_path="${config_path}/go2rtc" diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md index a1d097437e..a8cff3b827 100644 --- a/docs/docs/configuration/advanced/system.md +++ b/docs/docs/configuration/advanced/system.md @@ -397,6 +397,10 @@ 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. + +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. + ### Custom go2rtc version Frigate currently includes go2rtc v1.9.14, there may be certain cases where you want to run a different version of go2rtc. @@ -405,9 +409,11 @@ To do this: 1. Download the go2rtc build to the `/config` folder. 2. Rename the build to `go2rtc`. -3. Give `go2rtc` execute permission. +3. Give `go2rtc` execute permission for all users (`chmod 755`). It runs as its own `go2rtc` user, which doesn't own the file, so owner-only execute permission isn't enough. 4. Restart Frigate and the custom version will be used, you can verify by checking go2rtc logs. +The same exception applies, and again only to [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `go2rtc`: the binary is ignored there and the embedded one is used, with a warning in the log. Unlike `ffmpeg.path`, the go2rtc binary location is not configurable, so there is no outside-`/config` alternative. Use `FRIGATE_RUN_AS_ROOT=true` instead if you need both a custom go2rtc build and root. The default mode and the escape hatch both honor `/config/go2rtc` exactly as they always have. + ## Validating your config.yml file updates When frigate starts up, it checks whether your config file is valid, and if it is not, the process exits. To minimize interruptions when updating your config, you have three options -- you can edit the config via the WebUI which has built in validation, use the config API, or you can validate on the command line using the frigate docker container. diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index ef59ac67e7..af47cfef50 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -36,6 +36,8 @@ There are two reasons to use it: Try the device grants and `EXTRA_GROUPS` first. The `frigate` service runs the API and every ffmpeg process that decodes your camera streams, so listing it puts those back on root as well, not just your detectors. +A listed service also stops honoring a [custom ffmpeg or go2rtc build](/configuration/advanced/system#custom-dependencies) kept in `/config`, since that directory stays owned by the unprivileged user and a binary there would run as root. `FRIGATE_RUN_AS_ROOT=true` has no such restriction. + Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`. Listing all three services is not the same as `FRIGATE_RUN_AS_ROOT=true`. The escape hatch never touches ownership; the list keeps the ownership handling active. A few more details: diff --git a/frigate/test/test_ffmpeg_path_resolution.py b/frigate/test/test_ffmpeg_path_resolution.py new file mode 100644 index 0000000000..b6128cdf96 --- /dev/null +++ b/frigate/test/test_ffmpeg_path_resolution.py @@ -0,0 +1,154 @@ +"""Tests for custom ffmpeg path resolution and the root-mode guard.""" + +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, +) + +BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg" +CUSTOM = "/config/custom-ffmpeg" + + +class TestConfigFfmpegRootGuard(unittest.TestCase): + """A user-writable ffmpeg must not run as root under FRIGATE_ROOT_SERVICES.""" + + def setUp(self) -> None: + # 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"): + with ( + patch("os.geteuid", return_value=euid), + patch.dict("os.environ", env, clear=True), + ): + return resolve_ffmpeg_path(path, binary) + + def test_custom_path_used_when_service_is_unprivileged(self) -> None: + self.assertEqual( + self._resolve(CUSTOM, euid=1000, env={}), f"{CUSTOM}/bin/ffmpeg" + ) + + def test_escape_hatch_keeps_working_exactly_as_before(self) -> None: + # FRIGATE_RUN_AS_ROOT never sweeps /config and leaves no unprivileged + # service, so a custom build there is as safe as it was pre-drop + self.assertEqual( + self._resolve(CUSTOM, euid=0, env={"FRIGATE_RUN_AS_ROOT": "true"}), + f"{CUSTOM}/bin/ffmpeg", + ) + + def test_custom_path_ignored_when_frigate_is_a_root_service(self) -> None: + self.assertEqual( + self._resolve(CUSTOM, euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}), + BUNDLED, + ) + + def test_ffprobe_is_guarded_too(self) -> None: + self.assertEqual( + self._resolve( + CUSTOM, + euid=0, + env={"FRIGATE_ROOT_SERVICES": "frigate"}, + binary="ffprobe", + ), + f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe", + ) + + def test_another_root_service_does_not_trigger_the_guard(self) -> None: + # go2rtc running as root says nothing about who spawns ffmpeg + self.assertEqual( + self._resolve(CUSTOM, euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc"}), + f"{CUSTOM}/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 + self.assertEqual( + self._resolve(CUSTOM, euid=1000, env={"FRIGATE_ROOT_SERVICES": "frigate"}), + f"{CUSTOM}/bin/ffmpeg", + ) + + def test_path_outside_config_is_left_alone(self) -> None: + # only /config is runtime-user-owned; a root-owned tree stays usable + self.assertEqual( + self._resolve( + "/opt/custom-ffmpeg", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"} + ), + "/opt/custom-ffmpeg/bin/ffmpeg", + ) + + def test_traversal_out_of_config_does_not_evade_the_guard(self) -> None: + self.assertEqual( + self._resolve( + "/config/../config/custom-ffmpeg", + euid=0, + env={"FRIGATE_ROOT_SERVICES": "frigate"}, + ), + BUNDLED, + ) + + def test_media_tree_is_guarded_too(self) -> None: + # config.yml is uid-1000-writable, so ffmpeg.path can be pointed at any + # writable tree; /config alone would be an evasion, not a guard + self.assertEqual( + self._resolve( + "/media/frigate/evil", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"} + ), + BUNDLED, + ) + + def test_config_dir_itself_is_guarded(self) -> None: + self.assertEqual( + self._resolve("/config", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}), + BUNDLED, + ) + + def test_default_alias_is_unaffected(self) -> None: + self.assertEqual( + self._resolve("default", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}), + BUNDLED, + ) + + +class TestFrigateServiceIsGranularRoot(unittest.TestCase): + """Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch.""" + + def _check(self, *, euid: int, env: dict) -> bool: + with ( + patch("os.geteuid", return_value=euid), + patch.dict("os.environ", env, clear=True), + ): + return frigate_service_is_granular_root() + + def test_false_without_any_root_signal(self) -> None: + self.assertFalse(self._check(euid=0, env={})) + + def test_escape_hatch_is_not_granular_root(self) -> None: + # the escape hatch restores old behavior wholesale, sweep included + self.assertFalse(self._check(euid=0, env={"FRIGATE_RUN_AS_ROOT": "true"})) + self.assertFalse( + self._check( + euid=0, + env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "frigate"}, + ) + ) + + def test_membership_ignores_whitespace_and_other_entries(self) -> None: + self.assertTrue( + self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc, frigate"}) + ) + self.assertFalse( + self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"}) + ) + + def test_substring_of_a_service_name_does_not_match(self) -> None: + self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"})) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/config.py b/frigate/util/config.py index fe1218b5c9..965651ccc8 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -4,11 +4,14 @@ import asyncio import logging import os import shutil +from functools import cache from typing import Any from ruamel.yaml import YAML from frigate.const import ( + BASE_DIR, + CACHE_DIR, CONFIG_DIR, DEFAULT_FFMPEG_VERSION, EXPORT_DIR, @@ -43,13 +46,56 @@ 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 +# 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. + + The escape hatch is excluded: it never sweeps /config and leaves no + unprivileged service running, so custom binaries stay as safe as they + were before the privilege drop. + """ + if os.geteuid() != 0: + return False + + if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true": + return False + + entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",") + return any("".join(entry.split()) == "frigate" for entry in entries) + + +def _is_runtime_user_writable(path: str) -> bool: + """Report whether a path resolves inside a runtime-user-writable tree.""" + resolved = os.path.realpath(path) + return any( + resolved == root or resolved.startswith(f"{root}{os.sep}") + for root in RUNTIME_USER_WRITABLE_DIRS + ) + + +@cache +def _warn_ignored_ffmpeg_path(path: 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", + path, + ) + + def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> 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. + revert. Custom install paths (anything absolute) are used as-is, except + one in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES makes + frigate root; see RUNTIME_USER_WRITABLE_DIRS. """ if path == "default" or ( not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS @@ -58,7 +104,11 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str: elif path in INCLUDED_FFMPEG_VERSIONS: version = path else: - return f"{path}/bin/{binary}" + if not (frigate_service_is_granular_root() and _is_runtime_user_writable(path)): + return f"{path}/bin/{binary}" + + _warn_ignored_ffmpeg_path(path) + version = DEFAULT_FFMPEG_VERSION return f"/usr/lib/ffmpeg/{version}/bin/{binary}"