Compare commits

..
Author SHA1 Message Date
Josh Hawkins c2f1a6b87e Added a test that calls _execute_start_camera_watch and checks start_vlm_watch_job receives the caller's username, so removing that argument now fails it. 2026-10-24 16:37:15 -05:00
Josh Hawkins 5eb5d662f5 check ownership before stopping a camera watch from chat
The stop_camera_watch chat tool cancelled the active VLM watch job without checking who started it, so any authenticated user could cancel another user's job by calling /chat/completion directly. The tool now uses the same admin-or-creator check as DELETE /vlm/monitor, and chat-started jobs record their creator so non-admins can still stop their own.
2026-10-24 16:29:18 -05:00
2 changed files with 79 additions and 7 deletions
+16 -7
View File
@@ -45,6 +45,7 @@ from frigate.genai.prompts import (
)
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.vlm_watch import (
VLMWatchJob,
get_vlm_watch_job,
start_vlm_watch_job,
stop_vlm_watch_job,
@@ -765,7 +766,7 @@ async def _execute_tool_internal(
elif tool_name == "start_camera_watch":
return await _execute_start_camera_watch(request, arguments)
elif tool_name == "stop_camera_watch":
return _execute_stop_camera_watch()
return _execute_stop_camera_watch(request)
elif tool_name == "get_profile_status":
return _execute_get_profile_status(request)
elif tool_name == "get_recap":
@@ -819,6 +820,7 @@ async def _execute_start_camera_watch(
dispatcher=request.app.dispatcher,
labels=labels,
zones=zones,
username=request.headers.get("remote-user", ""),
)
except RuntimeError as e:
logger.exception("Failed to start VLM watch job: %s", e)
@@ -834,7 +836,18 @@ async def _execute_start_camera_watch(
}
def _execute_stop_camera_watch() -> dict[str, Any]:
def _can_cancel_watch_job(request: Request, job: VLMWatchJob) -> bool:
"""Admins can cancel any watch job; other users only jobs they started."""
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
return role == "admin" or username == job.username
def _execute_stop_camera_watch(request: Request) -> dict[str, Any]:
job = get_vlm_watch_job()
if job is not None and not _can_cancel_watch_job(request, job):
return {"success": False, "message": "Not authorized to cancel this watch job."}
cancelled = stop_vlm_watch_job()
if cancelled:
return {"success": True, "message": "Watch job cancelled."}
@@ -1548,11 +1561,7 @@ async def cancel_vlm_monitor(request: Request) -> JSONResponse:
status_code=404,
)
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
# Admin can cancel any job; other users can only cancel jobs they started.
if role != "admin" and username != job.username:
if not _can_cancel_watch_job(request, job):
return JSONResponse(
content={
"success": False,
@@ -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()