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:
Josh Hawkins
2026-10-18 14:41:28 -05:00
parent c90e00061a
commit 7dc981e5be
2 changed files with 325 additions and 61 deletions
+164 -45
View File
@@ -403,18 +403,7 @@ def config(request: Request):
config["go2rtc"]["streams"][stream_name] = cleaned config["go2rtc"]["streams"][stream_name] = cleaned
for section in ("rtsp", "api"): _redact_go2rtc_credentials(config["go2rtc"])
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")
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()} config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
@@ -442,6 +431,35 @@ def config(request: Request):
return JSONResponse(content=config) 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())]) @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
def get_profiles(request: Request): def get_profiles(request: Request):
"""List all available profiles and the currently active profile.""" """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: def _restore_masked_camera_paths(
"""Substitute incoming `*:*` masked credentials with the in-memory ones. 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 The /config response masks ffmpeg input credentials, so the settings UI
sends the masked path back when sibling fields (e.g. hwaccel_args) are 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 edited. Each masked path is matched to the existing input that masks to
the real credentials. Mutates `config_data` in place. 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") cameras = config_data.get("cameras")
if not isinstance(cameras, dict): if not isinstance(cameras, dict):
return return None
for camera_name, camera_data in cameras.items(): for camera_name, camera_data in cameras.items():
if not isinstance(camera_data, dict): 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): if not isinstance(inputs, list):
continue continue
existing = config.cameras.get(camera_name) existing = config.cameras.get(camera_name)
if existing is None: existing_inputs = existing.ffmpeg.inputs if existing else []
continue
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
for index, input_obj in enumerate(inputs): for index, input_obj in enumerate(inputs):
if not isinstance(input_obj, dict): if not isinstance(input_obj, dict):
continue continue
path = input_obj.get("path") path = input_obj.get("path")
if not isinstance(path, str): if not isinstance(path, str) or not is_masked_camera_path(path):
continue continue
if is_masked_camera_path(path) and index < len(existing_paths): candidates = [
input_obj["path"] = existing_paths[index] 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: 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: try:
updates = {} updates = {}
if body.config_data: if body.config_data:
_restore_masked_camera_paths(body.config_data, request.app.frigate_config) updates, error = _config_data_to_updates(
updates = flatten_config_data(body.config_data) body.config_data,
updates = {k: ("" if v is None else v) for k, v in updates.items()} request.app.frigate_config,
# Drop any field whose value still holds the redaction sentinel request.app.frigate_config.go2rtc.model_dump(
updates = { mode="json", warnings="none"
k: v ),
for k, v in updates.items() )
if REDACTED_CREDENTIAL_SENTINEL not in str(v) if error:
} return JSONResponse(
content={"success": False, "message": error}, status_code=400
)
if not updates: if not updates:
return JSONResponse( return JSONResponse(
@@ -956,20 +1072,23 @@ def config_set(request: Request, body: AppConfigSetBody):
if query_string: if query_string:
updates = process_config_query_string(query_string) updates = process_config_query_string(query_string)
elif body.config_data: elif body.config_data:
_restore_masked_camera_paths( try:
body.config_data, request.app.frigate_config 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) updates, error = _config_data_to_updates(
# Convert None values to empty strings for deletion (e.g., when deleting masks) body.config_data, request.app.frigate_config, saved_go2rtc
updates = {k: ("" if v is None else v) for k, v in updates.items()} )
# Drop sentinel-valued fields so untouched credential if error:
# placeholders don't clobber the saved YAML value. Lists return JSONResponse(
# are leaf values, so check for a sentinel nested inside. content={"success": False, "message": error},
updates = { status_code=400,
k: v )
for k, v in updates.items()
if REDACTED_CREDENTIAL_SENTINEL not in str(v)
}
if not updates: if not updates:
return JSONResponse( return JSONResponse(
+161 -16
View File
@@ -514,6 +514,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
self.minimal_config["go2rtc"] = { self.minimal_config["go2rtc"] = {
"rtsp": {"username": "rtspuser", "password": "rtsppass"}, "rtsp": {"username": "rtspuser", "password": "rtsppass"},
"api": {"username": "apiuser", "password": "apipass"}, "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": { "webrtc": {
"candidates": ["10.0.0.5:8555"], "candidates": ["10.0.0.5:8555"],
"ice_servers": [ "ice_servers": [
@@ -569,6 +573,17 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally: finally:
os.unlink(config_path) 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") @patch("frigate.api.app.find_config_file")
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config): def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
self._add_credentials() self._add_credentials()
@@ -579,36 +594,166 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
app, _ = self._create_app_with_publisher() app, _ = self._create_app_with_publisher()
with AuthTestClient(app) as client: with AuthTestClient(app) as client:
go2rtc = client.get("/config").json()["go2rtc"] response = client.get("/config").json()
ice_server = go2rtc["webrtc"]["ice_servers"][0] body = str(response)
for secret in (
"rtsppass",
"apipass",
"mqpass",
"ngroksecret",
"19550224",
"streamkey",
"turnpass",
):
self.assertNotIn(secret, body)
for section in ("rtsp", "api"): go2rtc = response["go2rtc"]
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)
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
self.assertEqual(
go2rtc["webrtc"]["ice_servers"][0]["credential"],
REDACTED_CREDENTIAL_SENTINEL,
)
resp = client.put( go2rtc.pop("ngrok")
"/config/set", go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
json={"config_data": {"go2rtc": go2rtc}, "requires_restart": 0}, self.assertEqual(
self._save(client, {"go2rtc": go2rtc}).status_code, 200
) )
self.assertEqual(resp.status_code, 200)
raw, saved = self._read_config_file(config_path) raw, saved = self._read_config_file(config_path)
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass") 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( self.assertEqual(
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass" saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
) )
finally: finally:
os.unlink(config_path) 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__": if __name__ == "__main__":
unittest.main() unittest.main()