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.services import (
Go2rtcStreamIssue,
find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info,
process_logs,
@@ -556,19 +557,26 @@ def _restricted_go2rtc_streams_response(
# with no readable saved config, every source is treated as new
old_streams = None
restricted = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
if not restricted:
issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams)
if not issues:
return None
logger.warning(
"Rejected config change with restricted go2rtc stream sources: %s",
", ".join(restricted),
)
messages = []
for issue, label in (
(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(
content={
"success": False,
"message": f"Restricted go2rtc stream source in: {', '.join(restricted)}",
},
content={"success": False, "message": "; ".join(messages)},
status_code=400,
)
@@ -177,15 +177,38 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def test_set_rejects_unresolvable_placeholder(self):
before = self._read_config()
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}):
for src in (
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"{FRIGATE_RTSP_USER:>5}",
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
for src, message in (
(
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"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]})
assert resp.status_code == 400, src
assert resp.json()["message"] == message, src
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):
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
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 traceback
from datetime import datetime
from enum import Enum
from typing import Any
import cv2
@@ -1043,22 +1044,30 @@ 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
) -> list[str]:
"""Return the names of go2rtc streams with new restricted sources.
) -> 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 pass
`is_restricted_go2rtc_api_source`.
operator configured on disk keep working; any other source must resolve
its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
"""
# imported here since frigate.config imports this module
from frigate.config.env import substitute_frigate_vars
old_streams = old_streams if isinstance(old_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():
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
if is_go2rtc_arbitrary_exec_allowed():
continue
restricted.append(name)
issues[name] = Go2rtcStreamIssue.restricted
break
# go2rtc startup resolves sources with the same substitution, and
# refuses to start on one it cannot resolve
# 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 (KeyError, ValueError):
restricted.append(name)
except KeyError:
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):
restricted.append(name)
issues[name] = Go2rtcStreamIssue.restricted
break
return restricted
return issues
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: