From 3c9fe6fa0f5ba8bb82021e89eec8c4ae46fcda45 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 11:32:31 -0500 Subject: [PATCH] redact credentials in config endpoint with sentinel --- frigate/api/app.py | 38 ++++++++++++++++++++++++++------------ frigate/const.py | 2 ++ frigate/util/config.py | 17 ++++++++++++++++- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 2ba016d63e..cc5adc6f65 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -43,6 +43,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateTopic, ) +from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.genai import PROVIDERS, load_providers from frigate.jobs.media_sync import ( @@ -61,7 +62,11 @@ from frigate.util.builtin import ( process_config_query_string, update_yaml_file_bulk, ) -from frigate.util.config import apply_section_update, find_config_file +from frigate.util.config import ( + apply_section_update, + find_config_file, + redact_credential, +) from frigate.util.schema import get_config_schema from frigate.util.services import ( get_nvidia_driver_info, @@ -284,26 +289,24 @@ def config(request: Request): if request.headers.get("remote-role") != "admin": config.pop("environment_vars", None) - # remove mqtt credentials - config["mqtt"].pop("password", None) - config["mqtt"].pop("user", None) + # redact mqtt credentials + redact_credential(config["mqtt"], "password") - # remove the proxy secret - config["proxy"].pop("auth_secret", None) + # redact proxy secret + redact_credential(config["proxy"], "auth_secret") - # remove genai api keys - for genai_name, genai_cfg in config.get("genai", {}).items(): + # redact genai api keys + for _genai_name, genai_cfg in config.get("genai", {}).items(): if isinstance(genai_cfg, dict): - genai_cfg.pop("api_key", None) + redact_credential(genai_cfg, "api_key") for camera_name, camera in request.app.frigate_config.cameras.items(): camera_dict = config["cameras"][camera_name] - # remove onvif credentials + # redact onvif credentials onvif_dict = camera_dict.get("onvif", {}) if onvif_dict: - onvif_dict.pop("user", None) - onvif_dict.pop("password", None) + redact_credential(onvif_dict, "password") # clean paths for input in camera_dict.get("ffmpeg", {}).get("inputs", []): @@ -680,6 +683,10 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo _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( @@ -790,6 +797,13 @@ def config_set(request: Request, body: AppConfigSetBody): 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( diff --git a/frigate/const.py b/frigate/const.py index 07537ea5f7..dac04c4f1a 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video" SHM_FRAMES_VAR = "SHM_MAX_FRAMES" +REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__" + # Attribute & Object constants DEFAULT_ATTRIBUTE_LABEL_MAP = { diff --git a/frigate/util/config.py b/frigate/util/config.py index 71e2af809d..e6e3f09666 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -8,7 +8,7 @@ from typing import Any, Optional, Union from ruamel.yaml import YAML -from frigate.const import CONFIG_DIR, EXPORT_DIR +from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL from frigate.util.builtin import deep_merge from frigate.util.services import get_video_properties @@ -18,6 +18,21 @@ CURRENT_CONFIG_VERSION = "0.18-0" DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml") +def redact_credential(obj: dict[str, Any], key: str) -> None: + """Replace obj[key] with the redaction sentinel if a value is saved, else drop. + + Used when shaping the /config response so saved credentials never leave + the server. The frontend recognizes REDACTED_CREDENTIAL_SENTINEL, renders + the field as empty with a "saved — leave blank to keep" placeholder, and + /config/set strips it from any incoming payload so the YAML value is + preserved when the user doesn't touch the field. + """ + if obj.get(key): + obj[key] = REDACTED_CREDENTIAL_SENTINEL + else: + obj.pop(key, None) + + def find_config_file() -> str: config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)