From 9eda0b657aaa0840618a2562453a1a052c2206a7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:01:15 -0500 Subject: [PATCH] reject config saves that still hold masked credentials Index-keyed input maps, query-string saves and go2rtc stream lists echoed from /config bypassed the credential restore and could write a masked path or the redaction sentinel over the real value. Every save, body or query string, now rejects any value that still holds one after the restores run, naming the field. --- frigate/api/app.py | 44 +++++++-- frigate/test/http_api/test_http_config_set.py | 94 +++++++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 373d4df864..1cb495c3d1 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -943,12 +943,38 @@ def _config_data_to_updates( # clobber the saved YAML value. updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL} - if any(REDACTED_CREDENTIAL_SENTINEL in str(v) for v in updates.values()): - return {}, "Config contains saved credentials; resend with real values" + error = _find_masked_update(updates) + if error: + return {}, error return updates, None +def _holds_masked_value(value: Any) -> bool: + if isinstance(value, str): + return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value) + if isinstance(value, dict): + return any(_holds_masked_value(v) for v in value.values()) + if isinstance(value, list): + return any(_holds_masked_value(v) for v in value) + return False + + +def _find_masked_update(updates: dict[str, Any]) -> str | None: + """Return an error naming the first update that still holds a masked value. + + Runs after the restores so a masked path or redaction sentinel that could + not be restored is never written over the real credential. + """ + for key, value in updates.items(): + if _holds_masked_value(value): + return ( + f"{key} contains masked or saved credentials; " + "resend it with real values" + ) + return None + + def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse: """Apply config changes in-memory only, without writing to YAML. @@ -1112,8 +1138,11 @@ def config_set(request: Request, body: AppConfigSetBody): # Filter out empty keys but keep blank values for non-empty keys query_string = {k: v for k, v in query_string.items() if k} + error = None + if query_string: updates = process_config_query_string(query_string) + error = _find_masked_update(updates) elif body.config_data: try: saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config) @@ -1123,11 +1152,12 @@ def config_set(request: Request, body: AppConfigSetBody): body.config_data, saved_config if isinstance(saved_config, dict) else {}, ) - if error: - return JSONResponse( - content={"success": False, "message": error}, - status_code=400, - ) + + if error: + return JSONResponse( + content={"success": False, "message": error}, + status_code=400, + ) if not updates: return JSONResponse( diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index b8e0461f65..3a31593537 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -622,6 +622,100 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + @patch("frigate.api.app.find_config_file") + def test_over_masked_path_round_trips(self, mock_find_config): + path = "rtsp://10.0.0.1:554/live?x=a@b" + self._set_inputs([(path, "detect")]) + 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: + inputs = client.get("/config").json()["cameras"]["front_door"][ + "ffmpeg" + ]["inputs"] + self.assertNotEqual(inputs[0]["path"], path) + + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}}, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_unrestorable_masked_values_are_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: + for name, request in ( + ( + "index-keyed inputs", + { + "json": { + "config_data": { + "cameras": { + "front_door": { + "ffmpeg": { + "inputs": { + "0": {"path": "rtsp://*:*@h/x"} + } + } + } + } + }, + "requires_restart": 0, + } + }, + ), + ( + "go2rtc streams", + { + "json": { + "config_data": { + "go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}} + }, + "requires_restart": 0, + } + }, + ), + ( + "query string", + { + "params": { + "cameras.front_door.ffmpeg.inputs.0.path": ( + "rtsp://*:*@h/x" + ), + "go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL, + }, + "json": {"requires_restart": 0}, + }, + ), + ): + with self.subTest(name): + resp = client.put("/config/set", **request) + self.assertEqual(resp.status_code, 400) + self.assertIn("credentials", resp.json()["message"]) + + raw, _ = self._read_config_file(config_path) + self.assertNotIn("*:*", raw) + self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) + 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()