From 17a8efa09cc8f481f595917df58a8043b83e9306 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:56:04 -0500 Subject: [PATCH] Fix camera name collision bypassing admin check (#24516) * 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. * add test --- frigate/api/auth.py | 29 ++++++++++--------------- frigate/test/http_api/base_http_test.py | 6 +++-- frigate/test/http_api/test_http_app.py | 19 ++++++++++++++++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index fa51d6f328..e0090e32cd 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -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 diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 32d110962e..e382d373df 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -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 diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index ef5b99ad08..66618b26ac 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -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"],