From bd1fc1cc72cd4fa371464a087cbf3d7f3142edc6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 13 May 2026 10:40:29 -0500 Subject: [PATCH] API access improvements (#23183) * restrict viewer access to logs, labels, and go2rtc stream list * filter stats data for non admins * track creator on vlm watch jobs and scope view/cancel to admin or creator * add shortcut for admins in /stats --- frigate/api/app.py | 73 ++++++++++++++++++++++++++++++++++----- frigate/api/camera.py | 24 +++++++++++-- frigate/api/chat.py | 39 +++++++++++++++++++-- frigate/jobs/vlm_watch.py | 3 ++ 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 5c56ffade..9ff24ed7e 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -96,11 +96,46 @@ def version(): @router.get("/stats", dependencies=[Depends(allow_any_authenticated())]) -def stats(request: Request): - return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) +def stats( + request: Request, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + stats_data = request.app.stats_emitter.get_latest_stats() + + # Admins see the full snapshot + if request.headers.get("remote-role") == "admin": + return JSONResponse(content=stats_data) + + allowed_set = set(allowed_cameras) + + # Shallow-copy so we don't mutate the cached stats history entry. + filtered = {**stats_data} + + cameras = stats_data.get("cameras") + if cameras is not None: + filtered["cameras"] = { + name: data for name, data in cameras.items() if name in allowed_set + } + + bandwidth = stats_data.get("bandwidth_usages") + if bandwidth is not None: + filtered["bandwidth_usages"] = { + name: data for name, data in bandwidth.items() if name in allowed_set + } + + # cmdline can leak camera URLs/paths; strip but keep cpu/mem so + # client-side problem heuristics still work. + cpu_usages = stats_data.get("cpu_usages") + if cpu_usages is not None: + filtered["cpu_usages"] = { + pid: {k: v for k, v in usage.items() if k != "cmdline"} + for pid, usage in cpu_usages.items() + } + + return JSONResponse(content=filtered) -@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())]) +@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))]) def stats_history(request: Request, keys: str = None): if keys: keys = keys.split(",") @@ -835,7 +870,7 @@ def nvinfo(): @router.get( "/logs/{service}", tags=[Tags.logs], - dependencies=[Depends(allow_any_authenticated())], + dependencies=[Depends(require_role(["admin"]))], ) async def logs( service: str = Path(enum=["frigate", "nginx", "go2rtc"]), @@ -1040,12 +1075,27 @@ def get_media_sync_status(job_id: str): @router.get("/labels", dependencies=[Depends(allow_any_authenticated())]) -def get_labels(camera: str = ""): +def get_labels( + camera: str = "", + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): try: if camera: + if camera not in allowed_cameras: + return JSONResponse( + content={ + "success": False, + "message": f"Access denied to camera '{camera}'", + }, + status_code=403, + ) events = Event.select(Event.label).where(Event.camera == camera).distinct() else: - events = Event.select(Event.label).distinct() + events = ( + Event.select(Event.label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception as e: logger.error(e) return JSONResponse( @@ -1058,9 +1108,16 @@ def get_labels(camera: str = ""): @router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())]) -def get_sub_labels(split_joined: Optional[int] = None): +def get_sub_labels( + split_joined: Optional[int] = None, + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): try: - events = Event.select(Event.sub_label).distinct() + events = ( + Event.select(Event.sub_label) + .where(Event.camera << allowed_cameras) + .distinct() + ) except Exception: return JSONResponse( content=({"success": False, "message": "Failed to get sub_labels"}), diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 7a3b19439..54d508cbd 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError from zeep.transports import AsyncTransport from frigate.api.auth import ( + _get_stream_owner_cameras, allow_any_authenticated, + get_current_user, require_go2rtc_stream_access, require_role, ) @@ -31,6 +33,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.config.env import substitute_frigate_vars +from frigate.models import User from frigate.util.builtin import clean_camera_user_pass from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file @@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool: @router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())]) -def go2rtc_streams(): +async def go2rtc_streams(request: Request): r = requests.get("http://127.0.0.1:1984/api/streams") if not r.ok: logger.error("Failed to fetch streams from go2rtc") @@ -75,6 +78,24 @@ def go2rtc_streams(): status_code=500, ) stream_data = r.json() + + # Roles with an explicit camera list see only streams owned by an allowed + # camera. Admin and full-access roles (no list / empty list) see all streams. + current_user = await get_current_user(request) + if not isinstance(current_user, JSONResponse): + role = current_user["role"] + roles_dict = request.app.frigate_config.auth.roles + if role != "admin" and roles_dict.get(role): + all_camera_names = set(request.app.frigate_config.cameras.keys()) + allowed_cameras = set( + User.get_allowed_cameras(role, roles_dict, all_camera_names) + ) + stream_data = { + name: data + for name, data in stream_data.items() + if _get_stream_owner_cameras(request, name) & allowed_cameras + } + for data in stream_data.values(): for producer in data.get("producers") or []: producer["url"] = clean_camera_user_pass(producer.get("url", "")) @@ -966,7 +987,6 @@ async def onvif_probe( probe = ffprobe_stream( request.app.frigate_config.ffmpeg, test_uri, detailed=False ) - print(probe) ok = probe is not None and getattr(probe, "returncode", 1) == 0 tested_candidates.append( { diff --git a/frigate/api/chat.py b/frigate/api/chat.py index d759f47c6..7b450bac7 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -10,7 +10,7 @@ from functools import reduce from typing import Any, Dict, List, Optional import cv2 -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel @@ -1641,6 +1641,7 @@ async def start_vlm_monitor( dispatcher=request.app.dispatcher, labels=body.labels, zones=body.zones, + username=request.headers.get("remote-user", ""), ) except RuntimeError as e: logger.error("Failed to start VLM watch job: %s", e, exc_info=True) @@ -1661,10 +1662,22 @@ async def start_vlm_monitor( summary="Get current VLM watch job", description="Returns the current (or most recently completed) VLM watch job.", ) -async def get_vlm_monitor() -> JSONResponse: +async def get_vlm_monitor(request: Request) -> JSONResponse: job = get_vlm_watch_job() if job is None: return JSONResponse(content={"active": False}, status_code=200) + + role = request.headers.get("remote-role", "viewer") + username = request.headers.get("remote-user", "") + + # Admin and the job's creator always see the job. Other users only see it + # if they have access to the camera being watched; otherwise hide it. + if role != "admin" and username != job.username: + try: + await require_camera_access(job.camera, request=request) + except HTTPException: + return JSONResponse(content={"active": False}, status_code=200) + return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200) @@ -1674,7 +1687,27 @@ async def get_vlm_monitor() -> JSONResponse: summary="Cancel the current VLM watch job", description="Cancels the running watch job if one exists.", ) -async def cancel_vlm_monitor() -> JSONResponse: +async def cancel_vlm_monitor(request: Request) -> JSONResponse: + job = get_vlm_watch_job() + if job is None: + return JSONResponse( + content={"success": False, "message": "No active watch job to cancel."}, + 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: + return JSONResponse( + content={ + "success": False, + "message": "Not authorized to cancel this watch job.", + }, + status_code=403, + ) + cancelled = stop_vlm_watch_job() if not cancelled: return JSONResponse( diff --git a/frigate/jobs/vlm_watch.py b/frigate/jobs/vlm_watch.py index cd64325d0..41ed830f1 100644 --- a/frigate/jobs/vlm_watch.py +++ b/frigate/jobs/vlm_watch.py @@ -45,6 +45,7 @@ class VLMWatchJob(Job): last_reasoning: str = "" notification_message: str = "" iteration_count: int = 0 + username: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -374,6 +375,7 @@ def start_vlm_watch_job( dispatcher: Any, labels: list[str] | None = None, zones: list[str] | None = None, + username: str = "", ) -> str: """Start a new VLM watch job. Returns the job ID. @@ -397,6 +399,7 @@ def start_vlm_watch_job( max_duration_minutes=max_duration_minutes, labels=labels or [], zones=zones or [], + username=username, ) cancel_ev = threading.Event() _current_job = job