From e064024a312bc5e1b22dd0df121ba57a3ecdd6ce Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:02:19 -0600 Subject: [PATCH] Fix go2rtc stream alias auth (#22097) * Fix go2rtc stream alias authorization and live audio gating for main/sub stream names * revert * add tests --- frigate/api/auth.py | 66 +++++- frigate/api/camera.py | 23 +- .../test/http_api/test_http_camera_access.py | 204 ++++++++++++++++++ web/src/hooks/use-camera-live-mode.ts | 46 ++-- 4 files changed, 317 insertions(+), 22 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index e0a6ec924..7c3a231ed 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -986,7 +986,16 @@ async def require_camera_access( current_user = await get_current_user(request) if isinstance(current_user, JSONResponse): - return current_user + detail = "Authentication required" + try: + error_payload = json.loads(current_user.body) + detail = ( + error_payload.get("message") or error_payload.get("detail") or detail + ) + except Exception: + pass + + raise HTTPException(status_code=current_user.status_code, detail=detail) role = current_user["role"] all_camera_names = set(request.app.frigate_config.cameras.keys()) @@ -1004,6 +1013,61 @@ async def require_camera_access( ) +def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]: + owner_cameras: set[str] = set() + + for camera_name, camera in request.app.frigate_config.cameras.items(): + if stream_name == camera_name: + owner_cameras.add(camera_name) + continue + + if stream_name in camera.live.streams.values(): + owner_cameras.add(camera_name) + + return owner_cameras + + +async def require_go2rtc_stream_access( + stream_name: Optional[str] = None, + request: Request = None, +): + """Dependency to enforce go2rtc stream access based on owning camera access.""" + if stream_name is None: + return + + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + detail = "Authentication required" + try: + error_payload = json.loads(current_user.body) + detail = ( + error_payload.get("message") or error_payload.get("detail") or detail + ) + except Exception: + pass + + raise HTTPException(status_code=current_user.status_code, detail=detail) + + role = current_user["role"] + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + + # Admin or full access bypasses + if role == "admin" or not roles_dict.get(role): + return + + owner_cameras = _get_stream_owner_cameras(request, stream_name) + + if owner_cameras & set(allowed_cameras): + return + + raise HTTPException( + status_code=403, + detail=f"Access denied to camera '{stream_name}'. Allowed: {allowed_cameras}", + ) + + async def get_allowed_cameras_for_filter(request: Request): """Dependency to get allowed_cameras for filtering lists.""" current_user = await get_current_user(request) diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 488ec1e1f..a94486d8c 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -17,7 +17,7 @@ from zeep.transports import AsyncTransport from frigate.api.auth import ( allow_any_authenticated, - require_camera_access, + require_go2rtc_stream_access, require_role, ) from frigate.api.defs.tags import Tags @@ -71,14 +71,27 @@ def go2rtc_streams(): @router.get( - "/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)] + "/go2rtc/streams/{stream_name}", + dependencies=[Depends(require_go2rtc_stream_access)], ) -def go2rtc_camera_stream(request: Request, camera_name: str): +def go2rtc_camera_stream(request: Request, stream_name: str): r = requests.get( - f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone" + "http://127.0.0.1:1984/api/streams", + params={ + "src": stream_name, + "video": "all", + "audio": "all", + "microphone": "", + }, ) if not r.ok: - camera_config = request.app.frigate_config.cameras.get(camera_name) + camera_config = request.app.frigate_config.cameras.get(stream_name) + + if camera_config is None: + for camera_name, camera in request.app.frigate_config.cameras.items(): + if stream_name in camera.live.streams.values(): + camera_config = request.app.frigate_config.cameras.get(camera_name) + break if camera_config and camera_config.enabled: logger.error("Failed to fetch streams from go2rtc") diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py index 5cd115417..211c84bb4 100644 --- a/frigate/test/http_api/test_http_camera_access.py +++ b/frigate/test/http_api/test_http_camera_access.py @@ -1,6 +1,7 @@ from unittest.mock import patch from fastapi import HTTPException, Request +from fastapi.testclient import TestClient from frigate.api.auth import ( get_allowed_cameras_for_filter, @@ -9,6 +10,33 @@ from frigate.api.auth import ( from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp +# Minimal multi-camera config used by go2rtc stream access tests. +# front_door has a stream alias "front_door_main"; back_door uses its own name. +# The "limited_user" role is restricted to front_door only. +_MULTI_CAMERA_CONFIG = { + "mqtt": {"host": "mqtt"}, + "auth": { + "roles": { + "limited_user": ["front_door"], + } + }, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "live": {"streams": {"default": "front_door_main"}}, + }, + "back_door": { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, +} + class TestCameraAccessEventReview(BaseTestHttp): def setUp(self): @@ -190,3 +218,179 @@ class TestCameraAccessEventReview(BaseTestHttp): resp = client.get("/events/summary") summary_list = resp.json() assert len(summary_list) == 2 + + +class TestGo2rtcStreamAccess(BaseTestHttp): + """Tests for require_go2rtc_stream_access — the auth dependency on + GET /go2rtc/streams/{stream_name}. + + go2rtc is not running in unit tests, so an authorized request returns + 500 (the proxy call fails), while an unauthorized request returns 401/403 + before the proxy is ever reached. + """ + + def _make_app(self, config_override: dict | None = None): + """Build a test app, optionally replacing self.minimal_config.""" + if config_override is not None: + self.minimal_config = config_override + app = super().create_app() + + # Allow tests to control the current user via request headers. + async def mock_get_current_user(request: Request): + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") + if not username or not role: + from fastapi.responses import JSONResponse + + return JSONResponse( + content={"message": "No authorization headers."}, + status_code=401, + ) + return {"username": username, "role": role} + + app.dependency_overrides[get_current_user] = mock_get_current_user + return app + + def setUp(self): + super().setUp([Event, ReviewSegment, Recordings]) + + def tearDown(self): + super().tearDown() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_stream( + self, app, stream_name: str, role: str = "admin", user: str = "test" + ): + """Issue GET /go2rtc/streams/{stream_name} with the given role.""" + with AuthTestClient(app) as client: + return client.get( + f"/go2rtc/streams/{stream_name}", + headers={"remote-user": user, "remote-role": role}, + ) + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + def test_admin_can_access_any_stream(self): + """Admin role bypasses camera restrictions.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + # front_door stream — go2rtc is not running so expect 500, not 401/403 + resp = self._get_stream(app, "front_door", role="admin") + assert resp.status_code not in (401, 403), ( + f"Admin should not be blocked; got {resp.status_code}" + ) + + # back_door stream + resp = self._get_stream(app, "back_door", role="admin") + assert resp.status_code not in (401, 403) + + def test_missing_auth_headers_returns_401(self): + """Requests without auth headers must be rejected with 401.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + # Use plain TestClient (not AuthTestClient) so no headers are injected. + with TestClient(app, raise_server_exceptions=False) as client: + resp = client.get("/go2rtc/streams/front_door") + assert resp.status_code == 401, f"Expected 401, got {resp.status_code}" + + def test_unconfigured_role_can_access_any_stream(self): + """When no camera restrictions are configured for a role the user + should have access to all streams (no roles_dict entry ⇒ no restriction).""" + no_roles_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}, + }, + "back_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + }, + } + app = self._make_app(no_roles_config) + + # "myuser" role is not listed in roles_dict — should be allowed everywhere + for stream in ("front_door", "back_door"): + resp = self._get_stream(app, stream, role="myuser") + assert resp.status_code not in (401, 403), ( + f"Unconfigured role should not be blocked on '{stream}'; " + f"got {resp.status_code}" + ) + + def test_restricted_role_can_access_allowed_camera(self): + """limited_user role (restricted to front_door) can access front_door stream.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + resp = self._get_stream(app, "front_door", role="limited_user") + assert resp.status_code not in (401, 403), ( + f"limited_user should be allowed on front_door; got {resp.status_code}" + ) + + def test_restricted_role_blocked_from_disallowed_camera(self): + """limited_user role (restricted to front_door) cannot access back_door stream.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + resp = self._get_stream(app, "back_door", role="limited_user") + assert resp.status_code == 403, ( + f"limited_user should be denied on back_door; got {resp.status_code}" + ) + + def test_stream_alias_allowed_for_owning_camera(self): + """Stream alias 'front_door_main' is owned by front_door; limited_user (who + is allowed front_door) should be permitted.""" + app = self._make_app(_MULTI_CAMERA_CONFIG) + # front_door_main is the alias defined in live.streams for front_door + resp = self._get_stream(app, "front_door_main", role="limited_user") + assert resp.status_code not in (401, 403), ( + f"limited_user should be allowed on alias front_door_main; " + f"got {resp.status_code}" + ) + + 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.""" + # Give back_door a stream alias and restrict limited_user to front_door only + config = { + "mqtt": {"host": "mqtt"}, + "auth": { + "roles": { + "limited_user": ["front_door"], + } + }, + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + }, + "back_door": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + "live": {"streams": {"default": "back_door_main"}}, + }, + }, + } + app = self._make_app(config) + resp = self._get_stream(app, "back_door_main", role="limited_user") + assert resp.status_code == 403, ( + f"limited_user should be denied on alias back_door_main; " + f"got {resp.status_code}" + ) diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index 288c0ea09..5264d1a34 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -18,18 +18,25 @@ export default function useCameraLiveMode( const streamNames = new Set(); cameras.forEach((camera) => { - const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes( - Object.values(camera.live.streams)[0], - ); + if (activeStreams && activeStreams[camera.name]) { + const selectedStreamName = activeStreams[camera.name]; + const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes( + selectedStreamName, + ); - if (isRestreamed) { - if (activeStreams && activeStreams[camera.name]) { - streamNames.add(activeStreams[camera.name]); - } else { - Object.values(camera.live.streams).forEach((streamName) => { - streamNames.add(streamName); - }); + if (isRestreamed) { + streamNames.add(selectedStreamName); } + } else { + Object.values(camera.live.streams).forEach((streamName) => { + const isRestreamed = Object.keys( + config.go2rtc.streams || {}, + ).includes(streamName); + + if (isRestreamed) { + streamNames.add(streamName); + } + }); } }); @@ -66,11 +73,11 @@ export default function useCameraLiveMode( } = {}; cameras.forEach((camera) => { + const selectedStreamName = + activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0]; const isRestreamed = config && - Object.keys(config.go2rtc.streams || {}).includes( - Object.values(camera.live.streams)[0], - ); + Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName); newIsRestreamedStates[camera.name] = isRestreamed ?? false; @@ -101,14 +108,21 @@ export default function useCameraLiveMode( setPreferredLiveModes(newPreferredLiveModes); setIsRestreamedStates(newIsRestreamedStates); setSupportsAudioOutputStates(newSupportsAudioOutputStates); - }, [cameras, config, windowVisible, streamMetadata]); + }, [activeStreams, cameras, config, windowVisible, streamMetadata]); const resetPreferredLiveMode = useCallback( (cameraName: string) => { const mseSupported = "MediaSource" in window || "ManagedMediaSource" in window; + const cameraConfig = cameras.find((camera) => camera.name === cameraName); + const selectedStreamName = + activeStreams?.[cameraName] ?? + (cameraConfig + ? Object.values(cameraConfig.live.streams)[0] + : cameraName); const isRestreamed = - config && Object.keys(config.go2rtc.streams || {}).includes(cameraName); + config && + Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName); setPreferredLiveModes((prevModes) => { const newModes = { ...prevModes }; @@ -122,7 +136,7 @@ export default function useCameraLiveMode( return newModes; }); }, - [config], + [activeStreams, cameras, config], ); return {