mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-30 09:01:14 +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,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
|
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||||
from frigate.genai import PROVIDERS, load_providers
|
from frigate.genai import PROVIDERS, load_providers
|
||||||
from frigate.jobs.media_sync import (
|
from frigate.jobs.media_sync import (
|
||||||
@ -61,7 +62,11 @@ from frigate.util.builtin import (
|
|||||||
process_config_query_string,
|
process_config_query_string,
|
||||||
update_yaml_file_bulk,
|
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.schema import get_config_schema
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
get_nvidia_driver_info,
|
get_nvidia_driver_info,
|
||||||
@ -284,26 +289,24 @@ def config(request: Request):
|
|||||||
if request.headers.get("remote-role") != "admin":
|
if request.headers.get("remote-role") != "admin":
|
||||||
config.pop("environment_vars", None)
|
config.pop("environment_vars", None)
|
||||||
|
|
||||||
# remove mqtt credentials
|
# redact mqtt credentials
|
||||||
config["mqtt"].pop("password", None)
|
redact_credential(config["mqtt"], "password")
|
||||||
config["mqtt"].pop("user", None)
|
|
||||||
|
|
||||||
# remove the proxy secret
|
# redact proxy secret
|
||||||
config["proxy"].pop("auth_secret", None)
|
redact_credential(config["proxy"], "auth_secret")
|
||||||
|
|
||||||
# remove genai api keys
|
# redact genai api keys
|
||||||
for genai_name, genai_cfg in config.get("genai", {}).items():
|
for _genai_name, genai_cfg in config.get("genai", {}).items():
|
||||||
if isinstance(genai_cfg, dict):
|
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():
|
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||||
camera_dict = config["cameras"][camera_name]
|
camera_dict = config["cameras"][camera_name]
|
||||||
|
|
||||||
# remove onvif credentials
|
# redact onvif credentials
|
||||||
onvif_dict = camera_dict.get("onvif", {})
|
onvif_dict = camera_dict.get("onvif", {})
|
||||||
if onvif_dict:
|
if onvif_dict:
|
||||||
onvif_dict.pop("user", None)
|
redact_credential(onvif_dict, "password")
|
||||||
onvif_dict.pop("password", None)
|
|
||||||
|
|
||||||
# clean paths
|
# clean paths
|
||||||
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
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)
|
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||||
updates = flatten_config_data(body.config_data)
|
updates = flatten_config_data(body.config_data)
|
||||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
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:
|
if not updates:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -790,6 +797,13 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
updates = flatten_config_data(body.config_data)
|
updates = flatten_config_data(body.config_data)
|
||||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
# 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()}
|
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:
|
if not updates:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
|
|||||||
|
|
||||||
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
|
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
|
||||||
|
|
||||||
|
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
||||||
|
|
||||||
# Attribute & Object constants
|
# Attribute & Object constants
|
||||||
|
|
||||||
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import frigate.genai
|
import frigate.genai
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
|
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||||
from frigate.genai import GenAIClient
|
from frigate.genai import GenAIClient
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.stats.emitter import StatsEmitter
|
from frigate.stats.emitter import StatsEmitter
|
||||||
@ -75,6 +76,20 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
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 ##################################################
|
################################### POST /genai/probe Endpoint ##################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from typing import Any, Optional, Union
|
|||||||
|
|
||||||
from ruamel.yaml import YAML
|
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.builtin import deep_merge
|
||||||
from frigate.util.services import get_video_properties
|
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")
|
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:
|
def find_config_file() -> str:
|
||||||
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||||
|
|
||||||
|
|||||||
@ -316,5 +316,8 @@
|
|||||||
"pixels": "{{area}}px"
|
"pixels": "{{area}}px"
|
||||||
},
|
},
|
||||||
"no_items": "No items",
|
"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",
|
"ui:widget": "genaiRoles",
|
||||||
},
|
},
|
||||||
"*.api_key": {
|
"*.api_key": {
|
||||||
|
"ui:widget": "password",
|
||||||
"ui:options": { size: "lg" },
|
"ui:options": { size: "lg" },
|
||||||
},
|
},
|
||||||
"*.base_url": {
|
"*.base_url": {
|
||||||
|
|||||||
@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
|
|||||||
liveValidate: true,
|
liveValidate: true,
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
password: {
|
password: {
|
||||||
|
"ui:widget": "password",
|
||||||
"ui:options": { size: "xs" },
|
"ui:options": { size: "xs" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = {
|
|||||||
host: {
|
host: {
|
||||||
"ui:options": { size: "sm" },
|
"ui:options": { size: "sm" },
|
||||||
},
|
},
|
||||||
|
password: {
|
||||||
|
"ui:widget": "password",
|
||||||
|
},
|
||||||
profile: {
|
profile: {
|
||||||
"ui:widget": "onvifProfile",
|
"ui:widget": "onvifProfile",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = {
|
|||||||
"ui:options": { size: "lg" },
|
"ui:options": { size: "lg" },
|
||||||
},
|
},
|
||||||
auth_secret: {
|
auth_secret: {
|
||||||
|
"ui:widget": "password",
|
||||||
"ui:options": { size: "md" },
|
"ui:options": { size: "md" },
|
||||||
},
|
},
|
||||||
header_map: {
|
header_map: {
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||||
import { getSizedFieldClassName } from "../utils";
|
import { getSizedFieldClassName } from "../utils";
|
||||||
|
|
||||||
export function PasswordWidget(props: WidgetProps) {
|
export function PasswordWidget(props: WidgetProps) {
|
||||||
@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) {
|
|||||||
options,
|
options,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const { t } = useTranslation(["common"]);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
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 (
|
return (
|
||||||
<div className={cn("relative", fieldClassName)}>
|
<div className={cn("relative", fieldClassName)}>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={value ?? ""}
|
value={displayValue}
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
placeholder={placeholder || ""}
|
placeholder={effectivePlaceholder}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(e.target.value === "" ? undefined : e.target.value)
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
}
|
}
|
||||||
@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
disabled={disabled}
|
disabled={disabled || isRedacted}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<LuEyeOff className="h-4 w-4" />
|
<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. */
|
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||||
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
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_MIN = -10000;
|
||||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||||
export const ANNOTATION_OFFSET_STEP = 50;
|
export const ANNOTATION_OFFSET_STEP = 50;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual";
|
|||||||
import mergeWith from "lodash/mergeWith";
|
import mergeWith from "lodash/mergeWith";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
import { isJsonObject } from "@/lib/utils";
|
import { isJsonObject } from "@/lib/utils";
|
||||||
|
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
||||||
import {
|
import {
|
||||||
@ -29,6 +30,33 @@ import type {
|
|||||||
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
|
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
|
||||||
import { sectionConfigs } from "../components/config-form/sectionConfigs";
|
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
|
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user