From d92e4cc7a92d3862d14803a658ffde2d5904a4c8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:56:33 -0500 Subject: [PATCH] check camera access for jsmpeg and birdseye cameras --- frigate/output/birdseye.py | 10 ++++++++- frigate/output/camera.py | 18 +++++++++++++--- frigate/output/output.py | 5 ++++- frigate/output/ws_auth.py | 43 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 frigate/output/ws_auth.py diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8b0fea6d7b..ff846008ca 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -19,6 +19,7 @@ import numpy as np from frigate.comms.inter_process import InterProcessRequestor from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -236,12 +237,14 @@ class BroadcastThread(threading.Thread): converter: FFMpegConverter, websocket_server: Any, stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config def run(self) -> None: while not self.stop_event.is_set(): @@ -256,6 +259,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -806,7 +810,11 @@ class Birdseye: config.birdseye.restream, ) self.broadcaster = BroadcastThread( - "birdseye", self.converter, websocket_server, stop_event + "birdseye", + self.converter, + websocket_server, + stop_event, + config, ) self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event) self.frame_manager = SharedMemoryFrameManager() diff --git a/frigate/output/camera.py b/frigate/output/camera.py index 917e38dd1d..88d16ed4b4 100644 --- a/frigate/output/camera.py +++ b/frigate/output/camera.py @@ -7,7 +7,8 @@ import threading from multiprocessing.synchronize import Event as MpEvent from typing import Any -from frigate.config import CameraConfig, FfmpegConfig +from frigate.config import CameraConfig, FfmpegConfig, FrigateConfig +from frigate.output.ws_auth import ws_has_camera_access logger = logging.getLogger(__name__) @@ -102,12 +103,14 @@ class BroadcastThread(threading.Thread): converter: FFMpegConverter, websocket_server: Any, stop_event: MpEvent, + config: FrigateConfig, ): super().__init__() self.camera = camera self.converter = converter self.websocket_server = websocket_server self.stop_event = stop_event + self.config = config def run(self) -> None: while not self.stop_event.is_set(): @@ -122,6 +125,7 @@ class BroadcastThread(threading.Thread): if ( not ws.terminated and ws.environ["PATH_INFO"] == f"/{self.camera}" + and ws_has_camera_access(ws, self.camera, self.config) ): try: ws.send(buf, binary=True) @@ -135,7 +139,11 @@ class BroadcastThread(threading.Thread): class JsmpegCamera: def __init__( - self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any + self, + config: CameraConfig, + frigate_config: FrigateConfig, + stop_event: MpEvent, + websocket_server: Any, ) -> None: self.config = config self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps) @@ -154,7 +162,11 @@ class JsmpegCamera: config.live.quality, ) self.broadcaster = BroadcastThread( - config.name or "", self.converter, websocket_server, stop_event + config.name or "", + self.converter, + websocket_server, + stop_event, + frigate_config, ) self.converter.start() diff --git a/frigate/output/output.py b/frigate/output/output.py index 22bcbb31ff..265c226215 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -32,6 +32,7 @@ from frigate.const import ( from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder +from frigate.output.ws_auth import ws_has_camera_access from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame from frigate.util.process import FrigateProcess @@ -102,7 +103,7 @@ class OutputProcess(FrigateProcess): ) -> None: camera_config = self.config.cameras[camera] jsmpeg_cameras[camera] = JsmpegCamera( - camera_config, self.stop_event, websocket_server + camera_config, self.config, self.stop_event, websocket_server ) preview_recorders[camera] = PreviewRecorder(camera_config) preview_write_times[camera] = 0 @@ -262,6 +263,7 @@ class OutputProcess(FrigateProcess): # send camera frame to ffmpeg process if websockets are connected if any( ws.environ["PATH_INFO"].endswith(camera) + and ws_has_camera_access(ws, camera, self.config) for ws in websocket_server.manager ): # write to the converter for the camera if clients are listening to the specific camera @@ -275,6 +277,7 @@ class OutputProcess(FrigateProcess): self.config.birdseye.restream or any( ws.environ["PATH_INFO"].endswith("birdseye") + and ws_has_camera_access(ws, "birdseye", self.config) for ws in websocket_server.manager ) ) diff --git a/frigate/output/ws_auth.py b/frigate/output/ws_auth.py new file mode 100644 index 0000000000..ae0d172f60 --- /dev/null +++ b/frigate/output/ws_auth.py @@ -0,0 +1,43 @@ +"""Authorization helpers for JSMPEG websocket clients.""" + +from typing import Any + +from frigate.config import FrigateConfig +from frigate.models import User + + +def _get_valid_ws_roles(ws: Any, config: FrigateConfig) -> list[str]: + role_header = ws.environ.get("HTTP_REMOTE_ROLE", "") + roles = [ + role.strip() + for role in role_header.split(config.proxy.separator) + if role.strip() + ] + return [role for role in roles if role in config.auth.roles] + + +def ws_has_camera_access(ws: Any, camera_name: str, config: FrigateConfig) -> bool: + """Return True when a websocket client is authorized for the camera path.""" + roles = _get_valid_ws_roles(ws, config) + + if not roles: + return False + + roles_dict = config.auth.roles + + # Birdseye is a composite stream, so only users with unrestricted access + # should receive it. + if camera_name == "birdseye": + return any(role == "admin" or not roles_dict.get(role) for role in roles) + + all_camera_names = set(config.cameras.keys()) + + for role in roles: + if role == "admin" or not roles_dict.get(role): + return True + + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + if camera_name in allowed_cameras: + return True + + return False \ No newline at end of file