From c90e00061ac72af5c2234f5598f6fcbaada7a459 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:42:54 -0500 Subject: [PATCH] bound the credential mask to the url authority The userinfo pattern ran from any host:port to the last "@" in the token, so rtsp://host:554/live?x=a@b lost its host and a comma between two URLs was swallowed, which merged the paths /ffprobe splits on. It also backtracked quadratically on long lines with many "://", and every subprocess log line goes through it. A "user:" userinfo now runs to the last "@" before the next URL unless a port follows the colon, and any other userinfo stops at the end of the authority. Query masking now covers only real secret names, so auth, private and autoken are left alone. --- frigate/const.py | 10 ++++++++-- frigate/test/test_camera_pw.py | 25 ++++++++++++++++++++++++- web/src/utils/credentialMask.ts | 5 +++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/frigate/const.py b/frigate/const.py index 3b118682a1..b4da7e8f8a 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -117,10 +117,16 @@ 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_URL_USERINFO = r"://(?:[^\s/:]*:\S*|[^\s/]+)@" +# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the +# last "@" before the next URL. A port after the colon is rejected so a bare +# host:port/path@x is not taken for credentials. Anything else stops at the +# authority end. +REGEX_URL_USERINFO = ( + r"://(?:[^\s/?#:\[\]]*:(?!\d+(?:[/?#,\s]|$))(?:(?!://)\S)*|[^\s/?#]+)@" +) _CREDENTIAL_QUERY_KEYS = ( r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd" - r"|[a-z_]*token|[a-z_]*secret|[a-z_]*private|api_?key|auth)" + r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)" ) REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*" REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])" diff --git a/frigate/test/test_camera_pw.py b/frigate/test/test_camera_pw.py index 0a058b9269..09c86bfffc 100644 --- a/frigate/test/test_camera_pw.py +++ b/frigate/test/test_camera_pw.py @@ -1,5 +1,6 @@ """Test camera user and password cleanup.""" +import time import unittest from frigate.util.builtin import ( @@ -57,6 +58,11 @@ class TestUserPassMasking(unittest.TestCase): "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", + "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" @@ -94,6 +100,11 @@ class TestUserPassMasking(unittest.TestCase): "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", + "rtsp://10.0.0.1:554/live?x=a@b", + "rtsp://10.0.0.1:554/path@thing", + "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): @@ -102,5 +113,17 @@ class TestUserPassMasking(unittest.TestCase): def test_commas_outside_credentials_survive(self): """ffprobe splits multiple paths on commas left after masking.""" - self.assertIn(",", clean_camera_user_pass("rtsp://a/x,rtsp://u:p@b/y")) + 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/web/src/utils/credentialMask.ts b/web/src/utils/credentialMask.ts index 56eb390437..2438475be8 100644 --- a/web/src/utils/credentialMask.ts +++ b/web/src/utils/credentialMask.ts @@ -1,7 +1,8 @@ // 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|[a-z_]*private|api_?key|auth)"; -const URL_USERINFO_PATTERN = /:\/\/(?:[^\s/:]*:\S*|[^\s/]+)@/g; + "(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"; +const URL_USERINFO_PATTERN = + /:\/\/(?:[^\s/?#:[\]]*:(?!\d+(?:[/?#,\s]|$))(?:(?!:\/\/)\S)*|[^\s/?#]+)@/g; const CREDENTIAL_QUERY_PATTERN = new RegExp( `([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`, "gi",