From 68e8afd35c76f05f68de47ee9588d2c91796de4b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 20 May 2026 16:59:01 -0500 Subject: [PATCH] Improve credential redaction handling (#23265) * redact credentials in config endpoint with sentinel * backend test * frontend * apply widget for credential fields * i18n --- frigate/api/app.py | 38 +++++++++++++------ frigate/const.py | 2 + frigate/test/http_api/test_http_app.py | 15 ++++++++ frigate/util/config.py | 17 ++++++++- web/public/locales/en/common.json | 5 ++- .../config-form/section-configs/genai.ts | 1 + .../config-form/section-configs/mqtt.ts | 1 + .../config-form/section-configs/onvif.ts | 3 ++ .../config-form/section-configs/proxy.ts | 1 + .../theme/widgets/PasswordWidget.tsx | 22 +++++++++-- web/src/lib/const.ts | 10 +++++ web/src/utils/configUtil.ts | 28 ++++++++++++++ 12 files changed, 126 insertions(+), 17 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/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index 3198515267..4c581dd426 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import frigate.genai from frigate.config import GenAIProviderEnum +from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.genai import GenAIClient from frigate.models import Event, Recordings, ReviewSegment from frigate.stats.emitter import StatsEmitter @@ -75,6 +76,20 @@ class TestHttpApp(BaseTestHttp): assert response.status_code == 200 assert app.frigate_config.cameras["front_door"].objects.track == ["person"] + #################################################################################################################### + ################################### Credential redaction sentinel ################################################ + #################################################################################################################### + def test_config_response_redacts_mqtt_password_with_sentinel(self): + self.minimal_config["mqtt"]["user"] = "mqttuser" + self.minimal_config["mqtt"]["password"] = "supersecret" + app = super().create_app() + + with AuthTestClient(app) as client: + response = client.get("/config") + assert response.status_code == 200 + mqtt = response.json()["mqtt"] + assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL + #################################################################################################################### ################################### POST /genai/probe Endpoint ################################################## #################################################################################################################### 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) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index a05126c681..4436808d08 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -316,5 +316,8 @@ "pixels": "{{area}}px" }, "no_items": "No items", - "validation_errors": "Validation Errors" + "validation_errors": "Validation Errors", + "credentialField": { + "savedPlaceholder": "Saved — leave blank to keep current" + } } diff --git a/web/src/components/config-form/section-configs/genai.ts b/web/src/components/config-form/section-configs/genai.ts index a5f1cd8a32..bd7a875c8e 100644 --- a/web/src/components/config-form/section-configs/genai.ts +++ b/web/src/components/config-form/section-configs/genai.ts @@ -24,6 +24,7 @@ const genai: SectionConfigOverrides = { "ui:widget": "genaiRoles", }, "*.api_key": { + "ui:widget": "password", "ui:options": { size: "lg" }, }, "*.base_url": { diff --git a/web/src/components/config-form/section-configs/mqtt.ts b/web/src/components/config-form/section-configs/mqtt.ts index 67d863b089..52fd6b6d79 100644 --- a/web/src/components/config-form/section-configs/mqtt.ts +++ b/web/src/components/config-form/section-configs/mqtt.ts @@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = { liveValidate: true, uiSchema: { password: { + "ui:widget": "password", "ui:options": { size: "xs" }, }, }, diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index 71163a0341..edb62ff7fe 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = { host: { "ui:options": { size: "sm" }, }, + password: { + "ui:widget": "password", + }, profile: { "ui:widget": "onvifProfile", }, diff --git a/web/src/components/config-form/section-configs/proxy.ts b/web/src/components/config-form/section-configs/proxy.ts index ffdb27cf9f..08e05b6b86 100644 --- a/web/src/components/config-form/section-configs/proxy.ts +++ b/web/src/components/config-form/section-configs/proxy.ts @@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = { "ui:options": { size: "lg" }, }, auth_secret: { + "ui:widget": "password", "ui:options": { size: "md" }, }, header_map: { diff --git a/web/src/components/config-form/theme/widgets/PasswordWidget.tsx b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx index 80a4e504ee..a7a09af7b3 100644 --- a/web/src/components/config-form/theme/widgets/PasswordWidget.tsx +++ b/web/src/components/config-form/theme/widgets/PasswordWidget.tsx @@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; import { cn } from "@/lib/utils"; +import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { getSizedFieldClassName } from "../utils"; export function PasswordWidget(props: WidgetProps) { @@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) { options, } = props; + const { t } = useTranslation(["common"]); const [showPassword, setShowPassword] = useState(false); const fieldClassName = getSizedFieldClassName(options, "sm"); + // When the backend returns the sentinel, hide it visually and prompt the + // user that a value is already saved. The value stays as the sentinel in + // form state — backend /config/set strips it so the saved YAML is + // preserved when the user doesn't touch the field. + const isRedacted = value === REDACTED_CREDENTIAL_SENTINEL; + const displayValue = isRedacted ? "" : (value ?? ""); + const effectivePlaceholder = isRedacted + ? t("credentialField.savedPlaceholder", { + ns: "common", + defaultValue: "Saved — leave blank to keep current", + }) + : placeholder || ""; + return (
onChange(e.target.value === "" ? undefined : e.target.value) } @@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) { size="sm" className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} - disabled={disabled} + disabled={disabled || isRedacted} > {showPassword ? ( diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index 96aa1f4b18..5db13e375d 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -1,6 +1,16 @@ /** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const; +/** + * Sentinel the backend substitutes for saved credentials (api keys, + * passwords, secrets) in /config responses. The credential widget renders + * this value as an empty input with a "saved — leave blank to keep" hint, + * and stripRedactedCredentials() removes any field still equal to this + * value before sending a config/set payload so the saved YAML value is + * preserved. Mirror of frigate.const.REDACTED_CREDENTIAL_SENTINEL. + */ +export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"; + export const ANNOTATION_OFFSET_MIN = -10000; export const ANNOTATION_OFFSET_MAX = 5000; export const ANNOTATION_OFFSET_STEP = 50; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 4b6ffefb71..cb7f6f52b6 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual"; import mergeWith from "lodash/mergeWith"; import set from "lodash/set"; import { isJsonObject } from "@/lib/utils"; +import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { applySchemaDefaults } from "@/lib/config-schema"; import { normalizeConfigValue } from "@/hooks/use-config-override"; import { @@ -29,6 +30,33 @@ import type { import type { SectionConfig } from "../components/config-form/sections/BaseSection"; import { sectionConfigs } from "../components/config-form/sectionConfigs"; +/** + * Recursively strip any key whose value is the redaction sentinel from a + * config_data payload. Use just before sending to /config/set so untouched + * credential placeholder fields don't clobber the saved YAML value. Mutates + * and returns the input. + */ +export function stripRedactedCredentials(value: T): T { + if (Array.isArray(value)) { + for (const item of value) { + stripRedactedCredentials(item); + } + return value; + } + if (value && typeof value === "object") { + const obj = value as Record; + for (const key of Object.keys(obj)) { + const v = obj[key]; + if (v === REDACTED_CREDENTIAL_SENTINEL) { + delete obj[key]; + } else if (v && typeof v === "object") { + stripRedactedCredentials(v); + } + } + } + return value; +} + // --------------------------------------------------------------------------- // cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics // ---------------------------------------------------------------------------