From daed9ebeb3fd4958a943689e234efb49ebb18607 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:17:15 -0500 Subject: [PATCH] redact go2rtc rtsp, api and webrtc credentials in /config /config cleaned only go2rtc.streams, so go2rtc.rtsp and go2rtc.api usernames and passwords and the WebRTC ICE server credentials went back verbatim to any authenticated user. They now use the redaction sentinel like the MQTT and ONVIF passwords. Config save already dropped a field equal to the sentinel, but lists are leaf values after flattening, so an ice_servers list echoed back from /config would have written the sentinel to disk. Save now drops any value that contains it. --- frigate/api/app.py | 24 +++++++++-- frigate/test/http_api/test_http_config_set.py | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 60905be632..401c5cbeaa 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -403,6 +403,19 @@ def config(request: Request): config["go2rtc"]["streams"][stream_name] = cleaned + for section in ("rtsp", "api"): + go2rtc_section = config["go2rtc"].get(section) + if isinstance(go2rtc_section, dict): + redact_credential(go2rtc_section, "username") + redact_credential(go2rtc_section, "password") + + webrtc = config["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") + config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} for index, model in enumerate(config_obj.models): @@ -788,9 +801,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo _restore_masked_camera_paths(body.config_data, request.app.frigate_config) updates = flatten_config_data(body.config_data) updates = {k: ("" if v is None else v) for k, v in updates.items()} - # Drop any field whose value is still the redaction sentinel + # Drop any field whose value still holds the redaction sentinel updates = { - k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL + k: v + for k, v in updates.items() + if REDACTED_CREDENTIAL_SENTINEL not in str(v) } if not updates: @@ -948,11 +963,12 @@ def config_set(request: Request, body: AppConfigSetBody): # Convert None values to empty strings for deletion (e.g., when deleting masks) updates = {k: ("" if v is None else v) for k, v in updates.items()} # Drop sentinel-valued fields so untouched credential - # placeholders don't clobber the saved YAML value. + # placeholders don't clobber the saved YAML value. Lists + # are leaf values, so check for a sentinel nested inside. updates = { k: v for k, v in updates.items() - if v != REDACTED_CREDENTIAL_SENTINEL + if REDACTED_CREDENTIAL_SENTINEL not in str(v) } if not updates: diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 6d9374e76d..c1af38ba4e 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -14,6 +14,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateTopic, ) from frigate.config.holder import ConfigHolder +from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.models import Event, Recordings, ReviewSegment from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -568,6 +569,46 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + @patch("frigate.api.app.find_config_file") + def test_go2rtc_credentials_redacted_and_round_trip(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: + go2rtc = client.get("/config").json()["go2rtc"] + ice_server = go2rtc["webrtc"]["ice_servers"][0] + + for section in ("rtsp", "api"): + self.assertEqual( + go2rtc[section]["username"], REDACTED_CREDENTIAL_SENTINEL + ) + self.assertEqual( + go2rtc[section]["password"], REDACTED_CREDENTIAL_SENTINEL + ) + self.assertEqual(ice_server["credential"], REDACTED_CREDENTIAL_SENTINEL) + self.assertEqual(ice_server["username"], REDACTED_CREDENTIAL_SENTINEL) + self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) + + resp = client.put( + "/config/set", + json={"config_data": {"go2rtc": go2rtc}, "requires_restart": 0}, + ) + self.assertEqual(resp.status_code, 200) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) + self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass") + self.assertEqual(saved["go2rtc"]["api"]["password"], "apipass") + self.assertEqual( + saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass" + ) + finally: + os.unlink(config_path) + if __name__ == "__main__": unittest.main()