mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-14 01:23:01 +03:00
Don't run config ffmpeg as root (#24891)
* 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. * resolve the full ffmpeg path under granular root
This commit is contained in:
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user