Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

Brings the go2rtc security fixes from #24658 to dev. Dev-specific changes
in the merge:
- frigate/config/env.py stays deleted; the environment_vars protection for
  GO2RTC_ALLOW_ARBITRARY_EXEC moves to apply_config_env_vars in
  frigate/util/env.py, so the value never reaches os.environ
- services.py imports substitute_frigate_vars from frigate.util.env and
  maps UnknownVariableError to the unknown variable message
- go2rtc stream tests patch dev's import-time exec override snapshot and
  its FRIGATE_* variable sources
- test_http_media.py keeps dev's _insert_recording helper; the one added
  on master replaced it and broke the dev tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-10-14 14:30:11 -05:00
co-authored by Claude Opus 5.5
39 changed files with 1524 additions and 152 deletions
+14 -1
View File
@@ -165,12 +165,25 @@ def reload_sources(warn: bool = True) -> None:
_rebuild(warn)
# Security overrides that must come from the container environment, which is
# controlled by the operator, rather than from the config, which admins can edit
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
def apply_config_env_vars(values: Mapping[str, object]) -> None:
"""Install the environment_vars block as the lowest priority source.
Unprefixed keys only set os.environ.
Unprefixed keys only set os.environ. Protected security overrides are
skipped.
"""
for key, value in values.items():
if key.upper() in _PROTECTED_ENV_VARS:
logger.warning(
"Ignoring %s in environment_vars, it must be set in the container environment",
key,
)
continue
resolved = str(value)
if key.startswith("FRIGATE_"):
+121 -4
View File
@@ -14,6 +14,7 @@ import subprocess as sp
import time
import traceback
from datetime import datetime
from enum import Enum
from typing import Any
import cv2
@@ -29,6 +30,7 @@ from frigate.const import (
SHM_FRAMES_VAR,
)
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
logger = logging.getLogger(__name__)
@@ -511,8 +513,9 @@ def get_intel_gpu_stats(
Each DRM client FD exposes monotonic per-engine busy counters via
/proc/<pid>/fdinfo/<fd>. For i915 this requires kernel 6.5 or newer:
earlier kernels omit the per-engine counters whenever GuC submission is
active, which is the default on 12th gen and newer. Xe has exposed them
since its first release. We sample twice and divide busy-time deltas by
active, which is the default on 12th gen and newer. i915 never exposes
them on GPUs older than Broadwell (Gen8). Xe has exposed them since its
first release. We sample twice and divide busy-time deltas by
wall-clock to derive utilization. Render/3D and Compute are pooled into
"compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of
those pools (clamped to 100%).
@@ -581,10 +584,11 @@ def get_intel_gpu_stats(
# i915 suppresses per-client engine counters while GuC submission is
# active on kernels older than 6.5 (kernel commit 1324680a80eb lifted
# this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen
# and newer.
# and newer. It never publishes them for pre-Broadwell GPUs.
logger.warning(
"Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
"no per-engine counters. Kernel 6.5 or newer is required.",
"the kernel reports no usage for them. GPUs older than Broadwell "
"(5th gen) are not supported. 12th gen and newer need kernel 6.5+",
len(snapshot_a),
"/".join(sorted({client["driver"] for client in snapshot_a.values()})),
)
@@ -1027,6 +1031,119 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool:
return not is_go2rtc_arbitrary_exec_allowed()
# go2rtc expands an `ffmpeg:` source into an `exec:` command line built from its
# `#key=value` parameters, so only parameters that map to fixed presets or plain
# numbers are accepted from the API
_GO2RTC_FFMPEG_NAME_PARAMS = frozenset({"video", "audio", "hardware"})
_GO2RTC_FFMPEG_NUMERIC_PARAMS = frozenset(
{"width", "height", "rotate", "bitrate", "timeout"}
)
_GO2RTC_FFMPEG_FLAG_PARAMS = frozenset({"async"})
# a preset or codec name, optionally with a sample rate (e.g. opus/16000)
_GO2RTC_FFMPEG_NAME_VALUE = re.compile(r"^([A-Za-z0-9_]*|[A-Za-z0-9_]+/[0-9]{1,6})$")
_GO2RTC_FFMPEG_NUMERIC_VALUE = re.compile(r"^-?[0-9]+[kKmM]?$")
def _is_safe_go2rtc_ffmpeg_source(stream_source: str) -> bool:
"""Check that an `ffmpeg:` source only uses allowlisted parameters."""
if any(c in stream_source for c in "'\"") or re.search(r"\s", stream_source):
return False
_, *params = stream_source.split("#")
for param in params:
key, has_value, value = param.partition("=")
if key in _GO2RTC_FFMPEG_NAME_PARAMS:
if not _GO2RTC_FFMPEG_NAME_VALUE.match(value):
return False
elif key in _GO2RTC_FFMPEG_NUMERIC_PARAMS:
if not _GO2RTC_FFMPEG_NUMERIC_VALUE.match(value):
return False
elif key in _GO2RTC_FFMPEG_FLAG_PARAMS:
if has_value:
return False
else:
return False
return True
def is_restricted_go2rtc_api_source(stream_source: str) -> bool:
"""Check if a stream source submitted through the API is restricted.
In addition to the echo/expr/exec sources, this restricts `ffmpeg:` sources
whose parameters could inject arbitrary ffmpeg arguments. The
GO2RTC_ALLOW_ARBITRARY_EXEC override lifts both restrictions.
"""
source = stream_source.strip()
if is_restricted_go2rtc_source(source):
return True
if not source.startswith("ffmpeg:"):
return False
if _is_safe_go2rtc_ffmpeg_source(source[len("ffmpeg:") :]):
return False
return not is_go2rtc_arbitrary_exec_allowed()
def _go2rtc_stream_sources(stream: Any) -> list[Any]:
return stream if isinstance(stream, list) else [stream]
class Go2rtcStreamIssue(str, Enum):
"""Why a go2rtc stream source was rejected."""
restricted = "restricted"
unknown_variable = "unknown_variable"
invalid_placeholder = "invalid_placeholder"
def find_restricted_go2rtc_stream_changes(
old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None
) -> dict[str, Go2rtcStreamIssue]:
"""Return the go2rtc streams with new sources that must be rejected.
Used when the config is changed through the API. A source that is already
in the saved config for the same stream is left alone, so streams the
operator configured on disk keep working; any other source must resolve
its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
"""
old_streams = old_streams if isinstance(old_streams, dict) else {}
new_streams = new_streams if isinstance(new_streams, dict) else {}
issues: dict[str, Go2rtcStreamIssue] = {}
for name, stream in new_streams.items():
existing = _go2rtc_stream_sources(old_streams.get(name))
for source in _go2rtc_stream_sources(stream):
if source in existing:
continue
if not isinstance(source, str):
# the map form is only usable with arbitrary exec enabled
if is_go2rtc_arbitrary_exec_allowed():
continue
issues[name] = Go2rtcStreamIssue.restricted
break
# only sources whose placeholders resolve here are accepted, so the
# resolved value checked below is what go2rtc loads at startup
try:
resolved_source = substitute_frigate_vars(source)
except UnknownVariableError:
issues[name] = Go2rtcStreamIssue.unknown_variable
break
except ValueError:
issues[name] = Go2rtcStreamIssue.invalid_placeholder
break
if is_restricted_go2rtc_api_source(
source
) or is_restricted_go2rtc_api_source(resolved_source):
issues[name] = Go2rtcStreamIssue.restricted
break
return issues
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)