mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 05:02:49 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a5eacda8 |
@@ -122,12 +122,14 @@ updates:
|
||||
# Intermittent failure in the UI settings import spec, cause not found
|
||||
- dependency-name: "zod"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Needs an on-device pinch test before moving
|
||||
# Every later version clamps the pan when the player hides or drops
|
||||
# pinch gestures near the edges, 4.2.0 included
|
||||
- dependency-name: "react-zoom-pan-pinch"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Build fails until monaco-worker-manager releases its PR 4
|
||||
versions: [">3.4.4"]
|
||||
# 0.54 pins a vulnerable dompurify; 0.55+ breaks YAML validation until
|
||||
# monaco-worker-manager releases its PR 4
|
||||
- dependency-name: "monaco-editor"
|
||||
versions: [">=0.55.0"]
|
||||
versions: [">=0.54.0"]
|
||||
# Later versions need Node 22 and extract false keys
|
||||
- dependency-name: "i18next-cli"
|
||||
versions: [">1.5.11"]
|
||||
|
||||
+33
-172
@@ -61,7 +61,6 @@ 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,
|
||||
@@ -72,7 +71,6 @@ 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,
|
||||
@@ -404,8 +402,6 @@ 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):
|
||||
@@ -432,28 +428,6 @@ 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."""
|
||||
@@ -768,24 +742,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
"""Substitute incoming masked camera paths with the saved ones.
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory 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. 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.
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return None
|
||||
|
||||
saved_cameras = saved.get("cameras")
|
||||
if not isinstance(saved_cameras, dict):
|
||||
saved_cameras = {}
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -793,122 +760,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
|
||||
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)
|
||||
]
|
||||
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
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
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -921,13 +786,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
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
|
||||
)
|
||||
_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
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -1074,26 +939,22 @@ 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)
|
||||
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,
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
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(
|
||||
|
||||
+1
-13
@@ -117,19 +117,7 @@ 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]+@"
|
||||
# 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_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
|
||||
REGEX_JSON = re.compile(r"^\s*\{")
|
||||
|
||||
# Known Driver Names
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
|
||||
@@ -504,358 +503,6 @@ 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()
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
escape_special_characters,
|
||||
is_masked_camera_path,
|
||||
)
|
||||
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||
|
||||
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
@@ -50,90 +45,3 @@ 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)
|
||||
|
||||
+4
-22
@@ -21,11 +21,8 @@ import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import (
|
||||
REGEX_CREDENTIAL_QUERY_KEY,
|
||||
REGEX_CREDENTIAL_QUERY_PARAM,
|
||||
REGEX_MASKED_CREDENTIAL,
|
||||
REGEX_HTTP_CAMERA_USER_PASS,
|
||||
REGEX_RTSP_CAMERA_USER_PASS,
|
||||
REGEX_URL_USERINFO,
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
@@ -120,24 +117,9 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
|
||||
|
||||
def clean_camera_user_pass(line: str) -> str:
|
||||
"""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
|
||||
"""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)
|
||||
|
||||
|
||||
def escape_special_characters(path: str) -> str:
|
||||
|
||||
@@ -32,11 +32,7 @@ type CameraInput = {
|
||||
input_args?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorInputs: CameraInput[],
|
||||
rawInputs: CameraInput[] = frontDoorInputs,
|
||||
) {
|
||||
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
@@ -60,7 +56,7 @@ async function installRoutes(
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
|
||||
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
},
|
||||
}),
|
||||
@@ -204,64 +200,4 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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";
|
||||
@@ -23,7 +21,6 @@ type RawPathsResponse = {
|
||||
ffmpeg?: {
|
||||
inputs?: Array<{
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -69,35 +66,17 @@ 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 || !isMaskedPath(rawValue)) {
|
||||
if (!cameraName || inputIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 path =
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||
return typeof path === "string" ? path : undefined;
|
||||
}, [cameraName, inputIndex, rawPaths]);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
const resolvedValue =
|
||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||
const canReveal =
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
// 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",
|
||||
);
|
||||
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||
|
||||
export const isMaskedPath = (value: string): boolean =>
|
||||
MASKED_PATTERN.test(value);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const maskCredentials = (value: string): string => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
|
||||
CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
|
||||
? "://*:*@"
|
||||
: `://*:*@${rest}`,
|
||||
)
|
||||
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
|
||||
};
|
||||
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||
|
||||
export const hasCredentials = (value: string): boolean =>
|
||||
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
|
||||
return maskedAuth
|
||||
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user