keep custom binaries out of root services only under granular root

This commit is contained in:
Josh Hawkins 2026-08-30 07:43:30 -05:00
parent 58a8e21fb9
commit e42cd63439
5 changed files with 226 additions and 7 deletions

View File

@ -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"

View File

@ -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.

View File

@ -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:

View File

@ -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()

View File

@ -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}"