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()