mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 21:22:50 +03:00
reject config saves that still hold masked credentials
Index-keyed input maps, query-string saves and go2rtc stream lists echoed from /config bypassed the credential restore and could write a masked path or the redaction sentinel over the real value. Every save, body or query string, now rejects any value that still holds one after the restores run, naming the field.
This commit is contained in:
+37
-7
@@ -943,12 +943,38 @@ def _config_data_to_updates(
|
||||
# clobber the saved YAML value.
|
||||
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
|
||||
|
||||
if any(REDACTED_CREDENTIAL_SENTINEL in str(v) for v in updates.values()):
|
||||
return {}, "Config contains saved credentials; resend with real values"
|
||||
error = _find_masked_update(updates)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _holds_masked_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_holds_masked_value(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_holds_masked_value(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _find_masked_update(updates: dict[str, Any]) -> str | None:
|
||||
"""Return an error naming the first update that still holds a masked value.
|
||||
|
||||
Runs after the restores so a masked path or redaction sentinel that could
|
||||
not be restored is never written over the real credential.
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if _holds_masked_value(value):
|
||||
return (
|
||||
f"{key} contains masked or saved credentials; "
|
||||
"resend it with real values"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
"""Apply config changes in-memory only, without writing to YAML.
|
||||
|
||||
@@ -1112,8 +1138,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
error = None
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
error = _find_masked_update(updates)
|
||||
elif body.config_data:
|
||||
try:
|
||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
||||
@@ -1123,11 +1152,12 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
body.config_data,
|
||||
saved_config if isinstance(saved_config, dict) else {},
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -622,6 +622,100 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_over_masked_path_round_trips(self, mock_find_config):
|
||||
path = "rtsp://10.0.0.1:554/live?x=a@b"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
self.assertNotEqual(inputs[0]["path"], path)
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for name, request in (
|
||||
(
|
||||
"index-keyed inputs",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"0": {"path": "rtsp://*:*@h/x"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"go2rtc streams",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"query string",
|
||||
{
|
||||
"params": {
|
||||
"cameras.front_door.ffmpeg.inputs.0.path": (
|
||||
"rtsp://*:*@h/x"
|
||||
),
|
||||
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
"json": {"requires_restart": 0},
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(name):
|
||||
resp = client.put("/config/set", **request)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("credentials", resp.json()["message"])
|
||||
|
||||
raw, _ = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
|
||||
Reference in New Issue
Block a user