From 12d9ab15c29bd530ffa0f93f79e2405740c409f1 Mon Sep 17 00:00:00 2001 From: Blake Blackshear Date: Wed, 14 Oct 2026 07:10:49 -0500 Subject: [PATCH] Restrict ffmpeg: go2rtc sources added through the API go2rtc expands ffmpeg: sources into an exec: command line built from the source's #key=value parameters, so the echo/expr/exec prefix check on PUT /go2rtc/streams could be bypassed to run arbitrary ffmpeg arguments. Sources submitted through the API now only accept the ffmpeg: parameters the UI and docs use (video, audio, hardware with preset names, plus numeric width/height/rotate/bitrate/timeout and async). Quotes, whitespace, #raw, #input, #drawtext and unknown parameters are rejected unless GO2RTC_ALLOW_ARBITRARY_EXEC is set. Sources defined in the config file are unchanged. Fixes GHSA-qjjw-3v66-279f Co-Authored-By: Claude Opus 5.5 --- frigate/api/camera.py | 10 +-- .../test/http_api/test_http_camera_access.py | 66 +++++++++++++++++++ frigate/test/test_env.py | 8 +-- frigate/util/services.py | 52 +++++++++++++++ 4 files changed, 125 insertions(+), 11 deletions(-) diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 6498901b97..a23fe58e60 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -46,7 +46,7 @@ from frigate.util.image import run_ffmpeg_snapshot from frigate.util.services import ( analyze_record_keyframes, ffprobe_stream, - is_restricted_go2rtc_source, + is_restricted_go2rtc_api_source, ) logger = logging.getLogger(__name__) @@ -152,9 +152,9 @@ def go2rtc_camera_stream(request: Request, stream_name: str): ) def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): """Add or update a go2rtc stream configuration.""" - if src and is_restricted_go2rtc_source(src): + if src and is_restricted_go2rtc_api_source(src): logger.warning( - "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + "Rejected go2rtc stream '%s' with restricted source", stream_name, ) return JSONResponse( @@ -173,9 +173,9 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): except KeyError: resolved_src = src - if is_restricted_go2rtc_source(resolved_src): + if is_restricted_go2rtc_api_source(resolved_src): logger.warning( - "Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)", + "Rejected go2rtc stream '%s' with restricted source", stream_name, ) return JSONResponse( diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 4c74792c14..ba3f79d3e9 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -403,6 +403,72 @@ class TestGo2rtcStreamAccess(BaseTestHttp): forwarded_src = mock_put.call_args.kwargs["params"]["src"] assert forwarded_src == "exec:/tmp/something" + def test_add_stream_rejects_ffmpeg_argument_injection(self): + """PUT /go2rtc/streams must reject ffmpeg: sources whose parameters can + inject arbitrary ffmpeg arguments, since go2rtc expands them to exec:""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + with patch("frigate.api.camera.requests.put") as mock_put: + with AuthTestClient(app) as client: + for src in ( + # shape of the source in GHSA-qjjw-3v66-279f + "ffmpeg:#input='-f''h264''-i''data:placeholder''-c''copy'", + "ffmpeg:#input=-re", + "ffmpeg:front_door#raw=-vn", + "ffmpeg:front_door#video=-vn", + "ffmpeg:front_door#video=out.mp4", + "ffmpeg:front_door#drawtext=x", + "ffmpeg:front_door#width=1x", + "ffmpeg:'front_door'", + " ffmpeg:#input=-re", + ): + resp = client.put("/go2rtc/streams/injected", params={"src": src}) + assert resp.status_code == 400, ( + f"Expected 400 for ffmpeg src {src!r}; got {resp.status_code}" + ) + assert resp.json().get("success") is False + mock_put.assert_not_called() + + def test_add_stream_allows_standard_ffmpeg_sources(self): + """ffmpeg: sources built by the UI and shown in the docs must still be + forwarded to go2rtc""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() + with patch( + "frigate.api.camera.requests.put", return_value=mock_response + ) as mock_put: + with AuthTestClient(app) as client: + for src in ( + "ffmpeg:rtsp://10.0.0.1:554/video", + "ffmpeg:rtsp://10.0.0.1:554/video#video=copy#audio=copy", + "ffmpeg:rtsp://10.0.0.1:554/video#video=h264#hardware", + "ffmpeg:front_door#audio=opus", + "ffmpeg:front_door#video=h264#hardware=vaapi", + "ffmpeg:front_door#video=h264#width=1280#rotate=-90", + "ffmpeg:http://10.0.0.1/flv?port=1935&app=bcs#video=copy", + ): + resp = client.put("/go2rtc/streams/legit", params={"src": src}) + assert resp.status_code == 200, ( + f"Expected 200 for ffmpeg src {src!r}; got {resp.status_code}" + ) + forwarded_src = mock_put.call_args.kwargs["params"]["src"] + assert forwarded_src == src + + def test_add_stream_allows_ffmpeg_injection_when_override_set(self): + """GO2RTC_ALLOW_ARBITRARY_EXEC also lifts the ffmpeg: parameter check""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})() + with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}): + with patch( + "frigate.api.camera.requests.put", return_value=mock_response + ) as mock_put: + with AuthTestClient(app) as client: + resp = client.put( + "/go2rtc/streams/legit", + params={"src": "ffmpeg:front_door#raw=-vn"}, + ) + assert resp.status_code == 200 + mock_put.assert_called_once() + def test_stream_alias_blocked_when_owning_camera_disallowed(self): """limited_user cannot access a stream alias that belongs to a camera they are not allowed to see.""" diff --git a/frigate/test/test_env.py b/frigate/test/test_env.py index 37b81a6564..be7f51df43 100644 --- a/frigate/test/test_env.py +++ b/frigate/test/test_env.py @@ -49,14 +49,10 @@ class TestGo2RtcAddStreamSubstitution(unittest.TestCase): def test_mixed_localtime_and_frigate_var(self): """%{localtime\\:...} alongside {FRIGATE_USER} substitutes only the var.""" FRIGATE_ENV_VARS["FRIGATE_USER"] = "admin" - src = ( - "ffmpeg:rtsp://host/s#raw=-vf " - "drawtext=text=%{localtime\\:%Y-%m-%d}:user={FRIGATE_USER}" - ) + src = "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user={FRIGATE_USER}" self.assertEqual( self._call_route(src), - "ffmpeg:rtsp://host/s#raw=-vf " - "drawtext=text=%{localtime\\:%Y-%m-%d}:user=admin", + "rtsp://host/s?text=%{localtime\\:%Y-%m-%d}&user=admin", ) def test_unknown_var_falls_back_to_raw_src(self): diff --git a/frigate/util/services.py b/frigate/util/services.py index de17e29711..79faa03b10 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -986,6 +986,58 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool: return not is_go2rtc_arbitrary_exec_allowed() +# go2rtc expands an `ffmpeg:` source into an `exec:` command line built from its +# `#key=value` parameters, so only parameters that map to fixed presets or plain +# numbers are accepted from the API +_GO2RTC_FFMPEG_NAME_PARAMS = frozenset({"video", "audio", "hardware"}) +_GO2RTC_FFMPEG_NUMERIC_PARAMS = frozenset( + {"width", "height", "rotate", "bitrate", "timeout"} +) +_GO2RTC_FFMPEG_FLAG_PARAMS = frozenset({"async"}) +_GO2RTC_FFMPEG_NAME_VALUE = re.compile(r"^[A-Za-z0-9_]*$") +_GO2RTC_FFMPEG_NUMERIC_VALUE = re.compile(r"^-?[0-9]+[kKmM]?$") + + +def _is_safe_go2rtc_ffmpeg_source(stream_source: str) -> bool: + """Check that an `ffmpeg:` source only uses allowlisted parameters.""" + if any(c in stream_source for c in "'\"") or re.search(r"\s", stream_source): + return False + + _, *params = stream_source.split("#") + for param in params: + key, has_value, value = param.partition("=") + if key in _GO2RTC_FFMPEG_NAME_PARAMS: + if not _GO2RTC_FFMPEG_NAME_VALUE.match(value): + return False + elif key in _GO2RTC_FFMPEG_NUMERIC_PARAMS: + if not _GO2RTC_FFMPEG_NUMERIC_VALUE.match(value): + return False + elif key in _GO2RTC_FFMPEG_FLAG_PARAMS: + if has_value: + return False + else: + return False + + return True + + +def is_restricted_go2rtc_api_source(stream_source: str) -> bool: + """Check if a stream source submitted through the API is restricted. + + In addition to the echo/expr/exec sources, this restricts `ffmpeg:` sources + whose parameters could inject arbitrary ffmpeg arguments. The + GO2RTC_ALLOW_ARBITRARY_EXEC override lifts both restrictions. + """ + source = stream_source.strip() + if is_restricted_go2rtc_source(source): + return True + if not source.startswith("ffmpeg:"): + return False + if _is_safe_go2rtc_ffmpeg_source(source[len("ffmpeg:") :]): + return False + return not is_go2rtc_arbitrary_exec_allowed() + + def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: """Run ffprobe on stream.""" clean_path = escape_special_characters(path)