Apply go2rtc source restrictions to config/save and config/set

go2rtc.streams written through config/save or config/set were only
filtered at startup, which blocks echo/expr/exec and the map form but not
the ffmpeg: parameters that PUT /go2rtc/streams now rejects. Saving a
stream this way and restarting bypassed the API restriction.

Both endpoints now reject a change that adds a go2rtc stream source that
fails is_restricted_go2rtc_api_source, or a map-form source, unless
GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources already in the saved config
for the same stream are left alone, so streams configured on disk keep
working and resubmitting the whole file from the config editor succeeds.
config/set restores the previous file when it rejects a change.

Follow-up to GHSA-qjjw-3v66-279f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-10-14 08:00:25 -05:00
co-authored by Claude Opus 5.5
parent 3bbac1be48
commit 9b165948c3
3 changed files with 266 additions and 2 deletions
+62 -2
View File
@@ -73,6 +73,7 @@ from frigate.util.config import (
)
from frigate.util.schema import get_config_schema
from frigate.util.services import (
find_restricted_go2rtc_stream_changes,
get_nvidia_driver_info,
process_logs,
restart_frigate,
@@ -522,6 +523,46 @@ def config_raw():
)
def _load_yaml_dict(raw_config: str) -> dict[str, Any]:
try:
loaded = ruamel.yaml.YAML(typ="safe", pure=True).load(raw_config)
except ruamel.yaml.YAMLError:
return {}
return loaded if isinstance(loaded, dict) else {}
def _go2rtc_streams(raw_config: str) -> Any:
go2rtc = _load_yaml_dict(raw_config).get("go2rtc")
return go2rtc.get("streams") if isinstance(go2rtc, dict) else None
def _restricted_go2rtc_streams_response(
old_raw_config: str, new_raw_config: str
) -> JSONResponse | None:
"""Reject config changes that add restricted go2rtc stream sources.
The same sources are rejected by PUT /go2rtc/streams, and go2rtc would
otherwise load them from the saved config on the next restart.
"""
restricted = find_restricted_go2rtc_stream_changes(
_go2rtc_streams(old_raw_config), _go2rtc_streams(new_raw_config)
)
if not restricted:
return None
logger.warning(
"Rejected config change with restricted go2rtc stream sources: %s",
", ".join(restricted),
)
return JSONResponse(
content={
"success": False,
"message": f"Restricted go2rtc stream source in: {', '.join(restricted)}",
},
status_code=400,
)
@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))])
def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
new_config = body.decode()
@@ -598,10 +639,22 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
status_code=400,
)
config_file = find_config_file()
try:
with open(config_file) as f:
old_raw_config = f.read()
except OSError:
old_raw_config = ""
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_config
)
if restricted_response is not None:
return restricted_response
# Save the config to file
try:
config_file = find_config_file()
with open(config_file, "w") as f:
f.write(new_config)
f.close()
@@ -912,6 +965,13 @@ def config_set(request: Request, body: AppConfigSetBody):
),
status_code=400,
)
restricted_response = _restricted_go2rtc_streams_response(
old_raw_config, new_raw_config
)
if restricted_response is not None:
with open(config_file, "w") as f:
f.write(old_raw_config)
return restricted_response
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
@@ -0,0 +1,155 @@
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
import copy
import os
import tempfile
from unittest.mock import patch
import ruamel.yaml
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
# shape of the source in GHSA-qjjw-3v66-279f, with the payload replaced
_INJECTED_SRC = "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'"
# an unrestricted ffmpeg: source the operator already has in the file on disk
_EXISTING_SRC = "ffmpeg:rtsp://10.0.0.1:554/video#raw=-vn"
_CONFIG = {
"mqtt": {"host": "mqtt"},
"go2rtc": {"streams": {"front_door": [_EXISTING_SRC]}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
def _dump(config: dict) -> str:
yaml = ruamel.yaml.YAML()
stream = tempfile.SpooledTemporaryFile(mode="w+")
yaml.dump(config, stream)
stream.seek(0)
return stream.read()
class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
def setUp(self):
super().setUp([Event, Recordings, ReviewSegment])
self.minimal_config = _CONFIG
self.app = super().create_app()
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
with os.fdopen(fd, "w") as f:
f.write(_dump(_CONFIG))
self.addCleanup(os.unlink, self.config_path)
find_patch = patch(
"frigate.api.app.find_config_file", return_value=self.config_path
)
find_patch.start()
self.addCleanup(find_patch.stop)
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
def _read_config(self) -> str:
with open(self.config_path) as f:
return f.read()
def _set_streams(self, streams: dict):
with AuthTestClient(self.app) as client:
return client.put(
"/config/set",
json={
"config_data": {"go2rtc": {"streams": streams}},
"requires_restart": 1,
},
)
def _save_streams(self, streams: dict):
config = copy.deepcopy(_CONFIG)
config["go2rtc"]["streams"].update(streams)
with AuthTestClient(self.app) as client:
return client.post(
"/config/save?save_option=saveonly",
content=_dump(config),
headers={"Content-Type": "text/plain"},
)
# --- config/set ---
def test_set_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert resp.json()["success"] is False
assert self._read_config() == before
def test_set_rejects_injected_string_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "ffmpeg:front_door#raw=-vn"})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_rejects_injected_source_added_to_existing_stream(self):
before = self._read_config()
resp = self._set_streams({"front_door": [_EXISTING_SRC, _INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_set_allows_standard_sources(self):
resp = self._set_streams(
{
"new_stream": [
"rtsp://10.0.0.2:554/video",
"ffmpeg:new_stream#audio=opus",
]
}
)
assert resp.status_code == 200, resp.text
assert "ffmpeg:new_stream#audio=opus" in self._read_config()
def test_set_keeps_existing_source_on_disk(self):
resp = self._set_streams(
{"front_door": [_EXISTING_SRC, "ffmpeg:front_door#audio=opus"]}
)
assert resp.status_code == 200, resp.text
assert _EXISTING_SRC in self._read_config()
def test_set_allows_injected_source_when_override_set(self):
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 200, resp.text
# --- config/save ---
def test_save_rejects_injected_ffmpeg_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_exec_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": "exec:/tmp/something"})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_map_source(self):
before = self._read_config()
resp = self._save_streams({"new_stream": {"url": "rtsp://10.0.0.2/video"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_resubmitting_existing_config_allowed(self):
# the UI config editor always sends back the whole file
resp = self._save_streams({"new_stream": "rtsp://10.0.0.2:554/video"})
assert resp.status_code == 200, resp.text
config = self._read_config()
assert _EXISTING_SRC in config
assert "rtsp://10.0.0.2:554/video" in config
+49
View File
@@ -1038,6 +1038,55 @@ def is_restricted_go2rtc_api_source(stream_source: str) -> bool:
return not is_go2rtc_arbitrary_exec_allowed()
def _go2rtc_stream_sources(stream: Any) -> list[Any]:
return stream if isinstance(stream, list) else [stream]
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.
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`.
"""
# 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 = []
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
restricted.append(name)
break
try:
resolved_source = substitute_frigate_vars(source)
except (KeyError, ValueError):
resolved_source = source
if is_restricted_go2rtc_api_source(
source
) or is_restricted_go2rtc_api_source(resolved_source):
restricted.append(name)
break
return restricted
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)