Fix cross-camera auth in timeline and media endpoints (#22522)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

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

* formatting
This commit is contained in:
Josh Hawkins 2026-03-18 19:54:31 -05:00 committed by GitHub
parent e78da2758d
commit d11c26970d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 33 additions and 12 deletions

View File

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

View File

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