Fix cross-camera authorization bypass in timeline and event media endpoints

This commit is contained in:
Josh Hawkins 2026-03-18 12:10:24 -05:00
parent ae9b307dfc
commit d2f5fc9ed9
2 changed files with 35 additions and 12 deletions

View File

@ -732,7 +732,12 @@ def get_recognized_license_plates(
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
def timeline(
camera: str = "all",
limit: int = 100,
source_id: Optional[str] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
clauses = []
selected_columns = [
@ -754,6 +759,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
else:
clauses.append((Timeline.source_id.in_(source_ids)))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if len(clauses) == 0:
clauses.append((True))
@ -769,7 +777,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
def hourly_timeline(
params: AppTimelineHourlyQueryParameters = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get hourly summary for timeline."""
cameras = params.cameras
labels = params.labels
@ -787,6 +798,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
camera_list = cameras.split(",")
clauses.append((Timeline.camera << camera_list))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if labels != "all":
label_list = labels.split(",")
clauses.append((Timeline.data["label"] << label_list))

View File

@ -1142,7 +1142,6 @@ async def event_snapshot(
@router.get(
"/events/{event_id}/thumbnail.{extension}",
dependencies=[Depends(require_camera_access)],
)
async def event_thumbnail(
request: Request,
@ -1344,12 +1343,14 @@ def grid_snapshot(
@router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],
)
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
async def event_snapshot_clean(
request: Request, event_id: str, download: bool = False
):
webp_bytes = None
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
if not (snapshot_config.enabled and event.has_snapshot):
return JSONResponse(
@ -1470,7 +1471,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
@router.get(
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
"/events/{event_id}/clip.mp4",
)
async def event_clip(
request: Request,
@ -1484,6 +1485,8 @@ async def event_clip(
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
if not event.has_clip:
return JSONResponse(
content={"success": False, "message": "Clip not available"}, status_code=404
@ -1500,9 +1503,9 @@ async def event_clip(
@router.get(
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
"/events/{event_id}/preview.gif",
)
def event_preview(request: Request, event_id: str):
async def event_preview(request: Request, event_id: str):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@ -1510,6 +1513,8 @@ def event_preview(request: Request, event_id: str):
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
start_ts = event.start_time
end_ts = start_ts + (
min(event.end_time - event.start_time, 20) if event.end_time else 20
@ -1854,8 +1859,8 @@ def preview_mp4(
)
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
def review_preview(
@router.get("/review/{event_id}/preview")
async def review_preview(
request: Request,
event_id: str,
format: str = Query(default="gif", enum=["gif", "mp4"]),
@ -1868,6 +1873,8 @@ def review_preview(
status_code=404,
)
await require_camera_access(review.camera, request=request)
padding = 8
start_ts = review.start_time - padding
end_ts = (
@ -1881,10 +1888,12 @@ def review_preview(
@router.get(
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
"/preview/{file_name}/thumbnail.jpg",
dependencies=[Depends(allow_any_authenticated())],
)
@router.get(
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
"/preview/{file_name}/thumbnail.webp",
dependencies=[Depends(allow_any_authenticated())],
)
def preview_thumbnail(file_name: str):
"""Get a thumbnail from the cached preview frames."""