Container security hardening (phase 3, breaking) (#24081)

* Run the frigate service as the frigate user

* Run go2rtc as its own restricted user

* Run nginx as the frigate user with writable state in /tmp/nginx

* Disable bandwidth stats gracefully when not running as root

* Hand TensorRT model cache ownership to the runtime user

* Document non-root operation and per-hardware device access

* Create /media/frigate after the ownership sweep

* Assert non-root services, JWT migration, and escape hatch in CI

* only write the sweep sentinel when a media volume is mounted

* tolerate homekit config chown failures in the go2rtc run script

* chown the s6 log pipe so non-root nginx can reopen /dev/stdout

* set HOME to /config for non-root services

* run smoke nginx -t and the write probe as the runtime user

* re-own the nginx shm cache on service restart

* discard stdout for the unprivileged smoke nginx -t

* unwrap hard-wrapped prose in the installation docs

* report progress during the ownership sweep

* document EXTRA_GROUPS as the only device access path for dropped services

* expand the non-root device access docs with diagnosis steps and udev rules

* document network storage ownership and the remaining detector hardware

* skip lost+found during the ownership sweep

* hand /tmp/cache to the runtime user before services start

* make bundled models readable by the runtime user

* reload nginx by signaling the master instead of parsing its config as root

* harden root writes into unprivileged-owned paths

Restrict the sweep sentinel to a mount at or below /media/frigate so a
parent /media mount cannot bless a later-shadowed volume. Rebuild
/tmp/nginx root-owned each start so root's cp and tempio writes cannot
follow a symlink an unprivileged nginx planted in the previous run.

* collapse the duplicated sentinel comment

* add a service-runs-as-root helper for granular root services

* validate FRIGATE_ROOT_SERVICES and fail fast on unknown names

* let services listed in FRIGATE_ROOT_SERVICES skip the privilege drop

* record the root-services mode in the sentinel and sweep small trees each boot

* cache the runtime ids in the ownership helper

* chown recordings, previews, and exports to the runtime user at create

* chown the database files after init

* recommend FRIGATE_ROOT_SERVICES in the bandwidth stats warning

* assert granular root services in CI

* document FRIGATE_ROOT_SERVICES

* own every directory level created for a recording segment

* clear the cached runtime ids when ownership tests finish

* skip missing media paths in the per-boot ownership sweep

* clarify granular root services docs

* clean up

* install acl for device access grants

* grant runtime users access to mapped device nodes at boot

* assert device access grants in CI

* document automatic device access grants

* stop telling users device access needs host side setup

* clarify the non-root docs

* link the migration script to the repo

* group the manual device setup under one section

* harden against symlink attacks

/config is owned by the unprivileged runtime user after the ownership sweep, so root operations on files there could be redirected by a planted symlink.

- go2rtc HomeKit setup: replace the root yq/jq normalization and chown with an O_NOFOLLOW helper (prepare_homekit.py), so a symlink at go2rtc_homekit.yml can't redirect a root write or chown onto another file
- go2rtc binary override: ignore /config/go2rtc whenever the service runs as root, so a planted binary can't exec as root under FRIGATE_ROOT_SERVICES
- sweep sentinel: read and write it through safe-sentinel, which trusts only a root-owned regular file and never follows a symlink, so it can't be forged to skip the migration or symlinked to clobber a root file
- ownership sweep: chown with -execdir so a parent directory swapped for a symlink mid-walk can't redirect the chown out of the volume
- validate inputs: restrict DEVICE_ACL_PATHS to /dev, require nonzero numeric EXTRA_GROUPS, and reject PUID/PGID that collide with the go2rtc ids
- docs: correct the TLS key ownership note to match what actually happens

* tweak docs

* stop the ownership sweep chasing entries other mechanisms own

* keep custom binaries out of root services only under granular root
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 1693415375
commit 3be59c9c18
38 changed files with 1427 additions and 83 deletions
+52 -2
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}"
+4
View File
@@ -1,5 +1,6 @@
"""Helpers for aligning created files with the non-root runtime user."""
import functools
import logging
import os
import pwd
@@ -9,12 +10,15 @@ logger = logging.getLogger(__name__)
RUNTIME_USER = "frigate"
@functools.lru_cache(maxsize=1)
def get_runtime_ids() -> tuple[int, int] | None:
"""Return (uid, gid) that services run as, or None when chown is not applicable.
None when: not root (docker --user, so the host already mapped us),
FRIGATE_RUN_AS_ROOT=true (escape hatch must not mutate ownership),
or outside the Frigate container image (no frigate user).
The result is cached for the process lifetime because the runtime user
cannot change after boot.
"""
if os.geteuid() != 0:
return None
+15
View File
@@ -187,8 +187,23 @@ def get_physical_interfaces(interfaces) -> list:
return physical_interfaces
_bandwidth_warning_logged = False
def get_bandwidth_stats(config) -> dict[str, dict]:
"""Get bandwidth usages for each ffmpeg process id"""
global _bandwidth_warning_logged
if os.geteuid() != 0:
if not _bandwidth_warning_logged:
logger.warning(
"Network bandwidth stats require root (nethogs needs CAP_NET_ADMIN/CAP_NET_RAW) "
"and are disabled; set FRIGATE_ROOT_SERVICES=frigate (or FRIGATE_RUN_AS_ROOT=true) "
"or disable telemetry.stats.network_bandwidth to silence this warning"
)
_bandwidth_warning_logged = True
return {}
usages = {}
top_command = ["nethogs", "-t", "-v0", "-c5", "-d1"] + get_physical_interfaces(
config.telemetry.network_interfaces