mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 03:09:00 +03:00
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.
135 lines
4.2 KiB
Python
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")
|