From c6eadfebb8c9c74ce923c68f4f7bb2aa4e5d86d6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 15 May 2026 10:06:38 -0500 Subject: [PATCH] Miscellaneous fixes (#23201) * sync filter entries with track and listen labels - Auto-populate `audio.filters` from `audio.listen` instead of the full audio labelmap, matching how `objects.filters` is keyed by `track` (no longer need to populate the full audio labelmap, which was added in #22630) - Synthesize the matching filter entries in the settings form on load so each track/listen label shows its collapsible after a profile is selected, since the backend's auto-populate only runs at config init * translate main label for lifecycle description with attribute * reject restricted go2rtc stream sources when added via api * add env var check function --- .../rootfs/usr/local/go2rtc/create_config.py | 37 ++------------- frigate/api/camera.py | 21 +++++++-- frigate/config/config.py | 16 +++---- .../test/http_api/test_http_camera_access.py | 46 +++++++++++++++++++ frigate/test/test_config.py | 16 +++---- frigate/util/services.py | 35 ++++++++++++++ .../config-form/sections/BaseSection.tsx | 4 +- .../sections/section-special-cases.ts | 27 +++++++---- web/src/utils/lifecycleUtil.ts | 2 +- 9 files changed, 135 insertions(+), 69 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 71897be0a7..5796a58aad 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -3,7 +3,6 @@ import json import os import sys -from pathlib import Path from typing import Any from ruamel.yaml import YAML @@ -18,37 +17,12 @@ from frigate.const import ( ) from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.util.config import find_config_file +from frigate.util.services import is_restricted_go2rtc_source sys.path.remove("/opt/frigate") yaml = YAML() -# Check if arbitrary exec sources are allowed (defaults to False for security) -allow_arbitrary_exec = None -if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: - allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") -elif ( - os.path.isdir("/run/secrets") - and os.access("/run/secrets", os.R_OK) - and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") -): - allow_arbitrary_exec = ( - Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC")) - .read_text() - .strip() - ) -# check for the add-on options file -elif os.path.isfile("/data/options.json"): - with open("/data/options.json") as f: - raw_options = f.read() - options = json.loads(raw_options) - allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec") - -ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str( - allow_arbitrary_exec -).lower() in ("true", "1", "yes") - - config_file = find_config_file() try: @@ -128,18 +102,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59: go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args -def is_restricted_source(stream_source: str) -> bool: - """Check if a stream source is restricted (echo, expr, or exec).""" - return stream_source.strip().startswith(("echo:", "expr:", "exec:")) - - for name in list(go2rtc_config.get("streams", {})): stream = go2rtc_config["streams"][name] if isinstance(stream, str): try: formatted_stream = substitute_frigate_vars(stream) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." @@ -158,7 +127,7 @@ for name in list(go2rtc_config.get("streams", {})): for i, stream_item in enumerate(stream): try: formatted_stream = substitute_frigate_vars(stream_item) - if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): + if is_restricted_go2rtc_source(formatted_stream): print( f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources." diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 54d508cbd2..9eb4bec9e2 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -38,7 +38,7 @@ from frigate.util.builtin import clean_camera_user_pass from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream +from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source logger = logging.getLogger(__name__) @@ -147,9 +147,24 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): params = {"name": stream_name} if src: try: - params["src"] = substitute_frigate_vars(src) + resolved_src = substitute_frigate_vars(src) except KeyError: - params["src"] = src + resolved_src = src + + if is_restricted_go2rtc_source(resolved_src): + logger.warning( + "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + stream_name, + ) + return JSONResponse( + content={ + "success": False, + "message": "Restricted stream source type", + }, + status_code=400, + ) + + params["src"] = resolved_src r = requests.put( "http://127.0.0.1:1984/api/streams", diff --git a/frigate/config/config.py b/frigate/config/config.py index d1cc2101be..6873e6b880 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -26,7 +26,6 @@ from frigate.plus import PlusApi from frigate.util.builtin import ( deep_merge, get_ffmpeg_arg_list, - load_labels, ) from frigate.util.config import ( CURRENT_CONFIG_VERSION, @@ -638,17 +637,12 @@ class FrigateConfig(FrigateBaseModel): if self.ffmpeg.hwaccel_args == "auto": self.ffmpeg.hwaccel_args = auto_detect_hwaccel() - # Populate global audio filters for all audio labels - all_audio_labels = { - label - for label in load_labels("/audio-labelmap.txt", prefill=521).values() - if label - } - + # Populate global audio filters from listen. Existing user-defined + # entries for labels not in listen are preserved but unused at runtime. if self.audio.filters is None: self.audio.filters = {} - for key in sorted(all_audio_labels - self.audio.filters.keys()): + for key in sorted(set(self.audio.listen) - self.audio.filters.keys()): self.audio.filters[key] = AudioFilterConfig() self.audio.filters = dict(sorted(self.audio.filters.items())) @@ -840,7 +834,9 @@ class FrigateConfig(FrigateBaseModel): if camera_config.audio.filters is None: camera_config.audio.filters = {} - for key in sorted(all_audio_labels - camera_config.audio.filters.keys()): + for key in sorted( + set(camera_config.audio.listen) - camera_config.audio.filters.keys() + ): camera_config.audio.filters[key] = AudioFilterConfig() camera_config.audio.filters = dict( diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 211c84bb4f..44520d79f5 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -1,3 +1,4 @@ +import os from unittest.mock import patch from fastapi import HTTPException, Request @@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp): f"got {resp.status_code}" ) + def test_add_stream_rejects_restricted_source(self): + """PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for + admins""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + for src in ( + "exec:/tmp/rev.sh", + "echo:foo", + "expr:bar", + " exec:/tmp/rev.sh", + ): + resp = client.put(f"/go2rtc/streams/revshell?src={src}") + assert resp.status_code == 400, ( + f"Expected 400 for restricted src {src!r}; got {resp.status_code}" + ) + assert resp.json().get("success") is False + + def test_add_stream_allows_non_restricted_source(self): + """A normal stream URL should pass the restricted-source check and reach + the (unavailable in tests) go2rtc proxy — so we expect 500, not 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video") + assert resp.status_code != 400, ( + f"Non-restricted source should not be rejected with 400; got {resp.status_code}" + ) + + def test_add_stream_allows_restricted_source_when_override_set(self): + """When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator + intent and forward the request to go2rtc instead of short-circuiting with 400.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() + with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}): + with patch( + "frigate.api.camera.requests.put", return_value=mock_response + ) as mock_put: + with AuthTestClient(app) as client: + resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something") + assert resp.status_code == 200, ( + f"Restricted src should be forwarded when override set; got {resp.status_code}" + ) + mock_put.assert_called_once() + forwarded_src = mock_put.call_args.kwargs["params"]["src"] + assert forwarded_src == "exec:/tmp/something" + def test_stream_alias_blocked_when_owning_camera_disallowed(self): """limited_user cannot access a stream alias that belongs to a camera they are not allowed to see.""" diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 3a8909b30d..c63f27430a 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.const import MODEL_CACHE_DIR from frigate.detectors import DetectorTypeEnum -from frigate.util.builtin import deep_merge, load_labels +from frigate.util.builtin import deep_merge class TestConfig(unittest.TestCase): @@ -309,16 +309,11 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - all_audio_labels = { - label - for label in load_labels("/audio-labelmap.txt", prefill=521).values() - if label + assert set(frigate_config.cameras["back"].audio.filters.keys()) == { + "speech", + "yell", } - assert all_audio_labels.issubset( - set(frigate_config.cameras["back"].audio.filters.keys()) - ) - def test_override_audio_filters(self): config = { "mqtt": {"host": "mqtt"}, @@ -345,7 +340,8 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert "speech" in frigate_config.cameras["back"].audio.filters assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9 - assert "babbling" in frigate_config.cameras["back"].audio.filters + assert "yell" in frigate_config.cameras["back"].audio.filters + assert "babbling" not in frigate_config.cameras["back"].audio.filters def test_inherit_object_filters(self): config = { diff --git a/frigate/util/services.py b/frigate/util/services.py index 5ee15f8b48..4a715608e2 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -778,6 +778,41 @@ def get_hailo_temps() -> dict[str, float]: return temps +def _go2rtc_arbitrary_exec_allowed() -> bool: + """Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker + secrets, or the Home Assistant add-on options file.""" + raw: Optional[str] = None + if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ: + raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC") + elif ( + os.path.isdir("/run/secrets") + and os.access("/run/secrets", os.R_OK) + and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets") + ): + try: + with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f: + raw = f.read().strip() + except OSError: + raw = None + elif os.path.isfile("/data/options.json"): + try: + with open("/data/options.json") as f: + options = json.loads(f.read()) + raw = options.get("go2rtc_allow_arbitrary_exec") + except (OSError, json.JSONDecodeError): + raw = None + + return raw is not None and str(raw).lower() in ("true", "1", "yes") + + +def is_restricted_go2rtc_source(stream_source: str) -> bool: + """Check if a stream source is a restricted type (echo, expr, or exec) + and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set.""" + if not stream_source.strip().startswith(("echo:", "expr:", "exec:")): + return False + return not _go2rtc_arbitrary_exec_allowed() + + def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: """Run ffprobe on stream.""" clean_path = escape_special_characters(path) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 66cc2cc4ab..6c32ffae6a 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -22,7 +22,7 @@ import { modifySchemaForSection, getEffectiveDefaultsForSection, sanitizeOverridesForSection, - synthesizeMissingObjectFilters, + synthesizeMissingFilters, } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; import { useConfigOverride } from "@/hooks/use-config-override"; @@ -370,7 +370,7 @@ export function ConfigSection({ return {}; } - return synthesizeMissingObjectFilters( + return synthesizeMissingFilters( sectionPath, rawSectionValue, modifiedSchema ?? undefined, diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index d8121aea8b..62a4bfa85a 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -128,22 +128,31 @@ export function getEffectiveDefaultsForSection( return schemaDefaults; } +// Sections whose `filters` dict is keyed by a sibling list field. The backend +// auto-populates these filters at config init but doesn't re-run after profile +// merges, so we synthesize the missing entries on the frontend. +const FILTER_SECTIONS: Record = { + objects: { listField: "track" }, + audio: { listField: "listen" }, +}; + /** - * Add default filter entries for any label in `objects.track` that isn't - * already in `objects.filters`, so each tracked label gets a collapsible. - * The backend only auto-populates filters at config init, not after profile - * merges. + * Add default filter entries for any label in the section's list field + * (e.g. `objects.track`, `audio.listen`) that isn't already in `filters`, so + * each label gets a collapsible. The backend only auto-populates filters at + * config init, not after profile merges. */ -export function synthesizeMissingObjectFilters( +export function synthesizeMissingFilters( sectionPath: string, data: unknown, sectionSchema: RJSFSchema | undefined, ): unknown { - if (sectionPath !== "objects") return data; + const sectionConfig = FILTER_SECTIONS[sectionPath]; + if (!sectionConfig) return data; if (!isJsonObject(data)) return data; - const trackValue = (data as JsonObject).track; - if (!Array.isArray(trackValue) || trackValue.length === 0) return data; + const listValue = (data as JsonObject)[sectionConfig.listField]; + if (!Array.isArray(listValue) || listValue.length === 0) return data; const properties = (sectionSchema as { properties?: Record }) ?.properties; @@ -160,7 +169,7 @@ export function synthesizeMissingObjectFilters( const newFilters: JsonObject = { ...existingFilters }; let added = false; - for (const label of trackValue) { + for (const label of listValue) { if (typeof label !== "string") continue; if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue; newFilters[label] = ( diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts index 4e43de9c2f..7e9379e61a 100644 --- a/web/src/utils/lifecycleUtil.ts +++ b/web/src/utils/lifecycleUtil.ts @@ -60,7 +60,7 @@ export function getLifecycleItemDescription( } else { title = t("trackingDetails.lifecycleItemDesc.attribute.other", { ns: "views/explore", - label: lifecycleItem.data.label, + label: getTranslatedLabel(lifecycleItem.data.label), attribute: getTranslatedLabel( lifecycleItem.data.attribute.replaceAll("_", " "), ),