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 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-10-14 07:10:49 -05:00
co-authored by Claude Opus 5.5
parent 7badf191e3
commit 12d9ab15c2
4 changed files with 125 additions and 11 deletions
+5 -5
View File
@@ -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(
@@ -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."""
+2 -6
View File
@@ -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):
+52
View File
@@ -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)