Restrict go2rtc ffmpeg and ngrok options without arbitrary exec (#24709)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* restrict go2rtc ffmpeg and ngrok options without arbitrary exec

The GO2RTC_ALLOW_ARBITRARY_EXEC checks only looked at go2rtc.streams, so an admin could save a config with a custom ffmpeg.bin or ngrok.command and run any command as the go2rtc user after a restart. go2rtc builds every ffmpeg: source into a command that starts with ffmpeg.bin, and it runs ngrok.command at startup. A custom template also brought back arbitrary ffmpeg arguments, since a stream can reference any template key with #video=<name>.

config/save and config/set now reject any added or changed key under go2rtc.ffmpeg or go2rtc.ngrok unless arbitrary exec is enabled. Keys already in the saved config are left alone, the same as stream sources. At startup, ffmpeg.bin is always Frigate's ffmpeg and ngrok is dropped unless arbitrary exec is enabled. Templates already in the config file, such as the documented vaapi global, keep working.

* reject ffmpeg paths with spaces, quotes or shell characters

ffmpeg.path was used as-is when absolute, and several ffmpeg commands are built as strings and split on spaces before running. A path like `/bin/sh -c <cmd>;#` ran a shell command instead of ffmpeg in the preview and export processes, and in go2rtc through the birdseye restream exec source. The path is now validated as a version alias or an absolute path of plain characters, so config/save and config/set reject an unsafe value. resolve_ffmpeg_path falls back to the default ffmpeg for one, and the go2rtc startup script prints an error.
This commit is contained in:
Josh Hawkins
2026-10-18 06:41:38 -10:00
committed by GitHub
parent aa42b17911
commit cf418dd6b6
8 changed files with 328 additions and 35 deletions
@@ -85,6 +85,23 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
headers={"Content-Type": "text/plain"},
)
def _set_go2rtc(self, go2rtc: dict):
with AuthTestClient(self.app) as client:
return client.put(
"/config/set",
json={"config_data": {"go2rtc": go2rtc}, "requires_restart": 1},
)
def _save_go2rtc(self, go2rtc: dict):
config = copy.deepcopy(_CONFIG)
config["go2rtc"].update(go2rtc)
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):
@@ -215,3 +232,84 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 200, resp.text
assert src in self._read_config()
# --- go2rtc ffmpeg and ngrok options ---
def test_save_rejects_ffmpeg_bin_override(self):
before = self._read_config()
resp = self._save_go2rtc({"ffmpeg": {"bin": "/bin/sh", "global": "-c 'id' x"}})
assert resp.status_code == 400
assert resp.json()["message"] == (
"Restricted go2rtc option: ffmpeg.bin, ffmpeg.global"
)
assert self._read_config() == before
def test_set_rejects_ffmpeg_bin_override(self):
before = self._read_config()
resp = self._set_go2rtc({"ffmpeg": {"bin": "/bin/sh"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_ffmpeg_template_override(self):
before = self._read_config()
resp = self._save_go2rtc({"ffmpeg": {"rtsp": "-i {input} -y /config/x"}})
assert resp.status_code == 400
assert "ffmpeg.rtsp" in resp.json()["message"]
assert self._read_config() == before
def test_set_rejects_custom_ffmpeg_template(self):
# a stream can reference any template key with #video=<name>
before = self._read_config()
resp = self._set_go2rtc({"ffmpeg": {"evil": "-y /config/x"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_rejects_ngrok(self):
before = self._read_config()
resp = self._save_go2rtc({"ngrok": {"command": "/bin/sh -c id"}})
assert resp.status_code == 400
assert resp.json()["message"] == "Restricted go2rtc option: ngrok.command"
assert self._read_config() == before
def test_set_rejects_ngrok(self):
before = self._read_config()
resp = self._set_go2rtc({"ngrok": {"command": "/bin/sh -c id"}})
assert resp.status_code == 400
assert self._read_config() == before
def test_save_keeps_existing_ffmpeg_template_on_disk(self):
go2rtc = {"ffmpeg": {"global": "-vaapi_device /dev/dri/renderD128"}}
config = copy.deepcopy(_CONFIG)
config["go2rtc"].update(go2rtc)
with open(self.config_path, "w") as f:
f.write(_dump(config))
resp = self._save_go2rtc(go2rtc)
assert resp.status_code == 200, resp.text
resp = self._save_go2rtc({"ffmpeg": {"global": "-hide_banner"}})
assert resp.status_code == 400
def test_exec_options_allowed_when_override_set(self):
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
resp = self._save_go2rtc(
{
"ffmpeg": {"bin": "/usr/bin/ffmpeg", "rtsp": "-i {input}"},
"ngrok": {"command": "ngrok tcp 8555"},
}
)
assert resp.status_code == 200, resp.text
assert "ngrok tcp 8555" in self._read_config()
def test_save_rejects_unsafe_ffmpeg_path(self):
before = self._read_config()
config = copy.deepcopy(_CONFIG)
config["ffmpeg"] = {"path": "/bin/sh -c id;#"}
with AuthTestClient(self.app) as client:
resp = client.post(
"/config/save?save_option=saveonly",
content=_dump(config),
headers={"Content-Type": "text/plain"},
)
assert resp.status_code == 400
assert self._read_config() == before
+50
View File
@@ -11,6 +11,7 @@ from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.util.builtin import deep_merge
from frigate.util.config import resolve_ffmpeg_path
class TestConfig(unittest.TestCase):
@@ -1765,5 +1766,54 @@ class TestAttributeFilterDefaults(unittest.TestCase):
self.assertEqual(face_filter.min_score, 0.3)
class TestFfmpegPath(unittest.TestCase):
# ffmpeg commands are split on spaces, so this would run /bin/sh
UNSAFE_PATH = "/bin/sh -c id;#"
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
}
},
}
def test_custom_path_allowed(self):
config = deep_merge(
{"ffmpeg": {"path": "/usr/lib/jellyfin-ffmpeg"}}, self.minimal
)
frigate_config = FrigateConfig(**config)
self.assertEqual(
frigate_config.cameras["back"].ffmpeg.ffmpeg_path,
"/usr/lib/jellyfin-ffmpeg/bin/ffmpeg",
)
def test_unsafe_global_path_rejected(self):
for path in (self.UNSAFE_PATH, "/opt/my ffmpeg", "/opt/'x'", "/opt/x\n"):
config = deep_merge({"ffmpeg": {"path": path}}, self.minimal)
with self.assertRaises(ValidationError, msg=path):
FrigateConfig(**config)
def test_unsafe_camera_path_rejected(self):
config = deep_merge(
{"cameras": {"back": {"ffmpeg": {"path": self.UNSAFE_PATH}}}},
self.minimal,
)
with self.assertRaises(ValidationError):
FrigateConfig(**config)
def test_resolve_falls_back_for_unsafe_path(self):
self.assertEqual(
resolve_ffmpeg_path(self.UNSAFE_PATH), resolve_ffmpeg_path("default")
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the go2rtc ffmpeg and ngrok options applied at startup."""
import os
import unittest
from unittest.mock import patch
from frigate.util.services import apply_go2rtc_ffmpeg_config
FFMPEG_PATH = "/usr/lib/ffmpeg/7.0/bin/ffmpeg"
class TestApplyGo2rtcFfmpegConfig(unittest.TestCase):
def setUp(self):
env_patch = patch.dict(os.environ)
env_patch.start()
self.addCleanup(env_patch.stop)
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
def test_sets_bin_when_missing(self):
config = {}
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
assert config["ffmpeg"] == {"bin": FFMPEG_PATH}
def test_forces_bin_override(self):
config = {"ffmpeg": {"bin": "/bin/sh", "global": "-c 'id' x"}}
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == ["ffmpeg.bin"]
assert config["ffmpeg"]["bin"] == FFMPEG_PATH
def test_keeps_saved_templates(self):
config = {"ffmpeg": {"global": "-vaapi_device /dev/dri/renderD128"}}
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
assert config["ffmpeg"]["global"] == "-vaapi_device /dev/dri/renderD128"
def test_drops_ngrok(self):
config = {"ngrok": {"command": "/bin/sh -c id"}}
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == ["ngrok"]
assert "ngrok" not in config
def test_allows_bin_and_ngrok_when_override_set(self):
config = {
"ffmpeg": {"bin": "/usr/bin/ffmpeg"},
"ngrok": {"command": "ngrok tcp 8555"},
}
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
assert config["ffmpeg"]["bin"] == "/usr/bin/ffmpeg"
assert config["ngrok"] == {"command": "ngrok tcp 8555"}
if __name__ == "__main__":
unittest.main()