Merge remote-tracking branch 'origin/master' into dev
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

Resolve conflicts in the export pipeline where dev's job-queue refactor
met master's chapter-metadata and security work.

- Unify chapter support under ChaptersEnum (none / recording_segments /
  review_items); the realtime stream-copy export selects the per-segment
  or per-review-item builder by the camera's configured mode. Thread
  chapters through ExportRecordingsBody -> _build_export_job -> ExportJob
  -> RecordingExporter.
- Keep master's creation_time/comment export metadata and fix a
  video_path duplication the textual merge introduced in the preview
  command.
- Move the chapters request field to ExportRecordingsBody (the single
  export endpoint) where it is actually honored.

Restore security fixes the automatic merge would have reverted:
- frigate/util/services.py: restore the #23493 rename to the public
  is_go2rtc_arbitrary_exec_allowed so create_config.py's dynamic-source
  exec guard imports and runs (the merge otherwise left a broken import).
- Preserve the export image-path ".." traversal check inside
  _sanitize_existing_image, applied to single/custom/batch exports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blake Blackshear
2026-06-28 15:04:01 +02:00
co-authored by Claude Opus 4.8
18 changed files with 470 additions and 34 deletions
+39 -7
View File
@@ -72,11 +72,16 @@ _WS_VIEWER_TOPICS = frozenset(
}
)
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
def _check_ws_authorization(
topic: str,
role_header: str | None,
separator: str,
roles_config: dict[str, list[str]] | None = None,
camera_names: set[str] | None = None,
) -> bool:
"""Check if a WebSocket message is authorized.
@@ -84,6 +89,10 @@ def _check_ws_authorization(
topic: The message topic.
role_header: The HTTP_REMOTE_ROLE header value, or None.
separator: The role separator character from proxy config.
roles_config: The auth.roles mapping (role -> allowed cameras), used to
authorize camera-scoped commands for non-admin users.
camera_names: All configured camera names, used to resolve a role's
allowed cameras.
Returns:
True if authorized, False if blocked.
@@ -93,16 +102,33 @@ def _check_ws_authorization(
return False
# No role header: default to viewer (fail-closed)
if role_header is None:
return topic in _WS_VIEWER_TOPICS
roles = [r.strip() for r in role_header.split(separator)] if role_header else []
# Check if any role is admin
roles = [r.strip() for r in role_header.split(separator)]
# Admin can send anything
if "admin" in roles:
return True
# Non-admin: only viewer topics allowed
return topic in _WS_VIEWER_TOPICS
# Read-only topics any authenticated user can send
if topic in _WS_VIEWER_TOPICS:
return True
# Camera-scoped command like "<camera>/ptz": allow when the user's role(s)
# grant access to that camera.
parts = topic.split("/")
if (
roles_config is not None
and len(parts) == 2
and parts[1] in _WS_CAMERA_COMMAND_TOPICS
):
allowed: set[str] = set()
# No role header maps to the default viewer role (e.g. proxy-only setups)
for role in roles or ["viewer"]:
allowed.update(
User.get_allowed_cameras(role, roles_config, camera_names or set())
)
return parts[0] in allowed
return False
# ---- Outbound filtering ---------------------------------------------------
@@ -449,6 +475,8 @@ class WebSocketClient(Communicator):
class _WebSocketHandler(WebSocket):
receiver = self._dispatcher
role_separator = self.config.proxy.separator or ","
roles_config = self.config.auth.roles
camera_names = set(self.config.cameras.keys())
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
try:
@@ -470,7 +498,11 @@ class WebSocketClient(Communicator):
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
)
if self.environ is not None and not _check_ws_authorization(
topic, role_header, self.role_separator
topic,
role_header,
self.role_separator,
self.roles_config,
self.camera_names,
):
logger.warning(
"Blocked unauthorized WebSocket message: topic=%s, role=%s",