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

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:
Josh Hawkins
2026-08-13 21:59:46 -05:00
committed by GitHub
parent 812e5308a3
commit 11f8786459
8 changed files with 604 additions and 117 deletions
+134
View File
@@ -0,0 +1,134 @@
"""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")