Compare commits

..
Author SHA1 Message Date
Josh Hawkins 743fef2782 add test 2026-09-30 09:33:37 -05:00
Josh Hawkins 17e1d5e094 don't let camera names waive the admin check on non-camera routes
The global admin guard skipped the admin check for any request whose first path segment matched a configured camera name, without looking at which route actually handled it. A camera named `faces`, `lpr`, `audio`, or `classification` let viewers reach the face, LPR, audio transcription, and classification endpoints that rely only on the global guard. The exemption now also requires the matched route to be a `/{camera_name}` route, so camera routes behave exactly as before.
2026-09-30 09:15:27 -05:00
3 changed files with 35 additions and 19 deletions
+12 -17
View File
@@ -127,23 +127,18 @@ 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
# Camera routes enforce per-camera access via route-level dependencies
# (e.g. require_camera_access). Match on the route template, not the raw
# path, so a camera named like another namespace (e.g. "faces") can't
# waive the admin check for that namespace's routes.
route = request.scope.get("route")
if (
route is not None
and route.path.startswith("/{camera_name}")
and request.path_params.get("camera_name")
in request.app.frigate_config.cameras
):
return
# For all other paths, require admin role
# Internal port requests have admin role set automatically
+4 -2
View File
@@ -144,7 +144,9 @@ class BaseTestHttp(unittest.TestCase):
except OSError:
pass
def create_app(self, stats=None, event_metadata_publisher=None):
def create_app(
self, stats=None, event_metadata_publisher=None, enforce_default_admin=False
):
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
app = create_fastapi_app(
@@ -158,7 +160,7 @@ class BaseTestHttp(unittest.TestCase):
event_metadata_publisher,
None,
DebugReplayManager(),
enforce_default_admin=False,
enforce_default_admin=enforce_default_admin,
)
# Default test mocks for authentication
+19
View File
@@ -47,6 +47,25 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert response.json()["front_door"]["usage_percent"] == 25.0
def test_camera_name_collision_keeps_admin_default(self):
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app(enforce_default_admin=True)
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
with AuthTestClient(app) as client:
assert client.get("/faces", headers=viewer).status_code == 403
assert client.get("/faces").status_code == 200
assert (
client.post("/faces/train/person/classify", headers=viewer).status_code
== 403
)
# Camera routes for the same name stay reachable by viewers
response = client.get("/faces/recordings/summary", headers=viewer)
assert response.status_code == 200
def test_config_set_in_memory_replaces_objects_track_list(self):
self.minimal_config["cameras"]["front_door"]["objects"] = {
"track": ["person", "car"],