Compare commits

...
Author SHA1 Message Date
Josh Hawkins 06c23ab51b resolve the full ffmpeg path under granular root 2026-11-07 12:46:04 -06:00
Josh Hawkins e04035dfa2 don't run a config ffmpeg.path as root at boot
The frigate and go2rtc run scripts probed "$ffmpeg_path" -version as root before dropping privileges, and resolve_ffmpeg_path only refused runtime-user-writable paths when frigate itself was a root service. In the default mode uid 1000 could set ffmpeg.path to a /config directory, plant bin/ffmpeg there, and get root on the next restart, and under FRIGATE_ROOT_SERVICES=go2rtc a root go2rtc used that path as its ffmpeg bin. The probe now runs as the service user whenever the service drops privileges, and resolve_ffmpeg_path refuses writable paths for whichever service will run the binary, returning the resolved path so a symlink can't be retargeted after the check. go2rtc falls back to the bundled build's libavformat version if its user can't run the custom build.
2026-11-07 12:32:27 -06:00
8 changed files with 176 additions and 32 deletions
@@ -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
}
@@ -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
}
@@ -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))
@@ -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(
+2 -2
View File
@@ -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
+125 -6
View File
@@ -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"}))
+24 -15
View File
@@ -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}"
+2 -2
View File
@@ -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"