mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Improve credential redaction handling (#23265)
* redact credentials in config endpoint with sentinel * backend test * frontend * apply widget for credential fields * i18n
This commit is contained in:
parent
5ef8b9b924
commit
68e8afd35c
@ -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(
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ const genai: SectionConfigOverrides = {
|
||||
"ui:widget": "genaiRoles",
|
||||
},
|
||||
"*.api_key": {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
"*.base_url": {
|
||||
|
||||
@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
|
||||
liveValidate: true,
|
||||
uiSchema: {
|
||||
password: {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
|
||||
@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
password: {
|
||||
"ui:widget": "password",
|
||||
},
|
||||
profile: {
|
||||
"ui:widget": "onvifProfile",
|
||||
},
|
||||
|
||||
@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
auth_secret: {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
header_map: {
|
||||
|
||||
@ -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 (
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value ?? ""}
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || ""}
|
||||
placeholder={effectivePlaceholder}
|
||||
onChange={(e) =>
|
||||
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 ? (
|
||||
<LuEyeOff className="h-4 w-4" />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<T>(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<string, unknown>;
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user