From 048475e750de2364656a7f6853be35b8921ba261 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 29 Nov 2025 07:30:04 -0600 Subject: [PATCH] API admin exemptions and route guard updates (#21094) * update exempt paths and add missing guard to api endpoints * admin only frigate+ submission --- frigate/api/auth.py | 34 ++++++++++++++++--- frigate/api/event.py | 7 ++++ frigate/api/export.py | 3 ++ frigate/api/media.py | 1 + frigate/api/notification.py | 5 ++- frigate/api/review.py | 5 ++- .../overlay/detail/SearchDetailDialog.tsx | 3 +- .../overlay/detail/TrackingDetails.tsx | 6 ++-- .../overlay/dialog/FrigatePlusDialog.tsx | 3 ++ web/src/components/player/HlsVideoPlayer.tsx | 4 ++- web/src/components/timeline/EventMenu.tsx | 5 ++- 11 files changed, 64 insertions(+), 12 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 737f3706b..9d921ab6a 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -62,8 +62,8 @@ def require_admin_by_default(): "/", "/version", "/config/schema.json", - "/metrics", # Authenticated user endpoints (allow_any_authenticated) + "/metrics", "/stats", "/stats/history", "/config", @@ -76,22 +76,28 @@ def require_admin_by_default(): "/recognized_license_plates", "/timeline", "/timeline/hourly", - "/events/summary", "/recordings/storage", "/recordings/summary", "/recordings/unavailable", "/go2rtc/streams", + "/event_ids", + "/events", + "/exports", } # Path prefixes that should be exempt (for paths with parameters) EXEMPT_PREFIXES = ( "/logs/", # /logs/{service} - "/review", # /review, /review/{id}, /review_ids, /review/summary, etc. + "/review", # /review, /review/{id}, /review/summary, /review_ids, etc. "/reviews/", # /reviews/viewed, /reviews/delete - "/events/", # /events/{id}/thumbnail, etc. (camera-scoped) + "/events/", # /events/{id}/thumbnail, /events/summary, etc. (camera-scoped) + "/export/", # /export/{camera}/start/..., /export/{id}/rename, /export/{id} "/go2rtc/streams/", # /go2rtc/streams/{camera} "/users/", # /users/{username}/password (has own auth) "/preview/", # /preview/{file}/thumbnail.jpg + "/exports/", # /exports/{export_id} + "/vod/", # /vod/{camera_name}/... + "/notifications/", # /notifications/pubkey, /notifications/register ) async def admin_checker(request: Request): @@ -105,6 +111,24 @@ def require_admin_by_default(): if path.startswith(EXEMPT_PREFIXES): return + # Dynamic camera path exemption: + # Any path whose first segment matches a configured camera name should + # bypass the global admin requirement. These endpoints enforce access + # via route-level dependencies (e.g. require_camera_access) to ensure + # per-camera authorization. This allows non-admin authenticated users + # (e.g. viewer role) to access camera-specific resources without + # needing admin privileges. + try: + if path.startswith("/"): + first_segment = path.split("/", 2)[1] + if ( + first_segment + and first_segment in request.app.frigate_config.cameras + ): + return + except Exception: + pass + # For all other paths, require admin role # Port 5000 (internal) requests have admin role set automatically role = request.headers.get("remote-role") @@ -113,7 +137,7 @@ def require_admin_by_default(): raise HTTPException( status_code=403, - detail="Admin role required for this endpoint", + detail="Access denied. A user with the admin role is required.", ) return admin_checker diff --git a/frigate/api/event.py b/frigate/api/event.py index e85758181..8e966d98b 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -70,6 +70,7 @@ router = APIRouter(tags=[Tags.events]) @router.get( "/events", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get events", description="Returns a list of events.", ) @@ -344,6 +345,7 @@ def events( @router.get( "/events/explore", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get summary of objects.", description="""Gets a summary of objects from the database. Returns a list of objects with a max of `limit` objects for each label. @@ -436,6 +438,7 @@ def events_explore( @router.get( "/event_ids", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get events by ids.", description="""Gets events by a list of ids. Returns a list of events. @@ -469,6 +472,7 @@ async def event_ids(ids: str, request: Request): @router.get( "/events/search", + dependencies=[Depends(allow_any_authenticated())], summary="Search events.", description="""Searches for events in the database. Returns a list of events. @@ -919,6 +923,7 @@ def events_summary( @router.get( "/events/{event_id}", response_model=EventResponse, + dependencies=[Depends(allow_any_authenticated())], summary="Get event by id.", description="Gets an event by its id.", ) @@ -962,6 +967,7 @@ def set_retain(event_id: str): @router.post( "/events/{event_id}/plus", response_model=EventUploadPlusResponse, + dependencies=[Depends(require_role(["admin"]))], summary="Send event to Frigate+.", description="""Sends an event to Frigate+. Returns a success message or an error if the event is not found. @@ -1102,6 +1108,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N @router.put( "/events/{event_id}/false_positive", response_model=EventUploadPlusResponse, + dependencies=[Depends(require_role(["admin"]))], summary="Submit false positive to Frigate+", description="""Submit an event as a false positive to Frigate+. This endpoint is the same as the standard Frigate+ submission endpoint, diff --git a/frigate/api/export.py b/frigate/api/export.py index d7b314ab2..24fed93b0 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -14,6 +14,7 @@ from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.auth import ( + allow_any_authenticated, get_allowed_cameras_for_filter, require_camera_access, require_role, @@ -44,6 +45,7 @@ router = APIRouter(tags=[Tags.export]) @router.get( "/exports", response_model=ExportsResponse, + dependencies=[Depends(allow_any_authenticated())], summary="Get exports", description="""Gets all exports from the database for cameras the user has access to. Returns a list of exports ordered by date (most recent first).""", @@ -272,6 +274,7 @@ async def export_delete(event_id: str, request: Request): @router.get( "/exports/{export_id}", response_model=ExportModel, + dependencies=[Depends(allow_any_authenticated())], summary="Get a single export", description="""Gets a specific export by ID. The user must have access to the camera associated with the export.""", diff --git a/frigate/api/media.py b/frigate/api/media.py index 4dc5c7714..61c1e2b96 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -945,6 +945,7 @@ async def vod_hour( @router.get( "/vod/event/{event_id}", + dependencies=[Depends(allow_any_authenticated())], description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) async def vod_event( diff --git a/frigate/api/notification.py b/frigate/api/notification.py index 3d3a3eab0..502e76dbd 100644 --- a/frigate/api/notification.py +++ b/frigate/api/notification.py @@ -5,11 +5,12 @@ import os from typing import Any from cryptography.hazmat.primitives import serialization -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from peewee import DoesNotExist from py_vapid import Vapid01, utils +from frigate.api.auth import allow_any_authenticated from frigate.api.defs.tags import Tags from frigate.const import CONFIG_DIR from frigate.models import User @@ -21,6 +22,7 @@ router = APIRouter(tags=[Tags.notifications]) @router.get( "/notifications/pubkey", + dependencies=[Depends(allow_any_authenticated())], summary="Get VAPID public key", description="""Gets the VAPID public key for the notifications. Returns the public key or an error if notifications are not enabled. @@ -47,6 +49,7 @@ def get_vapid_pub_key(request: Request): @router.post( "/notifications/register", + dependencies=[Depends(allow_any_authenticated())], summary="Register notifications", description="""Registers a notifications subscription. Returns a success message or an error if the subscription is not provided. diff --git a/frigate/api/review.py b/frigate/api/review.py index b19f10f2b..5f6fbc13b 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -577,7 +577,9 @@ def delete_reviews(body: ReviewModifyMultipleBody): @router.get( - "/review/activity/motion", response_model=list[ReviewActivityMotionResponse] + "/review/activity/motion", + response_model=list[ReviewActivityMotionResponse], + dependencies=[Depends(allow_any_authenticated())], ) def motion_activity( params: ReviewActivityMotionQueryParams = Depends(), @@ -739,6 +741,7 @@ async def set_not_reviewed( @router.post( "/review/summarize/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(allow_any_authenticated())], description="Use GenAI to summarize review items over a period of time.", ) def generate_review_summary(request: Request, start_ts: float, end_ts: float): diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 467008e92..4ead27218 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1299,7 +1299,8 @@ function ObjectDetailsTab({ - {search.data.type === "object" && + {isAdmin && + search.data.type === "object" && config?.plus?.enabled && search.end_time != undefined && search.has_snapshot && ( diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index c6e10f8c2..26cba7d3a 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -38,6 +38,7 @@ import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; import { useApiHost } from "@/api"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ObjectTrackOverlay from "../ObjectTrackOverlay"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type TrackingDetailsProps = { className?: string; @@ -777,6 +778,7 @@ function LifecycleIconRow({ const { data: config } = useSWR("config"); const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); + const isAdmin = useIsAdmin(); const aspectRatio = useMemo(() => { if (!config) { @@ -993,7 +995,7 @@ function LifecycleIconRow({
{formattedEventTimestamp}
- {(config?.plus?.enabled || item.data.box) && ( + {((isAdmin && config?.plus?.enabled) || item.data.box) && (
@@ -1002,7 +1004,7 @@ function LifecycleIconRow({ - {config?.plus?.enabled && ( + {isAdmin && config?.plus?.enabled && ( { diff --git a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx index b57e73755..c011fa14b 100644 --- a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx +++ b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx @@ -20,6 +20,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator import { baseUrl } from "@/api/baseUrl"; import { getTranslatedLabel } from "@/utils/i18n"; import useImageLoaded from "@/hooks/use-image-loaded"; +import { useIsAdmin } from "@/hooks/use-is-admin"; export type FrigatePlusDialogProps = { upload?: Event; @@ -57,7 +58,9 @@ export function FrigatePlusDialog({ ); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); + const isAdmin = useIsAdmin(); const showCard = + isAdmin && !!upload && upload.data.type === "object" && upload.plus_id !== "not_enabled" && diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index f51085e51..4d7068204 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -20,6 +20,7 @@ import { cn } from "@/lib/utils"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; +import { useIsAdmin } from "@/hooks/use-is-admin"; // Android native hls does not seek correctly const USE_NATIVE_HLS = false; @@ -83,6 +84,7 @@ export default function HlsVideoPlayer({ }: HlsVideoPlayerProps) { const { t } = useTranslation("components/player"); const { data: config } = useSWR("config"); + const isAdmin = useIsAdmin(); // for detail stream context in History const currentTime = currentTimeOverride; @@ -285,7 +287,7 @@ export default function HlsVideoPlayer({ volume: true, seek: true, playbackRate: true, - plusUpload: config?.plus?.enabled == true, + plusUpload: isAdmin && config?.plus?.enabled == true, fullscreen: supportsFullscreen, }} setControlsOpen={setControlsOpen} diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index ca14fcae8..e6ad8eba4 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; import { useState } from "react"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type EventMenuProps = { event: Event; @@ -35,6 +36,7 @@ export default function EventMenu({ const navigate = useNavigate(); const { t } = useTranslation("views/explore"); const [isOpen, setIsOpen] = useState(false); + const isAdmin = useIsAdmin(); const handleObjectSelect = () => { if (isSelected) { @@ -85,7 +87,8 @@ export default function EventMenu({ - {event.has_snapshot && + {isAdmin && + event.has_snapshot && event.plus_id == undefined && event.data.type == "object" && config?.plus?.enabled && (