mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-15 19:22:52 +03:00
sanitize user-supplied path components (#23990)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
sanitize_filename leaves ".." intact and collapses variants like "..:" and "..*" to "..", so filesystem paths built from face names, classification model/category names, image ids, and trigger data could escape their base directory. Route every such site through new frigate/util/path.py helpers (safe_join, sanitize_path_component, sanitize_contained_path), which reject traversal and verify containment.
Worst case was DELETE /classification/{name}, which rmtree'd /media/frigate and /config while returning 200.
Important to note that all affected endpoints already require admin permission, so this sould be considered hardening rather than fixing exploitable code.
This commit is contained in:
+132
-63
@@ -11,7 +11,6 @@ from typing import Any
|
||||
import cv2
|
||||
from fastapi import APIRouter, Depends, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -43,12 +42,21 @@ from frigate.util.classification import (
|
||||
write_training_metadata,
|
||||
)
|
||||
from frigate.util.file import get_event_snapshot
|
||||
from frigate.util.path import safe_join, sanitize_path_component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.classification])
|
||||
|
||||
|
||||
def invalid_name_response(value: str) -> JSONResponse:
|
||||
"""Response for a name that cannot be used as a path component."""
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": f"Invalid name: {value}"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/faces",
|
||||
response_model=FacesResponse,
|
||||
@@ -98,9 +106,7 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file = os.path.join(
|
||||
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}"
|
||||
)
|
||||
training_file = safe_join(FACE_DIR, "train", json.get("training_file", ""))
|
||||
|
||||
if not training_file or not os.path.isfile(training_file):
|
||||
return JSONResponse(
|
||||
@@ -150,8 +156,10 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
training_file_name = sanitize_filename(json.get("training_file", ""))
|
||||
training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
|
||||
training_file_name = json.get("training_file", "")
|
||||
training_file = (
|
||||
safe_join(FACE_DIR, "train", training_file_name) if training_file_name else None
|
||||
)
|
||||
event_id = json.get("event_id")
|
||||
|
||||
if not training_file_name and not event_id:
|
||||
@@ -165,7 +173,9 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if training_file_name and not os.path.isfile(training_file):
|
||||
if training_file_name and (
|
||||
training_file is None or not os.path.isfile(training_file)
|
||||
):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
@@ -176,9 +186,13 @@ def train_face(request: Request, name: str, body: dict = None):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_filename(name)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
new_file_folder = safe_join(FACE_DIR, name)
|
||||
|
||||
if sanitized_name is None or new_file_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
new_name = f"{sanitized_name}-{datetime.datetime.now().timestamp()}.webp"
|
||||
new_file_folder = os.path.join(FACE_DIR, f"{sanitized_name}")
|
||||
|
||||
os.makedirs(new_file_folder, exist_ok=True)
|
||||
|
||||
@@ -261,9 +275,12 @@ async def create_face(request: Request, name: str):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
os.makedirs(
|
||||
os.path.join(FACE_DIR, sanitize_filename(name.replace(" ", "_"))), exist_ok=True
|
||||
)
|
||||
face_folder = safe_join(FACE_DIR, name.replace(" ", "_"))
|
||||
|
||||
if face_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
os.makedirs(face_folder, exist_ok=True)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"success": False, "message": "Successfully created face folder."},
|
||||
@@ -287,6 +304,9 @@ def register_face(request: Request, name: str, file: UploadFile):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
if sanitize_path_component(name) is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
result = None if context is None else context.register_face(name, file.file.read())
|
||||
|
||||
@@ -356,8 +376,8 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
image_id = sanitize_filename(json.get("id", ""))
|
||||
new_name = sanitize_filename(json.get("new_name", ""))
|
||||
image_id = sanitize_path_component(json.get("id", ""))
|
||||
new_name = sanitize_path_component(json.get("new_name", ""))
|
||||
|
||||
if not image_id or not new_name:
|
||||
return JSONResponse(
|
||||
@@ -381,7 +401,12 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
|
||||
source_folder = safe_join(FACE_DIR, name)
|
||||
target_folder = safe_join(FACE_DIR, new_name)
|
||||
|
||||
if source_folder is None or target_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
source_file = os.path.join(source_folder, image_id)
|
||||
|
||||
if not os.path.isfile(source_file):
|
||||
@@ -396,7 +421,6 @@ def reclassify_face_image(request: Request, name: str, body: dict = None):
|
||||
)
|
||||
|
||||
target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
|
||||
target_folder = os.path.join(FACE_DIR, new_name)
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
shutil.move(source_file, os.path.join(target_folder, target_filename))
|
||||
@@ -430,8 +454,19 @@ def deregister_faces(request: Request, name: str, body: DeleteFaceImagesBody):
|
||||
content={"message": "Face recognition is not enabled.", "success": False},
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
|
||||
if sanitized_name is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
sanitized_ids = [
|
||||
component
|
||||
for component in map(sanitize_path_component, body.ids)
|
||||
if component is not None
|
||||
]
|
||||
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context.delete_face_ids(name, map(lambda file: sanitize_filename(file), body.ids))
|
||||
context.delete_face_ids(sanitized_name, sanitized_ids)
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||
status_code=200,
|
||||
@@ -642,7 +677,11 @@ def transcribe_audio(request: Request, body: AudioTranscriptionBody):
|
||||
def get_classification_dataset(name: str):
|
||||
dataset_dict: dict[str, list[str]] = {}
|
||||
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
dataset_dir = safe_join(CLIPS_DIR, name, "dataset")
|
||||
|
||||
if sanitized_name is None or dataset_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return JSONResponse(
|
||||
@@ -664,8 +703,8 @@ def get_classification_dataset(name: str):
|
||||
dataset_dict[category_name].append(file)
|
||||
|
||||
# Get training metadata
|
||||
metadata = read_training_metadata(sanitize_filename(name))
|
||||
current_image_count = get_dataset_image_count(sanitize_filename(name))
|
||||
metadata = read_training_metadata(sanitized_name)
|
||||
current_image_count = get_dataset_image_count(sanitized_name)
|
||||
|
||||
if metadata is None:
|
||||
training_metadata = {
|
||||
@@ -729,8 +768,8 @@ def get_custom_attributes(
|
||||
if object_type is not None and object_type not in model_objects:
|
||||
continue
|
||||
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(model_key), "dataset")
|
||||
if not os.path.exists(dataset_dir):
|
||||
dataset_dir = safe_join(CLIPS_DIR, model_key, "dataset")
|
||||
if dataset_dir is None or not os.path.exists(dataset_dir):
|
||||
continue
|
||||
|
||||
attributes = []
|
||||
@@ -760,7 +799,10 @@ def get_custom_attributes(
|
||||
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def get_classification_images(name: str):
|
||||
train_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
|
||||
train_dir = safe_join(CLIPS_DIR, name, "train")
|
||||
|
||||
if train_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(train_dir):
|
||||
return JSONResponse(status_code=200, content=[])
|
||||
@@ -831,15 +873,17 @@ def delete_classification_dataset_images(
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if sanitized_name is None or folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
deleted_count = 0
|
||||
for id in list_of_ids:
|
||||
file_path = os.path.join(folder, sanitize_filename(id))
|
||||
file_path = safe_join(folder, id)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
if file_path and os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
deleted_count += 1
|
||||
|
||||
@@ -850,7 +894,6 @@ def delete_classification_dataset_images(
|
||||
# This ensures the dataset is marked as changed after deletion
|
||||
# (even if the total count happens to be the same after adding and deleting)
|
||||
if deleted_count > 0:
|
||||
sanitized_name = sanitize_filename(name)
|
||||
metadata = read_training_metadata(sanitized_name)
|
||||
if metadata:
|
||||
last_count = metadata.get("last_training_image_count", 0)
|
||||
@@ -888,8 +931,8 @@ def reclassify_classification_image(
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
image_id = sanitize_filename(json.get("id", ""))
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
image_id = sanitize_path_component(json.get("id", ""))
|
||||
new_category = sanitize_path_component(json.get("new_category", ""))
|
||||
|
||||
if not image_id or not new_category:
|
||||
return JSONResponse(
|
||||
@@ -913,10 +956,13 @@ def reclassify_classification_image(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_filename(name)
|
||||
source_folder = os.path.join(
|
||||
CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category)
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
source_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
target_folder = safe_join(CLIPS_DIR, name, "dataset", new_category)
|
||||
|
||||
if sanitized_name is None or source_folder is None or target_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
source_file = os.path.join(source_folder, image_id)
|
||||
|
||||
if not os.path.isfile(source_file):
|
||||
@@ -933,7 +979,6 @@ def reclassify_classification_image(
|
||||
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
timestamp = datetime.datetime.now().timestamp()
|
||||
new_name = f"{new_category}-{timestamp}-{random_id}.png"
|
||||
target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category)
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
@@ -983,7 +1028,7 @@ def rename_classification_category(
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
new_category = sanitize_path_component(json.get("new_category", ""))
|
||||
|
||||
if not new_category:
|
||||
return JSONResponse(
|
||||
@@ -996,12 +1041,12 @@ def rename_classification_category(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
old_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(old_category)
|
||||
)
|
||||
new_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", new_category
|
||||
)
|
||||
sanitized_name = sanitize_path_component(name)
|
||||
old_folder = safe_join(CLIPS_DIR, name, "dataset", old_category)
|
||||
new_folder = safe_join(CLIPS_DIR, name, "dataset", new_category)
|
||||
|
||||
if sanitized_name is None or old_folder is None or new_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
if not os.path.exists(old_folder):
|
||||
return JSONResponse(
|
||||
@@ -1030,7 +1075,6 @@ def rename_classification_category(
|
||||
|
||||
# Mark dataset as ready to train by resetting training metadata
|
||||
# This ensures the dataset is marked as changed after renaming
|
||||
sanitized_name = sanitize_filename(name)
|
||||
write_training_metadata(sanitized_name, 0)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1078,13 +1122,20 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
category = sanitize_filename(json.get("category", ""))
|
||||
training_file_name = sanitize_filename(json.get("training_file", ""))
|
||||
training_file = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "train", training_file_name
|
||||
category = sanitize_path_component(json.get("category", ""))
|
||||
training_file_name = json.get("training_file", "")
|
||||
training_file = (
|
||||
safe_join(CLIPS_DIR, name, "train", training_file_name)
|
||||
if training_file_name
|
||||
else None
|
||||
)
|
||||
|
||||
if training_file_name and not os.path.isfile(training_file):
|
||||
if category is None:
|
||||
return invalid_name_response(json.get("category", ""))
|
||||
|
||||
if training_file_name and (
|
||||
training_file is None or not os.path.isfile(training_file)
|
||||
):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
@@ -1098,9 +1149,10 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
||||
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
timestamp = datetime.datetime.now().timestamp()
|
||||
new_name = f"{category}-{timestamp}-{random_id}.png"
|
||||
new_file_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", category
|
||||
)
|
||||
new_file_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if new_file_folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
os.makedirs(new_file_folder, exist_ok=True)
|
||||
|
||||
@@ -1138,9 +1190,10 @@ def create_classification_category(request: Request, name: str, category: str):
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
category_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
|
||||
)
|
||||
category_folder = safe_join(CLIPS_DIR, name, "dataset", category)
|
||||
|
||||
if category_folder is None:
|
||||
return invalid_name_response(category)
|
||||
|
||||
os.makedirs(category_folder, exist_ok=True)
|
||||
|
||||
@@ -1179,12 +1232,15 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
list_of_ids = json.get("ids", "")
|
||||
folder = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
|
||||
folder = safe_join(CLIPS_DIR, name, "train")
|
||||
|
||||
if folder is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
for id in list_of_ids:
|
||||
file_path = os.path.join(folder, sanitize_filename(id))
|
||||
file_path = safe_join(folder, id)
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
if file_path and os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1201,7 +1257,11 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
||||
)
|
||||
async def generate_state_examples(request: Request, body: GenerateStateExamplesBody):
|
||||
"""Generate examples for state classification."""
|
||||
model_name = sanitize_filename(body.model_name)
|
||||
model_name = sanitize_path_component(body.model_name)
|
||||
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
cameras_normalized = {
|
||||
camera_name: tuple(crop)
|
||||
for camera_name, crop in body.cameras.items()
|
||||
@@ -1224,7 +1284,11 @@ async def generate_state_examples(request: Request, body: GenerateStateExamplesB
|
||||
)
|
||||
async def generate_object_examples(request: Request, body: GenerateObjectExamplesBody):
|
||||
"""Generate examples for object classification."""
|
||||
model_name = sanitize_filename(body.model_name)
|
||||
model_name = sanitize_path_component(body.model_name)
|
||||
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1243,10 +1307,16 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
Returns a success message.""",
|
||||
)
|
||||
def delete_classification_model(request: Request, name: str):
|
||||
sanitized_name = sanitize_filename(name)
|
||||
# This endpoint intentionally accepts models that are not in the config, so
|
||||
# there is no allow list to fall back on. Both paths below are recursive
|
||||
# deletes, so an unusable name has to be rejected outright.
|
||||
data_dir = safe_join(CLIPS_DIR, name)
|
||||
model_dir = safe_join(MODEL_CACHE_DIR, name)
|
||||
|
||||
if data_dir is None or model_dir is None:
|
||||
return invalid_name_response(name)
|
||||
|
||||
# Delete the classification model's data directory in clips
|
||||
data_dir = os.path.join(CLIPS_DIR, sanitized_name)
|
||||
if os.path.exists(data_dir):
|
||||
try:
|
||||
shutil.rmtree(data_dir)
|
||||
@@ -1255,7 +1325,6 @@ def delete_classification_model(request: Request, name: str):
|
||||
logger.debug(f"Failed to delete data directory for {name}: {e}")
|
||||
|
||||
# Delete the classification model's files in model_cache
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name)
|
||||
if os.path.exists(model_dir):
|
||||
try:
|
||||
shutil.rmtree(model_dir)
|
||||
|
||||
Reference in New Issue
Block a user