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.
This commit is contained in:
Josh Hawkins
2026-10-18 14:41:28 -05:00
parent 3356ed4eb5
commit daed9ebeb3
2 changed files with 61 additions and 4 deletions
+20 -4
View File
@@ -403,6 +403,19 @@ def config(request: Request):
config["go2rtc"]["streams"][stream_name] = cleaned 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()} config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
for index, model in enumerate(config_obj.models): 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) _restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data) updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()} 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 = { 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: 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) # 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()} updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential # 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 = { updates = {
k: v k: v
for k, v in updates.items() for k, v in updates.items()
if v != REDACTED_CREDENTIAL_SENTINEL if REDACTED_CREDENTIAL_SENTINEL not in str(v)
} }
if not updates: if not updates:
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.holder import ConfigHolder from frigate.config.holder import ConfigHolder
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -568,6 +569,46 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally: finally:
os.unlink(config_path) 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__": if __name__ == "__main__":
unittest.main() unittest.main()