Explain why a go2rtc stream change was rejected

A stream source with an undefined or malformed {FRIGATE_*} placeholder
was reported as a restricted source, which reads like a security block
when the user made a typo. The config API now reports unknown variables
and invalid placeholders separately from restricted sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-10-14 11:12:21 -05:00
co-authored by Claude Opus 5.5
parent c43b8f58f9
commit d43a1d8565
3 changed files with 69 additions and 26 deletions
+18 -10
View File
@@ -73,6 +73,7 @@ from frigate.util.config import (
) )
from frigate.util.schema import get_config_schema from frigate.util.schema import get_config_schema
from frigate.util.services import ( from frigate.util.services import (
Go2rtcStreamIssue,
find_restricted_go2rtc_stream_changes, find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info, get_nvidia_driver_info,
process_logs, process_logs,
@@ -556,19 +557,26 @@ def _restricted_go2rtc_streams_response(
# with no readable saved config, every source is treated as new # with no readable saved config, every source is treated as new
old_streams = None old_streams = None
restricted = find_restricted_go2rtc_stream_changes(old_streams, new_streams) issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
if not restricted: if not issues:
return None return None
logger.warning( messages = []
"Rejected config change with restricted go2rtc stream sources: %s", for issue, label in (
", ".join(restricted), (Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"),
) (Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"),
(
Go2rtcStreamIssue.invalid_placeholder,
"Invalid variable placeholder in go2rtc stream",
),
):
names = [name for name, found in issues.items() if found == issue]
if names:
messages.append(f"{label}: {', '.join(names)}")
logger.warning("Rejected config change: %s", "; ".join(messages))
return JSONResponse( return JSONResponse(
content={ content={"success": False, "message": "; ".join(messages)},
"success": False,
"message": f"Restricted go2rtc stream source in: {', '.join(restricted)}",
},
status_code=400, status_code=400,
) )
@@ -177,15 +177,38 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def test_set_rejects_unresolvable_placeholder(self): def test_set_rejects_unresolvable_placeholder(self):
before = self._read_config() before = self._read_config()
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}):
for src in ( for src, message in (
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn", (
"{FRIGATE_RTSP_USER:>5}", "{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video", "Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"{FRIGATE_RTSP_USER:>5}",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
"Unknown variable in go2rtc stream: new_stream",
),
): ):
resp = self._set_streams({"new_stream": [src]}) resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 400, src assert resp.status_code == 400, src
assert resp.json()["message"] == message, src
assert self._read_config() == before assert self._read_config() == before
def test_set_reports_each_issue(self):
resp = self._set_streams(
{
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
"injected_stream": [_INJECTED_SRC],
}
)
assert resp.status_code == 400
assert resp.json()["message"] == (
"Restricted go2rtc stream source in: injected_stream; "
"Unknown variable in go2rtc stream: typo_stream"
)
def test_set_allows_resolvable_placeholder(self): def test_set_allows_resolvable_placeholder(self):
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video" src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}):
+24 -12
View File
@@ -12,6 +12,7 @@ import subprocess as sp
import time import time
import traceback import traceback
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any from typing import Any
import cv2 import cv2
@@ -1043,22 +1044,30 @@ def _go2rtc_stream_sources(stream: Any) -> list[Any]:
return stream if isinstance(stream, list) else [stream] 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( def find_restricted_go2rtc_stream_changes(
old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None
) -> list[str]: ) -> dict[str, Go2rtcStreamIssue]:
"""Return the names of go2rtc streams with new restricted sources. """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 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 in the saved config for the same stream is left alone, so streams the
operator configured on disk keep working; any other source must pass operator configured on disk keep working; any other source must resolve
`is_restricted_go2rtc_api_source`. its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
""" """
# imported here since frigate.config imports this module # imported here since frigate.config imports this module
from frigate.config.env import substitute_frigate_vars from frigate.config.env import substitute_frigate_vars
old_streams = old_streams if isinstance(old_streams, dict) else {} old_streams = old_streams if isinstance(old_streams, dict) else {}
new_streams = new_streams if isinstance(new_streams, dict) else {} new_streams = new_streams if isinstance(new_streams, dict) else {}
restricted = [] issues: dict[str, Go2rtcStreamIssue] = {}
for name, stream in new_streams.items(): for name, stream in new_streams.items():
existing = _go2rtc_stream_sources(old_streams.get(name)) existing = _go2rtc_stream_sources(old_streams.get(name))
@@ -1071,24 +1080,27 @@ def find_restricted_go2rtc_stream_changes(
# the map form is only usable with arbitrary exec enabled # the map form is only usable with arbitrary exec enabled
if is_go2rtc_arbitrary_exec_allowed(): if is_go2rtc_arbitrary_exec_allowed():
continue continue
restricted.append(name) issues[name] = Go2rtcStreamIssue.restricted
break break
# go2rtc startup resolves sources with the same substitution, and # only sources whose placeholders resolve here are accepted, so the
# refuses to start on one it cannot resolve # resolved value checked below is what go2rtc loads at startup
try: try:
resolved_source = substitute_frigate_vars(source) resolved_source = substitute_frigate_vars(source)
except (KeyError, ValueError): except KeyError:
restricted.append(name) issues[name] = Go2rtcStreamIssue.unknown_variable
break
except ValueError:
issues[name] = Go2rtcStreamIssue.invalid_placeholder
break break
if is_restricted_go2rtc_api_source( if is_restricted_go2rtc_api_source(
source source
) or is_restricted_go2rtc_api_source(resolved_source): ) or is_restricted_go2rtc_api_source(resolved_source):
restricted.append(name) issues[name] = Go2rtcStreamIssue.restricted
break break
return restricted return issues
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: