mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 21:22:50 +03:00
restore masked credentials by value on config save
Masked camera paths were restored by index, so deleting the first input wrote its credentials onto the next input. Each masked path now matches the saved input that masks to the same value, with roles breaking a tie, and an unmatched or ambiguous path is rejected instead of written. A redacted ICE server is filled from the saved server with the same urls, or rejected when its urls changed, instead of dropping the whole list. /config also redacts the go2rtc mqtt credentials, the ngrok command, homekit pins and pairings, and publish destinations.
This commit is contained in:
+164
-45
@@ -403,18 +403,7 @@ def config(request: Request):
|
||||
|
||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||
|
||||
for section in ("rtsp", "api"):
|
||||
go2rtc_section = config["go2rtc"].get(section)
|
||||
if isinstance(go2rtc_section, dict):
|
||||
redact_credential(go2rtc_section, "username")
|
||||
redact_credential(go2rtc_section, "password")
|
||||
|
||||
webrtc = config["go2rtc"].get("webrtc")
|
||||
if isinstance(webrtc, dict):
|
||||
for ice_server in webrtc.get("ice_servers") or []:
|
||||
if isinstance(ice_server, dict):
|
||||
redact_credential(ice_server, "username")
|
||||
redact_credential(ice_server, "credential")
|
||||
_redact_go2rtc_credentials(config["go2rtc"])
|
||||
|
||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||
|
||||
@@ -442,6 +431,35 @@ 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)
|
||||
|
||||
webrtc = go2rtc.get("webrtc")
|
||||
if isinstance(webrtc, dict):
|
||||
for ice_server in webrtc.get("ice_servers") or []:
|
||||
if isinstance(ice_server, dict):
|
||||
redact_credential(ice_server, "username")
|
||||
redact_credential(ice_server, "credential")
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
@@ -756,17 +774,21 @@ 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, config: FrigateConfig
|
||||
) -> str | None:
|
||||
"""Substitute incoming masked camera paths 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. 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 existing input that masks to
|
||||
the same value, with roles breaking a tie, so deleting or reordering inputs
|
||||
keeps each path's own credentials. 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
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -775,17 +797,109 @@ def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> No
|
||||
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]
|
||||
existing_inputs = existing.ffmpeg.inputs if existing else []
|
||||
|
||||
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 is_masked_camera_path(path) and index < len(existing_paths):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
candidates = [
|
||||
inp
|
||||
for inp in existing_inputs
|
||||
if clean_camera_user_pass(inp.path) == path
|
||||
]
|
||||
if len({inp.path for inp in candidates}) > 1:
|
||||
candidates = [
|
||||
inp for inp in candidates if inp.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 _restore_redacted_ice_servers(
|
||||
config_data: dict, saved_go2rtc: dict[str, Any]
|
||||
) -> str | None:
|
||||
"""Fill redacted ICE server fields from the saved server with the same urls.
|
||||
|
||||
Lists are written whole, so the sentinel can't be dropped per field like
|
||||
other redacted credentials. Mutates `config_data` in place and returns an
|
||||
error message when a redacted server has no saved match.
|
||||
"""
|
||||
go2rtc = config_data.get("go2rtc")
|
||||
webrtc = go2rtc.get("webrtc") if isinstance(go2rtc, dict) else None
|
||||
ice_servers = webrtc.get("ice_servers") if isinstance(webrtc, dict) else None
|
||||
if not isinstance(ice_servers, list):
|
||||
return None
|
||||
|
||||
saved_webrtc = saved_go2rtc.get("webrtc")
|
||||
saved_servers = (
|
||||
saved_webrtc.get("ice_servers") or [] if isinstance(saved_webrtc, dict) else []
|
||||
)
|
||||
|
||||
for server in ice_servers:
|
||||
if (
|
||||
not isinstance(server, dict)
|
||||
or REDACTED_CREDENTIAL_SENTINEL not in server.values()
|
||||
):
|
||||
continue
|
||||
match = next(
|
||||
(
|
||||
saved
|
||||
for saved in saved_servers
|
||||
if isinstance(saved, dict) and saved.get("urls") == server.get("urls")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
return (
|
||||
"An ICE server contains saved credentials but its urls changed; "
|
||||
"resend it with real values"
|
||||
)
|
||||
for key, value in list(server.items()):
|
||||
if value != REDACTED_CREDENTIAL_SENTINEL:
|
||||
continue
|
||||
if key in match:
|
||||
server[key] = match[key]
|
||||
else:
|
||||
del server[key]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _config_data_to_updates(
|
||||
config_data: dict, config: FrigateConfig, saved_go2rtc: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Flatten a /config/set payload after restoring masked credentials.
|
||||
|
||||
Returns the flat updates, or an error message when a masked or redacted
|
||||
value can't be restored.
|
||||
"""
|
||||
error = _restore_masked_camera_paths(
|
||||
config_data, config
|
||||
) or _restore_redacted_ice_servers(config_data, saved_go2rtc)
|
||||
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}
|
||||
|
||||
if any(REDACTED_CREDENTIAL_SENTINEL in str(v) for v in updates.values()):
|
||||
return {}, "Config contains saved credentials; resend with real values"
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -798,15 +912,17 @@ 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 still holds the redaction sentinel
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if REDACTED_CREDENTIAL_SENTINEL not in str(v)
|
||||
}
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data,
|
||||
request.app.frigate_config,
|
||||
request.app.frigate_config.go2rtc.model_dump(
|
||||
mode="json", warnings="none"
|
||||
),
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error}, status_code=400
|
||||
)
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -956,20 +1072,23 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
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
|
||||
saved_go2rtc = (
|
||||
saved_config.get("go2rtc") or {}
|
||||
if isinstance(saved_config, dict)
|
||||
else {}
|
||||
)
|
||||
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. Lists
|
||||
# are leaf values, so check for a sentinel nested inside.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if REDACTED_CREDENTIAL_SENTINEL not in str(v)
|
||||
}
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data, request.app.frigate_config, saved_go2rtc
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -514,6 +514,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
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": [
|
||||
@@ -569,6 +573,17 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
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
|
||||
]
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
@@ -579,36 +594,166 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
go2rtc = client.get("/config").json()["go2rtc"]
|
||||
ice_server = go2rtc["webrtc"]["ice_servers"][0]
|
||||
response = client.get("/config").json()
|
||||
body = str(response)
|
||||
for secret in (
|
||||
"rtsppass",
|
||||
"apipass",
|
||||
"mqpass",
|
||||
"ngroksecret",
|
||||
"19550224",
|
||||
"streamkey",
|
||||
"turnpass",
|
||||
):
|
||||
self.assertNotIn(secret, body)
|
||||
|
||||
for section in ("rtsp", "api"):
|
||||
self.assertEqual(
|
||||
go2rtc[section]["username"], REDACTED_CREDENTIAL_SENTINEL
|
||||
)
|
||||
self.assertEqual(
|
||||
go2rtc[section]["password"], REDACTED_CREDENTIAL_SENTINEL
|
||||
)
|
||||
self.assertEqual(ice_server["credential"], REDACTED_CREDENTIAL_SENTINEL)
|
||||
self.assertEqual(ice_server["username"], REDACTED_CREDENTIAL_SENTINEL)
|
||||
go2rtc = response["go2rtc"]
|
||||
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
|
||||
self.assertEqual(
|
||||
go2rtc["webrtc"]["ice_servers"][0]["credential"],
|
||||
REDACTED_CREDENTIAL_SENTINEL,
|
||||
)
|
||||
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={"config_data": {"go2rtc": go2rtc}, "requires_restart": 0},
|
||||
go2rtc.pop("ngrok")
|
||||
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
|
||||
self.assertEqual(
|
||||
self._save(client, {"go2rtc": go2rtc}).status_code, 200
|
||||
)
|
||||
self.assertEqual(resp.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"]["api"]["password"], "apipass")
|
||||
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_redacted_ice_server_with_changed_urls_is_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:
|
||||
ice_servers = client.get("/config").json()["go2rtc"]["webrtc"][
|
||||
"ice_servers"
|
||||
]
|
||||
ice_servers[0]["urls"] = ["turn:new.example.com:3478"]
|
||||
webrtc = {"go2rtc": {"webrtc": {"ice_servers": ice_servers}}}
|
||||
resp = self._save(client, webrtc)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("saved credentials", resp.json()["message"])
|
||||
|
||||
ice_servers[0]["username"] = "newuser"
|
||||
ice_servers[0]["credential"] = "newpass"
|
||||
self.assertEqual(self._save(client, webrtc).status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "newpass"
|
||||
)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user