diff --git a/frigate/api/app.py b/frigate/api/app.py index d38876c34c..8f0fcb5f06 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -61,6 +61,7 @@ from frigate.util.builtin import ( clean_camera_user_pass, deep_merge, flatten_config_data, + is_masked_camera_path, load_labels, process_config_query_string, split_config_key_path, @@ -71,6 +72,7 @@ from frigate.util.config import ( find_config_file, redact_credential, ) +from frigate.util.env import substitute_frigate_vars from frigate.util.live_streams import ( generated_transcode_streams, sync_transcode_streams, @@ -402,6 +404,8 @@ def config(request: Request): config["go2rtc"]["streams"][stream_name] = cleaned + _redact_go2rtc_credentials(config["go2rtc"]) + config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} for index, model in enumerate(config_obj.models): @@ -428,6 +432,28 @@ def config(request: Request): return JSONResponse(content=config) +def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None: + """Redact go2rtc secrets outside of stream URLs, in place.""" + for section in ("rtsp", "api", "mqtt"): + if isinstance(go2rtc.get(section), dict): + redact_credential(go2rtc[section], "username") + redact_credential(go2rtc[section], "password") + + if isinstance(go2rtc.get("ngrok"), dict): + redact_credential(go2rtc["ngrok"], "command") + + if isinstance(go2rtc.get("homekit"), dict): + for accessory in go2rtc["homekit"].values(): + if isinstance(accessory, dict): + for key in ("pin", "device_private", "pairings"): + redact_credential(accessory, key) + + # publish destinations carry stream keys in the URL path + if isinstance(go2rtc.get("publish"), dict): + for name in list(go2rtc["publish"]): + redact_credential(go2rtc["publish"], name) + + @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) def get_profiles(request: Request): """List all available profiles and the currently active profile.""" @@ -742,17 +768,24 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) -def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None: - """Substitute incoming `*:*` masked credentials with the in-memory ones. +def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None: + """Substitute incoming masked camera paths with the saved ones. The /config response masks ffmpeg input credentials, so the settings UI sends the masked path back when sibling fields (e.g. hwaccel_args) are - edited. Without this we'd write `rtsp://*:*@host` into YAML and lose - the real credentials. Mutates `config_data` in place. + edited. Each masked path is matched to the saved input that masks to the + same value, with roles breaking a tie, so deleting or reordering inputs + keeps each path's own credentials. The saved path is restored as written, + keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and + returns an error message when a masked path has no single match. """ cameras = config_data.get("cameras") if not isinstance(cameras, dict): - return + return None + + saved_cameras = saved.get("cameras") + if not isinstance(saved_cameras, dict): + saved_cameras = {} for camera_name, camera_data in cameras.items(): if not isinstance(camera_data, dict): @@ -760,20 +793,122 @@ def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> No inputs = camera_data.get("ffmpeg", {}).get("inputs") if not isinstance(inputs, list): continue - existing = config.cameras.get(camera_name) - if existing is None: - continue - existing_paths = [inp.path for inp in existing.ffmpeg.inputs] + + saved_camera = saved_cameras.get(camera_name) + saved_ffmpeg = ( + saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None + ) + saved_inputs = [ + inp + for inp in ( + saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None + ) + or [] + if isinstance(inp, dict) and isinstance(inp.get("path"), str) + ] + for index, input_obj in enumerate(inputs): if not isinstance(input_obj, dict): continue path = input_obj.get("path") - if not isinstance(path, str): + if not isinstance(path, str) or not is_masked_camera_path(path): continue - if ("://*:*@" in path or "user=*&password=*" in path) and index < len( - existing_paths - ): - input_obj["path"] = existing_paths[index] + candidates = [ + inp + for inp in saved_inputs + if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path + ] + if len({inp["path"] for inp in candidates}) > 1: + candidates = [ + inp + for inp in candidates + if inp.get("roles") == input_obj.get("roles") + ] + real_paths = {inp["path"] for inp in candidates} + if len(real_paths) != 1: + return ( + f"Input {index} of camera {camera_name} has masked credentials " + "that could not be matched; resend it with real credentials" + ) + input_obj["path"] = real_paths.pop() + + return None + + +def _resolve_frigate_vars(value: str) -> str: + """Resolve {FRIGATE_*} placeholders the way /config shows them.""" + try: + return substitute_frigate_vars(value) + except ValueError: + return value + + +def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: + """Shape the in-memory camera inputs like the saved YAML.""" + return { + "cameras": { + name: { + "ffmpeg": { + "inputs": [ + {"path": inp.path, "roles": [role.value for role in inp.roles]} + for inp in camera.ffmpeg.inputs + ] + } + } + for name, camera in config.cameras.items() + }, + } + + +def _config_data_to_updates( + config_data: dict, saved: dict[str, Any] +) -> tuple[dict[str, Any], str | None]: + """Flatten a /config/set payload after restoring masked credentials. + + `saved` is the config the masked values were taken from. Returns the flat + updates, or an error message when a masked value can't be restored. + """ + error = _restore_masked_camera_paths(config_data, saved) + if error: + return {}, error + + updates = flatten_config_data(config_data) + # 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. + updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL} + + 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: @@ -786,13 +921,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo try: updates = {} if body.config_data: - _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 - updates = { - k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL - } + updates, error = _config_data_to_updates( + body.config_data, _saved_config_from_memory(request.app.frigate_config) + ) + if error: + return JSONResponse( + content={"success": False, "message": error}, status_code=400 + ) if not updates: return JSONResponse( @@ -939,22 +1074,26 @@ 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: - _restore_masked_camera_paths( - body.config_data, request.app.frigate_config + try: + saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config) + except ruamel.yaml.YAMLError: + saved_config = None + updates, error = _config_data_to_updates( + body.config_data, + saved_config if isinstance(saved_config, dict) else {}, + ) + + if error: + return JSONResponse( + content={"success": False, "message": error}, + status_code=400, ) - updates = flatten_config_data(body.config_data) - # 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. - updates = { - k: v - for k, v in updates.items() - if v != REDACTED_CREDENTIAL_SENTINEL - } if not updates: return JSONResponse( diff --git a/frigate/const.py b/frigate/const.py index 6ef8919be2..6f4a8470ae 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -117,7 +117,19 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"] REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$" REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@" -REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+" +# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the +# last "@" before the next URL. Anything else stops at the authority end. Group 1 +# is the userinfo and group 2 the rest of the URL. +REGEX_URL_USERINFO = ( + r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)" +) +_CREDENTIAL_QUERY_KEYS = ( + r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd" + r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)" +) +REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}=" +REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*" +REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])" REGEX_JSON = re.compile(r"^\s*\{") # Known Driver Names diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index f31d382c51..009e72573a 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 @@ -503,6 +504,358 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) + camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin" + + def _add_credentials(self): + """Give the config a credentialed camera path and go2rtc secrets.""" + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = ( + self.camera_path + ) + self.minimal_config["go2rtc"] = { + "rtsp": {"username": "rtspuser", "password": "rtsppass"}, + "api": {"username": "apiuser", "password": "apipass"}, + "mqtt": {"username": "mquser", "password": "mqpass"}, + "ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"}, + "homekit": {"front_door": {"pin": "19550224"}}, + "publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"}, + "webrtc": { + "candidates": ["10.0.0.5:8555"], + "ice_servers": [ + { + "urls": ["turn:turn.example.com:3478"], + "username": "turnuser", + "credential": "turnpass", + } + ], + }, + } + + def _read_config_file(self, config_path): + with open(config_path) as f: + raw = f.read() + return raw, ruamel.yaml.YAML().load(raw) + + @patch("frigate.api.app.find_config_file") + def test_masked_camera_path_round_trips(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: + camera = client.get("/config").json()["cameras"]["front_door"] + inputs = camera["ffmpeg"]["inputs"] + self.assertNotIn("s3cret", inputs[0]["path"]) + + resp = client.put( + "/config/set", + json={ + "config_data": { + "cameras": { + "front_door": { + "ffmpeg": {"inputs": inputs}, + "detect": {"fps": 7}, + } + } + }, + "requires_restart": 0, + }, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], + self.camera_path, + ) + finally: + os.unlink(config_path) + + def _save(self, client, config_data): + return client.put( + "/config/set", + json={"config_data": config_data, "requires_restart": 0}, + ) + + def _set_inputs(self, paths): + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [ + {"path": path, "roles": [role]} for path, role in paths + ] + + # config parsing rebuilds FRIGATE_ENV_VARS from its sources + @patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"}) + @patch.dict( + "frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"} + ) + @patch("frigate.api.app.find_config_file") + def test_masked_path_keeps_env_placeholder(self, mock_find_config): + path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live" + 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: + camera = client.get("/config").json()["cameras"]["front_door"] + self.assertNotIn("s3cret", str(camera)) + + resp = self._save( + client, + { + "cameras": { + "front_door": { + "ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]}, + "detect": {"fps": 7}, + } + } + }, + ) + self.assertEqual(resp.status_code, 200) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn("s3cret", raw) + 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_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() + 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: + response = client.get("/config").json() + body = str(response) + for secret in ( + "rtsppass", + "apipass", + "mqpass", + "ngroksecret", + "19550224", + "streamkey", + ): + self.assertNotIn(secret, body) + + go2rtc = response["go2rtc"] + self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) + # browsers need the TURN account to relay WebRTC + self.assertEqual( + go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass" + ) + + go2rtc.pop("ngrok") + go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"] + self.assertEqual( + self._save(client, {"go2rtc": go2rtc}).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"]["mqtt"]["password"], "mqpass") + self.assertEqual( + saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224" + ) + self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"]) + self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"]) + self.assertEqual( + saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass" + ) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_masked_inputs_restore_by_value(self, mock_find_config): + main = "rtsp://u:MAIN@10.0.0.1:554/main" + sub = "rtsp://u:SUB@10.0.0.1:554/sub" + + for name, edit, expected in ( + ("delete first", lambda inputs: [inputs[1]], [sub]), + ("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]), + ): + with self.subTest(name): + self._set_inputs([(main, "record"), (sub, "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"] + resp = self._save( + client, + { + "cameras": { + "front_door": {"ffmpeg": {"inputs": edit(inputs)}} + } + }, + ) + self.assertEqual(resp.status_code, 200) + + _, saved = self._read_config_file(config_path) + saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"] + self.assertEqual([i["path"] for i in saved_inputs], expected) + finally: + os.unlink(config_path) + + @patch("frigate.api.app.find_config_file") + def test_ambiguous_or_unmatched_masked_input(self, mock_find_config): + self._set_inputs( + [ + ("rtsp://u:one@10.0.0.1:554/live", "record"), + ("rtsp://u:two@10.0.0.1:554/live", "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"] + + # both inputs mask to the same value and roles no longer tell + # them apart + remaining = dict(inputs[1], roles=["detect", "record"]) + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}}, + ) + self.assertEqual(resp.status_code, 400) + + inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live" + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}}, + ) + self.assertEqual(resp.status_code, 400) + + # roles break the tie when the second input stays as it was + resp = self._save( + client, + {"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}}, + ) + self.assertEqual(resp.status_code, 200) + + raw, saved = self._read_config_file(config_path) + self.assertNotIn("*:*", raw) + self.assertEqual( + saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], + "rtsp://u:two@10.0.0.1:554/live", + ) + finally: + os.unlink(config_path) + if __name__ == "__main__": unittest.main() diff --git a/frigate/test/test_camera_pw.py b/frigate/test/test_camera_pw.py index af4cecbd22..c3697ea6ec 100644 --- a/frigate/test/test_camera_pw.py +++ b/frigate/test/test_camera_pw.py @@ -1,8 +1,13 @@ """Test camera user and password cleanup.""" +import time import unittest -from frigate.util.builtin import clean_camera_user_pass, escape_special_characters +from frigate.util.builtin import ( + clean_camera_user_pass, + escape_special_characters, + is_masked_camera_path, +) class TestUserPassCleanup(unittest.TestCase): @@ -45,3 +50,90 @@ class TestUserPassMasking(unittest.TestCase): escaped = clean_camera_user_pass(self.rtsp_log_message) print(f"The escaped is {escaped}") assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554" + + def test_masks_userinfo_and_credential_query_forms(self): + cases = { + "rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2", + "rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live", + "rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x", + "rtsps://u:1/2@h/x": "rtsps://*:*@h/x", + "rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@", + "rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@", + "rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@", + "http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@", + "ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": ( + "ffmpeg:http://*:*@" + ), + "rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*", + "http://h/x?api_key=a&apikey=b&access_token=c": ( + "http://h/x?api_key=*&apikey=*&access_token=*" + ), + "http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*", + "http://h/cgi?password=x&user=admin&channel=1": ( + "http://h/cgi?password=*&user=*&channel=1" + ), + "http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*", + "http://h/x?username=a&password=b": "http://h/x?username=*&password=*", + "rtsp://host/live?user=admin&password=p@ss": ( + "rtsp://host/live?user=*&password=*" + ), + "ring:?refresh_token=abc.def": "ring:?refresh_token=*", + "nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": ( + "nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p" + ), + "homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": ( + "homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d" + ), + "exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": ( + "exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}" + ), + "ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": ( + "ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null" + ), + } + + for line, expected in cases.items(): + with self.subTest(line=line): + masked = clean_camera_user_pass(line) + self.assertEqual(masked, expected) + self.assertTrue(is_masked_camera_path(masked)) + self.assertEqual(clean_camera_user_pass(masked), masked) + + def test_safe_strings_unchanged(self): + for line in ( + "rtsp://10.0.0.2:554/live", + "ffmpeg:front_door#video=copy", + "rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac", + "homekit://10.0.0.2:51827?device_id=d", + "http://10.0.0.1/snap.jpg?email=me@x.com", + "http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e", + "http://h/x?private=1&passthrough=1", + "Camera front_door is not responding", + ): + with self.subTest(line=line): + self.assertEqual(clean_camera_user_pass(line), line) + self.assertFalse(is_masked_camera_path(line)) + + def test_commas_outside_credentials_survive(self): + """ffprobe splits multiple paths on commas left after masking.""" + for line in ( + "rtsp://a/x,rtsp://u:p@b/y", + "rtsp://a:554/x,rtsp://u:p@b/y", + ): + with self.subTest(line=line): + self.assertIn(",", clean_camera_user_pass(line)) + self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y")) + + def test_long_line_masks_in_linear_time(self): + """Log lines run through the mask, so a long one must stay fast.""" + start = time.perf_counter() + clean_camera_user_pass("rtsp://u:p" * 20000) + clean_camera_user_pass("rtsp://" + "a." * 40000) + self.assertLess(time.perf_counter() - start, 1) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 7b93ca8183..83ab6a78ca 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -21,8 +21,11 @@ import numpy as np from ruamel.yaml import YAML from frigate.const import ( - REGEX_HTTP_CAMERA_USER_PASS, + REGEX_CREDENTIAL_QUERY_KEY, + REGEX_CREDENTIAL_QUERY_PARAM, + REGEX_MASKED_CREDENTIAL, REGEX_RTSP_CAMERA_USER_PASS, + REGEX_URL_USERINFO, STREAM_TYPE_MAIN, STREAM_TYPE_SUB, ) @@ -117,9 +120,24 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic def clean_camera_user_pass(line: str) -> str: - """Removes user and password from line.""" - rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line) - return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned) + """Mask URL userinfo and credential query parameters in line.""" + + line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line) + return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line) + + +def _mask_userinfo(match: re.Match[str]) -> str: + # A credential key in the userinfo means a query password holds "@" or a + # password holds a query key. Neither split is safe, so mask the whole URL. + if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)): + return "://*:*@" + + return f"://*:*@{match.group(2)}" + + +def is_masked_camera_path(path: str) -> bool: + """Return True if path holds credentials masked by clean_camera_user_pass.""" + return re.search(REGEX_MASKED_CREDENTIAL, path) is not None def escape_special_characters(path: str) -> str: diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts index 38b0f5c4a6..845c4d1a5a 100644 --- a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -32,7 +32,11 @@ type CameraInput = { input_args?: string; }; -async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { +async function installRoutes( + page: Page, + frontDoorInputs: CameraInput[], + rawInputs: CameraInput[] = frontDoorInputs, +) { const config = configFactory({ go2rtc: { streams: GO2RTC_STREAMS }, cameras: { @@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) { await page.route("**/api/config/raw_paths", (route) => route.fulfill({ json: { - cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } }, + cameras: { front_door: { ffmpeg: { inputs: rawInputs } } }, go2rtc: { streams: GO2RTC_STREAMS }, }, }), @@ -200,4 +204,64 @@ test.describe("camera ffmpeg input source toggle @medium", () => { payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0]; expect(input?.input_args).not.toBe("preset-rtsp-restream"); }); + + test("a masked path reveals its own saved path after an earlier input is deleted", async ({ + frigateApp, + }) => { + const main = "rtsp://admin:mainpw@10.0.0.1:554/main"; + const sub = "rtsp://admin:subpw@10.0.0.1:554/sub"; + await installRoutes( + frigateApp.page, + [ + { path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] }, + { path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] }, + ], + [ + { path: main, roles: ["record"] }, + { path: sub, 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 frigateApp.page + .getByRole("button", { name: "Delete" }) + .first() + .click(); + await expect(paths).toHaveCount(1); + + await paths.focus(); + 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); + }); }); diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index 03f6abb9d9..1adec37fed 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -1,5 +1,7 @@ import type { WidgetProps } from "@rjsf/utils"; import useSWR from "swr"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; import { useMemo, useState, type FocusEvent } from "react"; import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -21,6 +23,7 @@ type RawPathsResponse = { ffmpeg?: { inputs?: Array<{ path?: string; + roles?: string[]; }>; }; } @@ -66,17 +69,35 @@ export function CameraPathWidget(props: WidgetProps) { shouldFetchRawPaths ? "config/raw_paths" : null, ); + const rawValue = typeof value === "string" ? value : ""; + + const roles = + 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(() => { - if (!cameraName || inputIndex === undefined) { + if (!cameraName || !isMaskedPath(rawValue)) { return undefined; } - const path = - rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path; - return typeof path === "string" ? path : undefined; - }, [cameraName, inputIndex, rawPaths]); + let matches = ( + rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? [] + ).filter( + (input): input is { path: string; roles?: string[] } => + typeof input.path === "string" && + maskCredentials(input.path) === rawValue, + ); + if (new Set(matches.map((input) => input.path)).size > 1) { + 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 rawValue = typeof value === "string" ? value : ""; const resolvedValue = isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? ""); const canReveal = diff --git a/web/src/utils/credentialMask.ts b/web/src/utils/credentialMask.ts index 34f0996a90..a2e0e4e356 100644 --- a/web/src/utils/credentialMask.ts +++ b/web/src/utils/credentialMask.ts @@ -1,40 +1,37 @@ -const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; -const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; +// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend. +const CREDENTIAL_QUERY_KEYS = + "(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"; +const URL_USERINFO_PATTERN = + /:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g; +const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp( + `[?&]${CREDENTIAL_QUERY_KEYS}=`, + "i", +); +const CREDENTIAL_QUERY_PATTERN = new RegExp( + `([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`, + "gi", +); +const MASKED_PATTERN = new RegExp( + `://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`, + "i", +); export const isMaskedPath = (value: string): boolean => - MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); - -export const hasCredentials = (value: string): boolean => { - if (!value) { - return false; - } - - if (isMaskedPath(value)) { - return true; - } - - try { - const parsed = new URL(value); - if (parsed.username || parsed.password) { - return true; - } - - return ( - parsed.searchParams.has("user") && parsed.searchParams.has("password") - ); - } catch { - return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); - } -}; + MASKED_PATTERN.test(value); export const maskCredentials = (value: string): string => { if (!value) { return value; } - const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); - - return maskedAuth - .replace(/([?&]user=)[^&]*/gi, "$1*") - .replace(/([?&]password=)[^&]*/gi, "$1*"); + return value + .replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) => + CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo) + ? "://*:*@" + : `://*:*@${rest}`, + ) + .replace(CREDENTIAL_QUERY_PATTERN, "$1*"); }; + +export const hasCredentials = (value: string): boolean => + !!value && (isMaskedPath(value) || maskCredentials(value) !== value);