diff --git a/frigate/api/app.py b/frigate/api/app.py index 0af3bc1aba..68dd854168 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -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( diff --git a/frigate/test/http_api/test_http_config_go2rtc_streams.py b/frigate/test/http_api/test_http_config_go2rtc_streams.py new file mode 100644 index 0000000000..ba71110e53 --- /dev/null +++ b/frigate/test/http_api/test_http_config_go2rtc_streams.py @@ -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 diff --git a/frigate/util/services.py b/frigate/util/services.py index 79faa03b10..36691e5e18 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -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)