From 39e9612d0cc99dfdc03f02bd9bdb1c03a41e872e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:12:01 -0500 Subject: [PATCH] stop redacting ice servers and break camera path ties by role The WebRTC player passes /config's ice_servers to RTCPeerConnection, so redacting them sent the placeholder as the TURN account. ICE servers are no longer redacted and the restore code for them is removed. The camera path widget now uses the input's roles to pick between saved paths that mask to the same value, as the backend does on save. --- frigate/api/app.py | 79 +------------------ frigate/test/http_api/test_http_config_set.py | 69 +--------------- .../settings/camera-ffmpeg-streams.spec.ts | 29 +++++++ .../theme/widgets/CameraPathWidget.tsx | 34 +++++--- 4 files changed, 57 insertions(+), 154 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index f18abecf5c..8f0fcb5f06 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -453,13 +453,6 @@ def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None: for name in list(go2rtc["publish"]): redact_credential(go2rtc["publish"], name) - webrtc = go2rtc.get("webrtc") - if isinstance(webrtc, dict): - for ice_server in webrtc.get("ice_servers") or []: - if isinstance(ice_server, dict): - redact_credential(ice_server, "username") - redact_credential(ice_server, "credential") - @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) def get_profiles(request: Request): @@ -851,7 +844,7 @@ def _resolve_frigate_vars(value: str) -> str: def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: - """Shape the in-memory camera inputs and go2rtc like the saved YAML.""" + """Shape the in-memory camera inputs like the saved YAML.""" return { "cameras": { name: { @@ -864,84 +857,18 @@ def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: } for name, camera in config.cameras.items() }, - "go2rtc": config.go2rtc.model_dump(mode="json", warnings="none"), } -def _restore_redacted_ice_servers( - config_data: dict, saved_go2rtc: dict[str, Any] -) -> str | None: - """Fill redacted ICE server fields from the saved server with the same urls. - - Lists are written whole, so the sentinel can't be dropped per field like - other redacted credentials. Mutates `config_data` in place and returns an - error message when a redacted server has no saved match. - """ - go2rtc = config_data.get("go2rtc") - webrtc = go2rtc.get("webrtc") if isinstance(go2rtc, dict) else None - ice_servers = webrtc.get("ice_servers") if isinstance(webrtc, dict) else None - if not isinstance(ice_servers, list): - return None - - saved_webrtc = saved_go2rtc.get("webrtc") - saved_servers = ( - saved_webrtc.get("ice_servers") or [] if isinstance(saved_webrtc, dict) else [] - ) - - for server in ice_servers: - if ( - not isinstance(server, dict) - or REDACTED_CREDENTIAL_SENTINEL not in server.values() - ): - continue - redacted = [ - key - for key, value in server.items() - if value == REDACTED_CREDENTIAL_SENTINEL - ] - matches = [ - saved - for saved in saved_servers - if isinstance(saved, dict) and saved.get("urls") == server.get("urls") - ] - - # Servers sharing urls but holding different accounts can't be told apart - if not matches or any( - other.get(key) != matches[0].get(key) - for other in matches[1:] - for key in redacted - ): - return ( - "An ICE server contains saved credentials that could not be matched " - "by urls; resend it with real values" - ) - match = matches[0] - for key, value in list(server.items()): - if value != REDACTED_CREDENTIAL_SENTINEL: - continue - if key in match: - server[key] = match[key] - else: - del server[key] - - return None - - def _config_data_to_updates( config_data: dict, saved: dict[str, Any] ) -> tuple[dict[str, Any], str | None]: """Flatten a /config/set payload after restoring masked credentials. `saved` is the config the masked values were taken from. Returns the flat - updates, or an error message when a masked or redacted value can't be - restored. + updates, or an error message when a masked value can't be restored. """ - saved_go2rtc = saved.get("go2rtc") - error = _restore_masked_camera_paths( - config_data, saved - ) or _restore_redacted_ice_servers( - config_data, saved_go2rtc if isinstance(saved_go2rtc, dict) else {} - ) + error = _restore_masked_camera_paths(config_data, saved) if error: return {}, error diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 549b74e500..009e72573a 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -737,15 +737,14 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): "ngroksecret", "19550224", "streamkey", - "turnpass", ): self.assertNotIn(secret, body) go2rtc = response["go2rtc"] self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) + # browsers need the TURN account to relay WebRTC self.assertEqual( - go2rtc["webrtc"]["ice_servers"][0]["credential"], - REDACTED_CREDENTIAL_SENTINEL, + go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass" ) go2rtc.pop("ngrok") @@ -769,70 +768,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) - @patch("frigate.api.app.find_config_file") - def test_redacted_ice_server_with_changed_urls_is_rejected(self, mock_find_config): - self._add_credentials() - config_path = self._write_config_file() - mock_find_config.return_value = config_path - - try: - app, _ = self._create_app_with_publisher() - - with AuthTestClient(app) as client: - ice_servers = client.get("/config").json()["go2rtc"]["webrtc"][ - "ice_servers" - ] - ice_servers[0]["urls"] = ["turn:new.example.com:3478"] - webrtc = {"go2rtc": {"webrtc": {"ice_servers": ice_servers}}} - resp = self._save(client, webrtc) - self.assertEqual(resp.status_code, 400) - self.assertIn("saved credentials", resp.json()["message"]) - - ice_servers[0]["username"] = "newuser" - ice_servers[0]["credential"] = "newpass" - self.assertEqual(self._save(client, webrtc).status_code, 200) - - raw, saved = self._read_config_file(config_path) - self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) - self.assertEqual( - saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "newpass" - ) - finally: - os.unlink(config_path) - - @patch("frigate.api.app.find_config_file") - def test_redacted_ice_servers_sharing_urls_are_rejected(self, mock_find_config): - self._add_credentials() - self.minimal_config["go2rtc"]["webrtc"]["ice_servers"].append( - { - "urls": ["turn:turn.example.com:3478"], - "username": "otheruser", - "credential": "otherpass", - } - ) - config_path = self._write_config_file() - mock_find_config.return_value = config_path - - try: - app, _ = self._create_app_with_publisher() - - with AuthTestClient(app) as client: - ice_servers = client.get("/config").json()["go2rtc"]["webrtc"][ - "ice_servers" - ] - resp = self._save( - client, {"go2rtc": {"webrtc": {"ice_servers": ice_servers}}} - ) - self.assertEqual(resp.status_code, 400) - - _, saved = self._read_config_file(config_path) - self.assertEqual( - [s["credential"] for s in saved["go2rtc"]["webrtc"]["ice_servers"]], - ["turnpass", "otherpass"], - ) - finally: - os.unlink(config_path) - @patch("frigate.api.app.find_config_file") def test_masked_inputs_restore_by_value(self, mock_find_config): main = "rtsp://u:MAIN@10.0.0.1:554/main" diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts index d343a3b3ee..845c4d1a5a 100644 --- a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -235,4 +235,33 @@ test.describe("camera ffmpeg input source toggle @medium", () => { await paths.focus(); await expect(paths).toHaveValue(sub); }); + + test("inputs with the same masked path each reveal their own path by role", async ({ + frigateApp, + }) => { + const record = "rtsp://admin:one@10.0.0.1:554/live"; + const detect = "rtsp://viewer:two@10.0.0.1:554/live"; + const masked = "rtsp://*:*@10.0.0.1:554/live"; + await installRoutes( + frigateApp.page, + [ + { path: masked, roles: ["record"] }, + { path: masked, roles: ["detect"] }, + ], + [ + { path: record, roles: ["record"] }, + { path: detect, roles: ["detect"] }, + ], + ); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + const paths = frigateApp.page.getByRole("textbox", { name: "Input path" }); + await expect(paths).toHaveCount(2); + + await paths.nth(0).focus(); + await expect(paths.nth(0)).toHaveValue(record); + + await paths.nth(1).focus(); + await expect(paths.nth(1)).toHaveValue(detect); + }); }); diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index 4e446e8595..1adec37fed 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -1,5 +1,7 @@ import type { WidgetProps } from "@rjsf/utils"; import useSWR from "swr"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; import { useMemo, useState, type FocusEvent } from "react"; import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -21,6 +23,7 @@ type RawPathsResponse = { ffmpeg?: { inputs?: Array<{ path?: string; + roles?: string[]; }>; }; } @@ -68,23 +71,32 @@ export function CameraPathWidget(props: WidgetProps) { const rawValue = typeof value === "string" ? value : ""; - // Match by masked value, as the backend restores on save, so deleting or - // reordering inputs never reveals another input's path + const roles = + inputIndex === undefined + ? undefined + : get(formContext?.formData, ["inputs", inputIndex, "roles"]); + + // Match by masked value with roles breaking a tie, as the backend restores + // on save, so deleting or reordering inputs never reveals another input's path const rawPath = useMemo(() => { if (!cameraName || !isMaskedPath(rawValue)) { return undefined; } - const matches = new Set( - (rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []) - .map((input) => input.path) - .filter( - (path): path is string => - typeof path === "string" && maskCredentials(path) === rawValue, - ), + let matches = ( + rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? [] + ).filter( + (input): input is { path: string; roles?: string[] } => + typeof input.path === "string" && + maskCredentials(input.path) === rawValue, ); - return matches.size === 1 ? [...matches][0] : undefined; - }, [cameraName, rawPaths, rawValue]); + if (new Set(matches.map((input) => input.path)).size > 1) { + matches = matches.filter((input) => isEqual(input.roles, roles)); + } + + const paths = new Set(matches.map((input) => input.path)); + return paths.size === 1 ? [...paths][0] : undefined; + }, [cameraName, rawPaths, rawValue, roles]); const resolvedValue = isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");