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
This commit is contained in:
Josh Hawkins 2026-05-15 10:06:38 -05:00 committed by GitHub
parent d9c1ea908d
commit c6eadfebb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 135 additions and 69 deletions

View File

@ -3,7 +3,6 @@
import json import json
import os import os
import sys import sys
from pathlib import Path
from typing import Any from typing import Any
from ruamel.yaml import YAML 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.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.services import is_restricted_go2rtc_source
sys.path.remove("/opt/frigate") sys.path.remove("/opt/frigate")
yaml = YAML() 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() config_file = find_config_file()
try: try:
@ -128,18 +102,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59:
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args 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", {})): for name in list(go2rtc_config.get("streams", {})):
stream = go2rtc_config["streams"][name] stream = go2rtc_config["streams"][name]
if isinstance(stream, str): if isinstance(stream, str):
try: try:
formatted_stream = substitute_frigate_vars(stream) 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( print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " 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." 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): for i, stream_item in enumerate(stream):
try: try:
formatted_stream = substitute_frigate_vars(stream_item) 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( print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. " 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." f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."

View File

@ -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.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot 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__) logger = logging.getLogger(__name__)
@ -147,9 +147,24 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
params = {"name": stream_name} params = {"name": stream_name}
if src: if src:
try: try:
params["src"] = substitute_frigate_vars(src) resolved_src = substitute_frigate_vars(src)
except KeyError: 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( r = requests.put(
"http://127.0.0.1:1984/api/streams", "http://127.0.0.1:1984/api/streams",

View File

@ -26,7 +26,6 @@ from frigate.plus import PlusApi
from frigate.util.builtin import ( from frigate.util.builtin import (
deep_merge, deep_merge,
get_ffmpeg_arg_list, get_ffmpeg_arg_list,
load_labels,
) )
from frigate.util.config import ( from frigate.util.config import (
CURRENT_CONFIG_VERSION, CURRENT_CONFIG_VERSION,
@ -638,17 +637,12 @@ class FrigateConfig(FrigateBaseModel):
if self.ffmpeg.hwaccel_args == "auto": if self.ffmpeg.hwaccel_args == "auto":
self.ffmpeg.hwaccel_args = auto_detect_hwaccel() self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
# Populate global audio filters for all audio labels # Populate global audio filters from listen. Existing user-defined
all_audio_labels = { # entries for labels not in listen are preserved but unused at runtime.
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
if self.audio.filters is None: if self.audio.filters is None:
self.audio.filters = {} 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[key] = AudioFilterConfig()
self.audio.filters = dict(sorted(self.audio.filters.items())) self.audio.filters = dict(sorted(self.audio.filters.items()))
@ -840,7 +834,9 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.audio.filters is None: if camera_config.audio.filters is None:
camera_config.audio.filters = {} 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[key] = AudioFilterConfig()
camera_config.audio.filters = dict( camera_config.audio.filters = dict(

View File

@ -1,3 +1,4 @@
import os
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
f"got {resp.status_code}" 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): def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they """limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see.""" are not allowed to see."""

View File

@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum 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): class TestConfig(unittest.TestCase):
@ -309,16 +309,11 @@ class TestConfig(unittest.TestCase):
} }
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
all_audio_labels = { assert set(frigate_config.cameras["back"].audio.filters.keys()) == {
label "speech",
for label in load_labels("/audio-labelmap.txt", prefill=521).values() "yell",
if label
} }
assert all_audio_labels.issubset(
set(frigate_config.cameras["back"].audio.filters.keys())
)
def test_override_audio_filters(self): def test_override_audio_filters(self):
config = { config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
@ -345,7 +340,8 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config) frigate_config = FrigateConfig(**config)
assert "speech" in frigate_config.cameras["back"].audio.filters assert "speech" in frigate_config.cameras["back"].audio.filters
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9 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): def test_inherit_object_filters(self):
config = { config = {

View File

@ -778,6 +778,41 @@ def get_hailo_temps() -> dict[str, float]:
return temps 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: def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream.""" """Run ffprobe on stream."""
clean_path = escape_special_characters(path) clean_path = escape_special_characters(path)

View File

@ -22,7 +22,7 @@ import {
modifySchemaForSection, modifySchemaForSection,
getEffectiveDefaultsForSection, getEffectiveDefaultsForSection,
sanitizeOverridesForSection, sanitizeOverridesForSection,
synthesizeMissingObjectFilters, synthesizeMissingFilters,
} from "./section-special-cases"; } from "./section-special-cases";
import { getSectionValidation } from "../section-validations"; import { getSectionValidation } from "../section-validations";
import { useConfigOverride } from "@/hooks/use-config-override"; import { useConfigOverride } from "@/hooks/use-config-override";
@ -370,7 +370,7 @@ export function ConfigSection({
return {}; return {};
} }
return synthesizeMissingObjectFilters( return synthesizeMissingFilters(
sectionPath, sectionPath,
rawSectionValue, rawSectionValue,
modifiedSchema ?? undefined, modifiedSchema ?? undefined,

View File

@ -128,22 +128,31 @@ export function getEffectiveDefaultsForSection(
return schemaDefaults; 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<string, { listField: string }> = {
objects: { listField: "track" },
audio: { listField: "listen" },
};
/** /**
* Add default filter entries for any label in `objects.track` that isn't * Add default filter entries for any label in the section's list field
* already in `objects.filters`, so each tracked label gets a collapsible. * (e.g. `objects.track`, `audio.listen`) that isn't already in `filters`, so
* The backend only auto-populates filters at config init, not after profile * each label gets a collapsible. The backend only auto-populates filters at
* merges. * config init, not after profile merges.
*/ */
export function synthesizeMissingObjectFilters( export function synthesizeMissingFilters(
sectionPath: string, sectionPath: string,
data: unknown, data: unknown,
sectionSchema: RJSFSchema | undefined, sectionSchema: RJSFSchema | undefined,
): unknown { ): unknown {
if (sectionPath !== "objects") return data; const sectionConfig = FILTER_SECTIONS[sectionPath];
if (!sectionConfig) return data;
if (!isJsonObject(data)) return data; if (!isJsonObject(data)) return data;
const trackValue = (data as JsonObject).track; const listValue = (data as JsonObject)[sectionConfig.listField];
if (!Array.isArray(trackValue) || trackValue.length === 0) return data; if (!Array.isArray(listValue) || listValue.length === 0) return data;
const properties = (sectionSchema as { properties?: Record<string, unknown> }) const properties = (sectionSchema as { properties?: Record<string, unknown> })
?.properties; ?.properties;
@ -160,7 +169,7 @@ export function synthesizeMissingObjectFilters(
const newFilters: JsonObject = { ...existingFilters }; const newFilters: JsonObject = { ...existingFilters };
let added = false; let added = false;
for (const label of trackValue) { for (const label of listValue) {
if (typeof label !== "string") continue; if (typeof label !== "string") continue;
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue; if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
newFilters[label] = ( newFilters[label] = (

View File

@ -60,7 +60,7 @@ export function getLifecycleItemDescription(
} else { } else {
title = t("trackingDetails.lifecycleItemDesc.attribute.other", { title = t("trackingDetails.lifecycleItemDesc.attribute.other", {
ns: "views/explore", ns: "views/explore",
label: lifecycleItem.data.label, label: getTranslatedLabel(lifecycleItem.data.label),
attribute: getTranslatedLabel( attribute: getTranslatedLabel(
lifecycleItem.data.attribute.replaceAll("_", " "), lifecycleItem.data.attribute.replaceAll("_", " "),
), ),