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.
This commit is contained in:
Josh Hawkins
2026-10-18 15:12:01 -05:00
parent 4d89bd3309
commit 39e9612d0c
4 changed files with 57 additions and 154 deletions
+3 -76
View File
@@ -453,13 +453,6 @@ def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
for name in list(go2rtc["publish"]): for name in list(go2rtc["publish"]):
redact_credential(go2rtc["publish"], name) 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())]) @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
def get_profiles(request: Request): 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]: 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 { return {
"cameras": { "cameras": {
name: { name: {
@@ -864,84 +857,18 @@ def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
} }
for name, camera in config.cameras.items() 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( def _config_data_to_updates(
config_data: dict, saved: dict[str, Any] config_data: dict, saved: dict[str, Any]
) -> tuple[dict[str, Any], str | None]: ) -> tuple[dict[str, Any], str | None]:
"""Flatten a /config/set payload after restoring masked credentials. """Flatten a /config/set payload after restoring masked credentials.
`saved` is the config the masked values were taken from. Returns the flat `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 updates, or an error message when a masked value can't be restored.
restored.
""" """
saved_go2rtc = saved.get("go2rtc") error = _restore_masked_camera_paths(config_data, saved)
error = _restore_masked_camera_paths(
config_data, saved
) or _restore_redacted_ice_servers(
config_data, saved_go2rtc if isinstance(saved_go2rtc, dict) else {}
)
if error: if error:
return {}, error return {}, error
+2 -67
View File
@@ -737,15 +737,14 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
"ngroksecret", "ngroksecret",
"19550224", "19550224",
"streamkey", "streamkey",
"turnpass",
): ):
self.assertNotIn(secret, body) self.assertNotIn(secret, body)
go2rtc = response["go2rtc"] go2rtc = response["go2rtc"]
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
# browsers need the TURN account to relay WebRTC
self.assertEqual( self.assertEqual(
go2rtc["webrtc"]["ice_servers"][0]["credential"], go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
REDACTED_CREDENTIAL_SENTINEL,
) )
go2rtc.pop("ngrok") go2rtc.pop("ngrok")
@@ -769,70 +768,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally: finally:
os.unlink(config_path) 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") @patch("frigate.api.app.find_config_file")
def test_masked_inputs_restore_by_value(self, mock_find_config): def test_masked_inputs_restore_by_value(self, mock_find_config):
main = "rtsp://u:MAIN@10.0.0.1:554/main" main = "rtsp://u:MAIN@10.0.0.1:554/main"
@@ -235,4 +235,33 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
await paths.focus(); await paths.focus();
await expect(paths).toHaveValue(sub); 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);
});
}); });
@@ -1,5 +1,7 @@
import type { WidgetProps } from "@rjsf/utils"; import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr"; import useSWR from "swr";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { useMemo, useState, type FocusEvent } from "react"; import { useMemo, useState, type FocusEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -21,6 +23,7 @@ type RawPathsResponse = {
ffmpeg?: { ffmpeg?: {
inputs?: Array<{ inputs?: Array<{
path?: string; path?: string;
roles?: string[];
}>; }>;
}; };
} }
@@ -68,23 +71,32 @@ export function CameraPathWidget(props: WidgetProps) {
const rawValue = typeof value === "string" ? value : ""; const rawValue = typeof value === "string" ? value : "";
// Match by masked value, as the backend restores on save, so deleting or const roles =
// reordering inputs never reveals another input's path 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(() => { const rawPath = useMemo(() => {
if (!cameraName || !isMaskedPath(rawValue)) { if (!cameraName || !isMaskedPath(rawValue)) {
return undefined; return undefined;
} }
const matches = new Set( let matches = (
(rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []) rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
.map((input) => input.path) ).filter(
.filter( (input): input is { path: string; roles?: string[] } =>
(path): path is string => typeof input.path === "string" &&
typeof path === "string" && maskCredentials(path) === rawValue, maskCredentials(input.path) === rawValue,
),
); );
return matches.size === 1 ? [...matches][0] : undefined; if (new Set(matches.map((input) => input.path)).size > 1) {
}, [cameraName, rawPaths, rawValue]); 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 = const resolvedValue =
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");