mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-14 18:52:47 +03:00
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:
co-authored by
Claude Opus 5.5
parent
3bbac1be48
commit
9b165948c3
+62
-2
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user