Files
frigate/frigate/util/path.py
T
Josh HawkinsandGitHub 11f8786459
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 user-supplied path components (#23990)
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.
2026-08-13 21:59:46 -05:00

135 lines
4.2 KiB
Python

"""Helpers for building filesystem paths out of user supplied values."""
import os
from pathvalidate import ValidationError, sanitize_filename, sanitize_filepath
from frigate.const import TRIGGER_DIR
# Components that name a directory relative to its parent instead of a child.
# pathvalidate strips separators and reserved characters but leaves these
# intact, and it collapses values like "..:" down to "..", so they have to be
# rejected after sanitizing rather than before.
RELATIVE_COMPONENTS = {"", ".", ".."}
def sanitize_path_component(value: str | None) -> str | None:
"""Reduce a user supplied value to a single path component.
Args:
value: The untrusted value, such as a path parameter or body field
Returns:
A component that is safe to join onto a base directory, or None when
nothing usable remains so the caller can reject the request.
"""
if not value:
return None
try:
component = sanitize_filename(value)
except (ValidationError, ValueError):
return None
if component.strip() in RELATIVE_COMPONENTS:
return None
if os.sep in component or (os.altsep and os.altsep in component):
return None
return component
def is_contained_in(path: str, base: str) -> bool:
"""Check that a path sits inside a base directory.
Compares whole path components, so a sibling directory that merely shares a
name prefix with base is not treated as contained.
"""
resolved = os.path.normpath(path)
root = os.path.normpath(base)
try:
# commonpath compares components, and unlike a prefix test it stays
# correct for a base that already ends in a separator such as "/".
return os.path.commonpath([resolved, root]) == root
except ValueError:
# Raised when the paths cannot be compared, such as one relative and
# one absolute, or two different Windows drives.
return False
def safe_join(base: str, *parts: str | None) -> str | None:
"""Join user supplied parts beneath a trusted base directory.
Args:
base: Trusted base directory the result must stay inside of
parts: Untrusted values, each becoming one path component
Returns:
The joined path, or None if any part is unusable or the result would
land outside base.
"""
components: list[str] = []
for part in parts:
component = sanitize_path_component(part)
if component is None:
return None
components.append(component)
resolved = os.path.normpath(os.path.join(base, *components))
# normpath rather than realpath so symlinked media roots keep working; the
# per component checks above are what actually prevent traversal.
if not is_contained_in(resolved, base):
return None
return resolved
def sanitize_contained_path(path: str | None, base: str) -> str | None:
"""Validate a whole user supplied path that must already sit under base.
Unlike safe_join this keeps the directory structure the caller sent, so it
suits values that name an existing file rather than one component.
Args:
path: The untrusted path
base: Directory the path has to stay inside of
Returns:
The sanitized path, or None if it is unusable or escapes base.
"""
if not path:
return None
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
# like "clips\..\..\etc/passwd" would pass the containment check yet still
# escape once resolved. A valid path here never uses "..".
if ".." in path:
return None
sanitized = sanitize_filepath(path)
if not is_contained_in(sanitized, base):
return None
return sanitized
def get_trigger_thumbnail_path(camera_name: str, data: str) -> str | None:
"""Path of the thumbnail stored for a semantic search trigger.
Args:
camera_name: Camera the trigger belongs to
data: The trigger's data value, which is free-form text supplied by the
client and persisted verbatim
Returns:
The thumbnail path, or None if it cannot be built safely.
"""
return safe_join(TRIGGER_DIR, camera_name, f"{data}.webp")