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

Brings the go2rtc security fixes from #24658 to dev. Dev-specific changes
in the merge:
- frigate/config/env.py stays deleted; the environment_vars protection for
  GO2RTC_ALLOW_ARBITRARY_EXEC moves to apply_config_env_vars in
  frigate/util/env.py, so the value never reaches os.environ
- services.py imports substitute_frigate_vars from frigate.util.env and
  maps UnknownVariableError to the unknown variable message
- go2rtc stream tests patch dev's import-time exec override snapshot and
  its FRIGATE_* variable sources
- test_http_media.py keeps dev's _insert_recording helper; the one added
  on master replaced it and broke the dev tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-10-14 14:30:11 -05:00
co-authored by Claude Opus 5.5
39 changed files with 1524 additions and 152 deletions
@@ -417,6 +417,77 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
assert resp.status_code == 400
assert resp.json().get("success") is False
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#audio=/16000",
"ffmpeg:front_door#audio=opus/",
"ffmpeg:front_door#audio=opus/16000/1",
"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",
# documented HomeKit source
"ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000",
"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("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "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."""
@@ -0,0 +1,226 @@
"""Tests for go2rtc stream source restrictions on config/save and config/set."""
import contextlib
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
from frigate.util.env import _CONTAINER_ENV, FRIGATE_ENV_VARS, reload_sources
# 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},
},
},
}
@contextlib.contextmanager
def _frigate_vars(values: dict[str, str]):
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
with patch.dict(_CONTAINER_ENV, values), patch.dict(FRIGATE_ENV_VARS, values):
yield
reload_sources(warn=False)
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)
# the override is read once at import; make sure it is off here
exec_patch = patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", None)
exec_patch.start()
self.addCleanup(exec_patch.stop)
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, prefix: str = ""):
config = copy.deepcopy(_CONFIG)
config["go2rtc"]["streams"].update(streams)
with AuthTestClient(self.app) as client:
return client.post(
"/config/save?save_option=saveonly",
content=prefix + _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("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "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
def test_save_tagged_config_with_standard_source_allowed(self):
# a tag on the root map still passes validation and startup parsing
resp = self._save_streams(
{"new_stream": "rtsp://10.0.0.2:554/video"}, prefix="!config\n"
)
assert resp.status_code == 200, resp.text
def test_save_tagged_config_with_injected_source_rejected(self):
before = self._read_config()
resp = self._save_streams({"new_stream": [_INJECTED_SRC]}, prefix="!config\n")
assert resp.status_code == 400
assert "Restricted go2rtc stream source" in resp.json()["message"]
assert self._read_config() == before
# --- {FRIGATE_*} placeholders resolve the same way as at go2rtc startup ---
def test_set_rejects_unresolvable_placeholder(self):
before = self._read_config()
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
for src, message in (
(
"{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"{FRIGATE_RTSP_USER:>5}",
"Invalid variable placeholder in go2rtc stream: new_stream",
),
(
"rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video",
"Unknown variable in go2rtc stream: new_stream",
),
):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 400, src
assert resp.json()["message"] == message, src
assert self._read_config() == before
def test_set_reports_each_issue(self):
resp = self._set_streams(
{
"typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"],
"injected_stream": [_INJECTED_SRC],
}
)
assert resp.status_code == 400
assert resp.json()["message"] == (
"Restricted go2rtc stream source in: injected_stream; "
"Unknown variable in go2rtc stream: typo_stream"
)
def test_set_allows_resolvable_placeholder(self):
src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video"
with _frigate_vars({"FRIGATE_RTSP_USER": "user"}):
resp = self._set_streams({"new_stream": [src]})
assert resp.status_code == 200, resp.text
assert src in self._read_config()
+60
View File
@@ -4,6 +4,7 @@ import tempfile
import zipfile
from unittest.mock import patch
from frigate.const import CLIPS_DIR
from frigate.jobs.export import (
ExportJob,
get_export_job_manager,
@@ -1051,6 +1052,65 @@ class TestHttpExport(BaseTestHttp):
assert response.status_code == 400
assert ExportCase.select().count() == 0
def _batch_export_with_image(self, image_path: str, role: str):
with patch(
"frigate.api.export.start_export_job",
side_effect=lambda _config, job: job.id,
) as start_export_job:
with AuthTestClient(self.app) as client:
response = client.post(
"/exports/batch",
headers={"remote-user": role, "remote-role": role},
json={
"items": [
{
"camera": "front_door",
"start_time": 110,
"end_time": 150,
"image_path": image_path,
}
],
},
)
return response, start_export_job
def test_batch_export_restricted_role_rejects_unreadable_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
for image_path in (
f"{CLIPS_DIR}/review/thumb-backyard-123.456-abc.webp",
f"{CLIPS_DIR}/thumbs/backyard/123.webp",
f"{CLIPS_DIR}/faces/someone/1.webp",
f"{CLIPS_DIR}/custom/thumb.jpg",
):
response, _ = self._batch_export_with_image(image_path, "limited_user")
assert response.status_code == 400, image_path
def test_batch_export_restricted_role_accepts_own_camera_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
self.app.frigate_config.auth.roles["limited_user"] = ["front_door"]
image_path = f"{CLIPS_DIR}/review/thumb-front_door-123.456-abc.webp"
response, start_export_job = self._batch_export_with_image(
image_path, "limited_user"
)
assert response.status_code == 202
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_unrestricted_roles_accept_custom_image_path(self):
self._insert_recording("rec-front", "front_door", 100, 400)
image_path = f"{CLIPS_DIR}/custom/thumb.jpg"
for role in ("admin", "viewer"):
response, start_export_job = self._batch_export_with_image(image_path, role)
assert response.status_code == 202, role
assert start_export_job.call_args.args[1].image_path == image_path
def test_batch_export_non_admin_can_queue(self):
self._insert_recording("rec-front", "front_door", 100, 400)
@@ -0,0 +1,73 @@
"""Tests for the /auth subrequest on nginx-proxied go2rtc live-stream paths."""
import time
from unittest.mock import patch
import requests
from fastapi.testclient import TestClient
from joserfc.jwk import OctKey
from frigate.api.auth import create_encoded_jwt
from frigate.models import Event, Recordings, ReviewSegment, User
from frigate.test.http_api.base_http_test import BaseTestHttp
_CONFIG = {
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
},
},
}
# shape of the source in GHSA-93xj-hg88-ffg7, with the payload replaced
_AD_HOC_SRC = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
class TestGo2rtcProxyAuth(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment, Recordings, User])
self.minimal_config = _CONFIG
self.app = super().create_app()
self.app.jwt_token = OctKey.import_key(b"0" * 32)
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
get_patch.start()
self.addCleanup(get_patch.stop)
def _auth(self, role: str, original_url: str):
token = create_encoded_jwt(
role, role, int(time.time()) + 3600, self.app.jwt_token
)
with TestClient(self.app) as client:
return client.get(
"/auth",
headers={
"authorization": f"Bearer {token}",
"x-original-url": original_url,
},
)
def test_viewer_ad_hoc_mse_source_denied(self):
resp = self._auth("viewer", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_ad_hoc_webrtc_sources_denied(self):
for path in ("/live/webrtc/api/ws", "/api/go2rtc/webrtc"):
resp = self._auth("viewer", f"{path}?src={_AD_HOC_SRC}")
assert resp.status_code == 403, path
def test_admin_ad_hoc_mse_source_denied(self):
resp = self._auth("admin", f"/live/mse/api/ws?src={_AD_HOC_SRC}")
assert resp.status_code == 403
def test_viewer_camera_stream_allowed(self):
resp = self._auth("viewer", "/live/mse/api/ws?src=front_door")
assert resp.status_code == 202
assert resp.headers["remote-role"] == "viewer"
+78
View File
@@ -1,5 +1,7 @@
"""Unit tests for recordings/media API endpoints."""
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
@@ -2019,3 +2021,79 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def test_recording_snapshot_does_not_block_event_loop(self):
self._insert_recording("snapshot", 1000, 1010)
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_image(*args):
started.set()
release.wait(timeout=5)
finished.set()
return b"jpeg"
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", slow_image),
ThreadPoolExecutor(max_workers=1) as executor,
):
snapshot = executor.submit(
client.get, "/front_door/recordings/1005/snapshot.jpg"
)
self.assertTrue(started.wait(timeout=2))
version = client.get("/version")
blocked = finished.is_set()
release.set()
self.assertEqual(version.status_code, 200)
self.assertFalse(blocked)
self.assertEqual(snapshot.result().status_code, 200)
def test_recording_snapshot_hit_returns_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
):
response = client.get("/front_door/recordings/1005/snapshot.jpg")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.content, b"jpeg")
def test_recording_snapshot_misses_return_404(self):
self._insert_recording("snapshot", 1000, 1010)
with AuthTestClient(self.app) as client:
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
self.assertEqual(absent.status_code, 404)
self.assertEqual(rounded_miss.status_code, 404)
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording") as image,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 404)
image.assert_not_called()
def test_plus_snapshot_hit_uploads_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 200)
upload.assert_called_once_with("frame", "front_door")
+33
View File
@@ -0,0 +1,33 @@
"""Tests for user creation."""
from unittest.mock import MagicMock
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
PASSWORD = "a-valid-password-123"
class TestCreateUser(BaseTestHttp):
def setUp(self):
super().setUp([User])
self.app = super().create_app()
self.app.config_publisher = MagicMock()
def _create(self, username: str):
with AuthTestClient(self.app) as client:
return client.post(
"/users",
json={"username": username, "password": PASSWORD, "role": "viewer"},
)
def test_rejects_dot_only_usernames(self):
# browsers resolve these as URL path segments, so the per-user
# endpoints can never be reached for them
for username in (".", "..", "..."):
assert self._create(username).status_code == 400
assert User.get_or_none(User.username == username) is None
def test_accepts_username_containing_dots(self):
assert self._create("john.doe_1").status_code == 200
assert User.get_or_none(User.username == "john.doe_1") is not None
+193
View File
@@ -0,0 +1,193 @@
"""Tests for sharing one zmq socket wrapper across threads."""
import os
import random
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
import zmq
from frigate.comms import config_updater, embeddings_updater, inter_process
from frigate.comms.config_updater import ConfigPublisher, ConfigSubscriber
from frigate.comms.embeddings_updater import EmbeddingsRequestor
from frigate.comms.inter_process import InterProcessRequestor
from frigate.comms.webpush import WebPushClient
THREADS = 8
CALLS = 100
# nothing reads until the end, so the total stays under the default zmq HWM
PUBLISH_CALLS = 100
class TestSharedRequestor(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
self.stop = threading.Event()
self.context = zmq.Context()
self.responder = self.context.socket(zmq.REP)
self.responder.bind(self.address)
self.responder_thread = threading.Thread(target=self._respond)
self.responder_thread.start()
def tearDown(self) -> None:
self.stop.set()
self.responder_thread.join()
self.responder.close(linger=0)
self.context.destroy(linger=0)
self.tmp.cleanup()
def _respond(self) -> None:
while not self.stop.is_set():
ready, _, _ = zmq.select([self.responder], [], [], 0.1)
if ready:
self.responder.recv_json()
time.sleep(random.uniform(0, 0.002))
self.responder.send_json(["ok"])
def _call_from_threads(self, requestor) -> list:
results: list = []
def call() -> None:
for _ in range(CALLS):
# jitter lands some calls on the instant another reply arrives
time.sleep(random.uniform(0, 0.002))
results.append(requestor.send_data("topic", {"key": "value"}))
threads = [threading.Thread(target=call, daemon=True) for _ in range(THREADS)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(30)
self.assertFalse(any(thread.is_alive() for thread in threads))
return results
def test_inter_process_requestor_waits_for_overlapping_calls(self) -> None:
with patch.object(inter_process, "SOCKET_REP_REQ", self.address):
requestor = InterProcessRequestor()
results = self._call_from_threads(requestor)
self.assertEqual(results, [["ok"]] * THREADS * CALLS)
requestor.stop()
def test_embeddings_requestor_survives_overlapping_calls(self) -> None:
with patch.object(embeddings_updater, "SOCKET_REP_REQ", self.address):
requestor = EmbeddingsRequestor()
# an overlapping call may fail fast, but the socket has to stay usable
results = self._call_from_threads(requestor)
self.assertEqual(len(results), THREADS * CALLS)
self.assertEqual(requestor.send_data("topic", {}), ["ok"])
requestor.stop()
class TestSharedConfigPublisher(unittest.TestCase):
def test_frames_stay_paired_across_threads(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
address = f"ipc://{os.path.join(tmp, 'config')}"
with patch.object(config_updater, "SOCKET_PUB_SUB", address):
publisher = ConfigPublisher()
subscriber = ConfigSubscriber("config/")
# a subscription that is still connecting drops messages
subscriber.socket.setsockopt(zmq.RCVTIMEO, 5000)
while True:
publisher.publish("config/ready", None)
if zmq.select([subscriber.socket], [], [], 0.05)[0]:
break
def publish(index: int) -> None:
topic = f"config/cameras/camera_{index}"
for _ in range(PUBLISH_CALLS):
publisher.publish(topic, topic)
threads = [
threading.Thread(target=publish, args=(i,)) for i in range(THREADS)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
publisher.publish("config/done", None)
received = 0
while True:
topic = subscriber.socket.recv_string()
payload = subscriber.socket.recv_pyobj()
if topic == "config/done":
break
if topic != "config/ready":
self.assertEqual(topic, payload)
received += 1
self.assertEqual(received, THREADS * PUBLISH_CALLS)
subscriber.stop()
publisher.stop()
class FakeConfigSubscriber:
"""Records whether two threads read at the same time."""
def __init__(self) -> None:
self.reading = threading.Lock()
self.overlapped = False
def _read(self) -> None:
if not self.reading.acquire(blocking=False):
self.overlapped = True
return
time.sleep(0.001)
self.reading.release()
def check_for_update(self) -> tuple[None, None]:
self._read()
return (None, None)
def check_for_updates(self) -> dict:
self._read()
return {}
class TestSharedWebPushClient(unittest.TestCase):
def test_config_subscribers_read_by_one_thread_at_a_time(self) -> None:
client = WebPushClient.__new__(WebPushClient)
client.config_lock = threading.Lock()
client.global_config_subscriber = FakeConfigSubscriber()
client.config_subscriber = FakeConfigSubscriber()
def publish() -> None:
for _ in range(20):
client.publish("topic", "payload")
threads = [threading.Thread(target=publish) for _ in range(THREADS)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(client.global_config_subscriber.overlapped)
self.assertFalse(client.config_subscriber.overlapped)
if __name__ == "__main__":
unittest.main()
+25 -6
View File
@@ -54,14 +54,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):
@@ -251,6 +247,29 @@ class TestEnvVars(unittest.TestCase):
result = validate_env_string("{FRIGATE_BROKER}")
self.assertEqual(result, "mqtt.local")
def test_install_ignores_go2rtc_exec_override(self):
"""The go2rtc exec override cannot be enabled from the config."""
from frigate.util.services import is_go2rtc_arbitrary_exec_allowed
env_patch = patch.dict(os.environ)
env_patch.start()
self.addCleanup(env_patch.stop)
os.environ.pop("GO2RTC_ALLOW_ARBITRARY_EXEC", None)
ctx = self._make_context(install=True)
with patch("frigate.util.services.os.path.isfile", return_value=False):
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
self.assertNotIn(key, os.environ)
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
def test_install_keeps_container_go2rtc_exec_override(self):
"""An override set in the container environment is left untouched."""
ctx = self._make_context(install=True)
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
validate_env_vars({"GO2RTC_ALLOW_ARBITRARY_EXEC": "false"}, ctx)
self.assertEqual(os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC"), "true")
class TestVariableSources(unittest.TestCase):
"""Precedence between the sources that feed FRIGATE_ENV_VARS."""
+122 -1
View File
@@ -8,6 +8,9 @@ param, which the static-media auth in `media_auth` does not inspect.
import types
import unittest
from unittest.mock import MagicMock, patch
import requests
from frigate.api.auth import deny_response_for_go2rtc_stream
from frigate.config import FrigateConfig
@@ -20,6 +23,7 @@ _CONFIG = {
"dual_user": ["front_door", "back_door"],
}
},
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
"cameras": {
"front_door": {
"ffmpeg": {
@@ -46,7 +50,9 @@ _CONFIG = {
def _request(config: FrigateConfig) -> types.SimpleNamespace:
return types.SimpleNamespace(app=types.SimpleNamespace(frigate_config=config))
return types.SimpleNamespace(
app=types.SimpleNamespace(frigate_config=config), headers={}
)
class TestDenyResponseForGo2rtcStream(unittest.TestCase):
@@ -54,6 +60,20 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
self.config = FrigateConfig(**_CONFIG)
self.request = _request(self.config)
# go2rtc is not running in tests; individual tests override this
get_patch = patch(
"frigate.api.auth.requests.get",
side_effect=requests.ConnectionError("go2rtc unavailable"),
)
self.mock_get = get_patch.start()
self.addCleanup(get_patch.stop)
def _go2rtc_streams(self, *names: str) -> None:
response = MagicMock(ok=True)
response.json.return_value = {name: {} for name in names}
self.mock_get.side_effect = None
self.mock_get.return_value = response
def _deny(self, url: str, role: str):
return deny_response_for_go2rtc_stream(url, role, self.request)
@@ -170,6 +190,107 @@ class TestDenyResponseForGo2rtcStream(unittest.TestCase):
def test_missing_role_bypasses(self):
self.assertIsNone(self._deny("http://host/live/mse/api/ws?src=back_door", None))
# --- ad hoc sources are denied for every role ---
def test_ad_hoc_ffmpeg_source_denied_for_all_roles(self):
# shape of the source in GHSA-93xj-hg88-ffg7
src = "ffmpeg:%23input%3D'-f''h264''-i''data:placeholder''-c''copy'"
for path in (
"/live/mse/api/ws",
"/live/webrtc/api/ws",
"/api/go2rtc/webrtc",
):
for role in ("viewer", "admin", "limited_user", None):
self.assertEqual(
self._deny(f"http://host{path}?src={src}", role),
403,
f"{path} {role}",
)
def test_ad_hoc_source_with_name_param_denied(self):
# name= would otherwise let go2rtc replace an existing stream's source
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=ffmpeg:x%23video%3Dh264"
"&name=front_door",
"admin",
),
403,
)
def test_ad_hoc_source_after_existing_src_denied(self):
self.assertEqual(
self._deny(
"http://host/live/mse/api/ws?src=front_door&src=exec:x", "viewer"
),
403,
)
def test_unknown_stream_denied_for_admin(self):
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=nonexistent", "admin"), 403
)
def test_blank_src_denied_for_admin(self):
self.assertEqual(self._deny("http://host/live/mse/api/ws?src=", "admin"), 403)
def test_go2rtc_config_stream_allowed(self):
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=doorbell_audio", "viewer")
)
self.mock_get.assert_not_called()
def test_runtime_go2rtc_stream_allowed(self):
# streams added through PUT /go2rtc/streams only exist in go2rtc
self._go2rtc_streams("front_door", "wizard_test")
self.assertIsNone(
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin")
)
def test_runtime_lookup_does_not_admit_ad_hoc_source(self):
self._go2rtc_streams("front_door", "wizard_test")
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=ffmpeg:wizard_test", "admin"),
403,
)
def test_go2rtc_error_fails_closed(self):
self.mock_get.side_effect = None
self.mock_get.return_value = MagicMock(ok=False)
self.assertEqual(
self._deny("http://host/live/mse/api/ws?src=wizard_test", "admin"), 403
)
# --- path spellings nginx normalizes before matching its location ---
def test_path_variants_are_checked(self):
for path in (
"/live/mse/api/%77s",
"/live/mse/api%2Fws",
"//live/mse/api/ws",
"/live//mse/api/ws",
"/live/mse/api/./ws",
"/live/other/../mse/api/ws",
"/live/mse/api/%2e/ws",
"/%6Cive/webrtc/api/ws",
"/api/go2rtc/%77ebrtc",
"/live/mse/api/ws/",
"/live/mse/api/ws/extra",
"http://host//live/mse/api/ws",
):
self.assertEqual(self._deny(f"{path}?src=nonexistent", "viewer"), 403, path)
def test_path_variants_allow_existing_stream(self):
self.assertIsNone(self._deny("/live/mse/api/%77s?src=front_door", "viewer"))
def test_nginx_marker_applies_check(self):
self.request.headers = {"x-go2rtc-stream-proxy": "1"}
self.assertEqual(self._deny("/unexpected?src=nonexistent", "viewer"), 403)
def test_unrelated_paths_not_checked(self):
for path in ("/live/mse/api", "/api/go2rtc/streams", "/live/jsmpeg/x"):
self.assertIsNone(self._deny(f"{path}?src=nonexistent", "viewer"), path)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
import multiprocessing as mp
import os
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import frigate.__main__ as frigate_main
import frigate.log
from frigate.config import FrigateConfig
from frigate.util.process import FrigateProcess
ENV_KEY = "FRIGATE_TEST_PROCESS_ENV"
class EnvReportingProcess(FrigateProcess):
def __init__(self, stop_event, result_queue):
super().__init__(stop_event, 0, name="env_reporter", daemon=True)
self.result_queue = result_queue
def run(self) -> None:
self.pre_run_setup()
self.result_queue.put(os.environ.get(ENV_KEY))
def _run_main_and_report(result_queue) -> None:
"""Run frigate's main() with the config load and app faked out.
Runs in a fresh interpreter so no earlier test has started the forkserver.
"""
mp.set_start_method("forkserver", force=True)
def load(**kwargs):
os.environ[ENV_KEY] = "applied"
return SimpleNamespace()
def setup_logging(manager):
frigate.log.log_listener = SimpleNamespace(queue=manager.Queue())
class FakeApp:
def __init__(self, config, manager, stop_event):
self.stop_event = stop_event
def start(self):
child_queue = mp.Queue()
process = EnvReportingProcess(self.stop_event, child_queue)
process.start()
result_queue.put(child_queue.get(timeout=30))
process.join(timeout=10)
try:
with (
patch.object(sys, "argv", ["frigate"]),
patch.object(FrigateConfig, "load", load),
patch("frigate.__main__.setup_logging", setup_logging),
patch("frigate.__main__.FrigateApp", FakeApp),
):
frigate_main.main()
except Exception as err:
result_queue.put(f"error: {err!r}")
class TestWorkerEnvironment(unittest.TestCase):
def test_env_applied_by_config_load_reaches_worker_processes(self):
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
runner = ctx.Process(target=_run_main_and_report, args=(result_queue,))
runner.start()
try:
self.assertEqual(result_queue.get(timeout=60), "applied")
finally:
runner.join(timeout=10)
if runner.is_alive():
runner.terminate()
runner.join()
if __name__ == "__main__":
unittest.main()
+22 -1
View File
@@ -6,7 +6,7 @@ import unittest
from ruamel.yaml import YAML
from frigate.util.builtin import update_yaml_file_bulk
from frigate.util.builtin import clear_orphaned_comments, update_yaml_file_bulk
class TestUpdateYaml(unittest.TestCase):
@@ -178,6 +178,27 @@ class TestUpdateYaml(unittest.TestCase):
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
assert "# tuned for the pi" in self._read()
def test_delete_only_camera_with_comment_on_cameras_key(self):
"""Deleting the only camera stays parseable when the cameras key
carries the default config's trailing comment."""
self._write(
"cameras: # No cameras defined, UI wizard should be used\n"
" cam1:\n"
" enabled: true\n"
"version: 0.18-0\n"
)
yaml = YAML()
with open(self.config_path) as f:
data = yaml.load(f)
del data["cameras"]["cam1"]
clear_orphaned_comments(data["cameras"], data, "cameras")
with open(self.config_path, "w") as f:
yaml.dump(data, f)
data = self._load()
assert data["cameras"] == {}
assert data["version"] == "0.18-0"
if __name__ == "__main__":
unittest.main(verbosity=2)
+15
View File
@@ -1,8 +1,10 @@
"""Tests for push notification subscription validation."""
import unittest
from base64 import urlsafe_b64encode
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
from frigate.comms.webpush import _build_web_pushers
VALID_ENDPOINTS = [
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
@@ -148,3 +150,16 @@ class TestValidateSubscription(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestBuildWebPushers(unittest.TestCase):
def test_skips_subscriptions_with_malformed_keys(self):
bad = _subscription(VALID_ENDPOINTS[0])
good = _subscription(VALID_ENDPOINTS[0])
good["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
with self.assertLogs("frigate.comms.webpush", level="WARNING"):
pushers = _build_web_pushers("viewer", [bad, good])
self.assertEqual(len(pushers), 1)
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))