Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4iDiYSpWJD13YQUdNKC5o
This commit is contained in:
Blake Blackshear
2026-11-07 07:49:43 -06:00
co-authored by Claude Opus 5.5
22 changed files with 558 additions and 103 deletions
@@ -94,6 +94,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):
@@ -224,3 +241,108 @@ 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("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "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
# --- go2rtc ${VAR} expansion ---
def test_set_rejects_var_expansion_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "${NOPE:exec:touch /tmp/pwned}"})
assert resp.status_code == 400
assert "Restricted go2rtc stream source" in resp.json()["message"]
assert self._read_config() == before
def test_set_rejects_escaped_var_expansion_source(self):
before = self._read_config()
resp = self._set_streams({"new_stream": "${{NOPE:exec:id}}"})
assert resp.status_code == 400
assert "Restricted go2rtc stream source" in resp.json()["message"]
assert self._read_config() == before
def test_set_allows_var_expansion_source_when_override_set(self):
with patch(
"frigate.util.services.is_go2rtc_arbitrary_exec_allowed",
return_value=True,
):
resp = self._set_streams({"new_stream": "${NOPE:exec:id}"})
assert resp.status_code == 200, resp.text
@@ -0,0 +1,63 @@
"""Tests for camera watch ownership in the chat tools."""
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from frigate.api.chat import _execute_start_camera_watch, _execute_stop_camera_watch
from frigate.jobs.vlm_watch import VLMWatchJob
def _request(user: str, role: str) -> SimpleNamespace:
return SimpleNamespace(headers={"remote-user": user, "remote-role": role})
class TestStopCameraWatch(unittest.TestCase):
def setUp(self):
job = VLMWatchJob(camera="front", condition="person", username="alice")
patch("frigate.api.chat.get_vlm_watch_job", return_value=job).start()
self.stop = patch(
"frigate.api.chat.stop_vlm_watch_job", return_value=True
).start()
self.addCleanup(patch.stopall)
def test_other_user_cannot_cancel(self):
result = _execute_stop_camera_watch(_request("bob", "viewer"))
self.assertFalse(result["success"])
self.stop.assert_not_called()
def test_creator_can_cancel(self):
result = _execute_stop_camera_watch(_request("alice", "viewer"))
self.assertTrue(result["success"])
self.stop.assert_called_once()
def test_admin_can_cancel(self):
result = _execute_stop_camera_watch(_request("bob", "admin"))
self.assertTrue(result["success"])
self.stop.assert_called_once()
class TestStartCameraWatch(unittest.TestCase):
@patch("frigate.api.chat.require_camera_access", new_callable=AsyncMock)
@patch("frigate.api.chat.start_vlm_watch_job", return_value="job1")
def test_records_caller_as_owner(self, start, _access):
request = _request("alice", "viewer")
request.app = MagicMock()
request.app.frigate_config.cameras = {"front": None}
request.app.genai_manager.chat_client.supports_vision = True
asyncio.run(
_execute_start_camera_watch(
request, {"camera": "front", "condition": "person"}
)
)
self.assertEqual(start.call_args.kwargs["username"], "alice")
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -15,6 +15,7 @@ from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.detectors.device import build_detector_config, runner_names
from frigate.util.builtin import deep_merge
from frigate.util.config import resolve_ffmpeg_path
class TestConfig(unittest.TestCase):
@@ -2377,5 +2378,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)
+88
View File
@@ -0,0 +1,88 @@
"""Tests for the go2rtc ffmpeg and ngrok options applied at startup."""
import unittest
from unittest.mock import patch
from frigate.util.services import (
apply_go2rtc_ffmpeg_config,
is_restricted_go2rtc_api_source,
is_restricted_go2rtc_source,
)
FFMPEG_PATH = "/usr/lib/ffmpeg/7.0/bin/ffmpeg"
class TestApplyGo2rtcFfmpegConfig(unittest.TestCase):
def setUp(self):
# the override is snapshotted from the environment at import time
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
exec_patch.start()
self.addCleanup(exec_patch.stop)
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("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
assert apply_go2rtc_ffmpeg_config(config, FFMPEG_PATH) == []
assert config["ffmpeg"]["bin"] == "/usr/bin/ffmpeg"
assert config["ngrok"] == {"command": "ngrok tcp 8555"}
class TestRestrictedGo2rtcVarExpansion(unittest.TestCase):
"""go2rtc expands `${VAR:default}` over the raw config before parsing, and
the default can name an exec source, so a `${` source is restricted."""
def setUp(self):
exec_patch = patch(
"frigate.util.services.is_go2rtc_arbitrary_exec_allowed",
return_value=False,
)
self.mock_exec_allowed = exec_patch.start()
self.addCleanup(exec_patch.stop)
def test_var_expansion_source_is_restricted(self):
for source in (
"${NOPE:exec:touch /tmp/x}",
"rtsp://host/${CAM}",
" ${NOPE:exec:id}",
):
self.assertTrue(is_restricted_go2rtc_source(source), source)
self.assertTrue(is_restricted_go2rtc_api_source(source), source)
def test_plain_sources_are_not_restricted(self):
for source in (
"rtsp://user:pass@host/stream",
"ffmpeg:front#video=h264#height=720",
):
self.assertFalse(is_restricted_go2rtc_source(source), source)
self.assertFalse(is_restricted_go2rtc_api_source(source), source)
def test_var_expansion_allowed_when_override_set(self):
self.mock_exec_allowed.return_value = True
self.assertFalse(is_restricted_go2rtc_source("${NOPE:exec:id}"))
if __name__ == "__main__":
unittest.main()