mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 09:18:58 +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.
198 lines
7.0 KiB
Python
198 lines
7.0 KiB
Python
"""Tests for safe filesystem path construction."""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
|
|
from frigate.const import TRIGGER_DIR
|
|
from frigate.util.path import (
|
|
get_trigger_thumbnail_path,
|
|
is_contained_in,
|
|
safe_join,
|
|
sanitize_contained_path,
|
|
sanitize_path_component,
|
|
)
|
|
|
|
# Values that pathvalidate's sanitize_filename reduces to exactly "..", because
|
|
# it strips reserved characters but leaves relative markers intact. nginx only
|
|
# normalizes a bare ".." segment, so the decorated variants reach the app.
|
|
DOT_DOT_VARIANTS = ["..", "..:", "..*", "..?", '.."', "..<", "..>", "..|", ".. ", " .."]
|
|
|
|
|
|
class TestSanitizePathComponent(unittest.TestCase):
|
|
def test_rejects_dot_dot_variants(self):
|
|
for value in DOT_DOT_VARIANTS:
|
|
with self.subTest(value=value):
|
|
self.assertIsNone(sanitize_path_component(value))
|
|
|
|
def test_rejects_relative_markers_and_empty(self):
|
|
for value in [".", "", None, " ", "/", "//", "\\"]:
|
|
with self.subTest(value=value):
|
|
self.assertIsNone(sanitize_path_component(value))
|
|
|
|
def test_strips_separators(self):
|
|
component = sanitize_path_component("a/b/c")
|
|
self.assertIsNotNone(component)
|
|
self.assertNotIn("/", component)
|
|
|
|
def test_allows_ordinary_names(self):
|
|
for value in ["model1", "front-door", "My Model", "café", "a.b_c-1"]:
|
|
with self.subTest(value=value):
|
|
self.assertEqual(sanitize_path_component(value), value)
|
|
|
|
|
|
class TestSafeJoin(unittest.TestCase):
|
|
base = "/media/frigate/clips"
|
|
|
|
def test_rejects_dot_dot_variants(self):
|
|
for value in DOT_DOT_VARIANTS:
|
|
with self.subTest(value=value):
|
|
self.assertIsNone(safe_join(self.base, value))
|
|
|
|
def test_rejects_dot_dot_in_any_segment(self):
|
|
self.assertIsNone(safe_join(self.base, "model", "dataset", ".."))
|
|
self.assertIsNone(safe_join(self.base, "..", "dataset", ".."))
|
|
|
|
def test_result_stays_inside_base(self):
|
|
for value in ["model1", "a/../..", "....//", "..\\..", "%2e%2e"]:
|
|
with self.subTest(value=value):
|
|
joined = safe_join(self.base, value)
|
|
|
|
if joined is not None:
|
|
self.assertTrue(is_contained_in(joined, self.base))
|
|
|
|
def test_joins_multiple_segments(self):
|
|
self.assertEqual(
|
|
safe_join(self.base, "model1", "dataset", "none"),
|
|
"/media/frigate/clips/model1/dataset/none",
|
|
)
|
|
|
|
def test_rejects_empty_segment(self):
|
|
self.assertIsNone(safe_join(self.base, "model1", "", "none"))
|
|
|
|
|
|
class TestIsContainedIn(unittest.TestCase):
|
|
def test_rejects_sibling_sharing_a_name_prefix(self):
|
|
self.assertFalse(
|
|
is_contained_in("/media/frigate/clips_evil/x.webp", "/media/frigate/clips")
|
|
)
|
|
|
|
def test_accepts_base_itself_and_children(self):
|
|
self.assertTrue(is_contained_in("/media/frigate/clips", "/media/frigate/clips"))
|
|
self.assertTrue(
|
|
is_contained_in("/media/frigate/clips/a/b.webp", "/media/frigate/clips")
|
|
)
|
|
|
|
def test_rejects_parent(self):
|
|
self.assertFalse(is_contained_in("/media/frigate", "/media/frigate/clips"))
|
|
|
|
def test_handles_a_root_base(self):
|
|
# A prefix test would compare against "//" here and wrongly report that
|
|
# the root directory contains nothing.
|
|
self.assertTrue(is_contained_in("/child", "/"))
|
|
self.assertEqual(safe_join("/", "child"), "/child")
|
|
|
|
def test_rejects_uncomparable_paths(self):
|
|
self.assertFalse(is_contained_in("relative/x", "/media/frigate/clips"))
|
|
|
|
|
|
class TestSanitizeContainedPath(unittest.TestCase):
|
|
base = "/media/frigate/clips"
|
|
|
|
def test_rejects_dot_dot_anywhere(self):
|
|
for value in [
|
|
"/media/frigate/clips/../../etc/passwd",
|
|
"clips\\..\\..\\etc/passwd",
|
|
"/media/frigate/clips/a/../../../x",
|
|
]:
|
|
with self.subTest(value=value):
|
|
self.assertIsNone(sanitize_contained_path(value, self.base))
|
|
|
|
def test_rejects_sibling_sharing_a_name_prefix(self):
|
|
self.assertIsNone(
|
|
sanitize_contained_path("/media/frigate/clips_evil/x.webp", self.base)
|
|
)
|
|
|
|
def test_rejects_outside_base(self):
|
|
self.assertIsNone(sanitize_contained_path("/etc/passwd", self.base))
|
|
|
|
def test_rejects_empty(self):
|
|
self.assertIsNone(sanitize_contained_path("", self.base))
|
|
self.assertIsNone(sanitize_contained_path(None, self.base))
|
|
|
|
def test_keeps_a_valid_nested_path(self):
|
|
self.assertEqual(
|
|
sanitize_contained_path("/media/frigate/clips/a/b.webp", self.base),
|
|
"/media/frigate/clips/a/b.webp",
|
|
)
|
|
|
|
|
|
class TestTriggerThumbnailPath(unittest.TestCase):
|
|
def test_stays_inside_the_trigger_dir(self):
|
|
for camera, data in [
|
|
("cam", "../../../../etc/passwd"),
|
|
("cam", "../../../../config/config.yml"),
|
|
("cam", "normal-event-id"),
|
|
]:
|
|
with self.subTest(camera=camera, data=data):
|
|
path = get_trigger_thumbnail_path(camera, data)
|
|
|
|
self.assertIsNotNone(path)
|
|
self.assertTrue(is_contained_in(path, TRIGGER_DIR))
|
|
|
|
def test_rejects_traversal_camera_names(self):
|
|
for camera in DOT_DOT_VARIANTS:
|
|
with self.subTest(camera=camera):
|
|
self.assertIsNone(get_trigger_thumbnail_path(camera, "data"))
|
|
|
|
def test_builds_the_expected_path(self):
|
|
self.assertEqual(
|
|
get_trigger_thumbnail_path("front_door", "abc"),
|
|
os.path.join(TRIGGER_DIR, "front_door", "abc.webp"),
|
|
)
|
|
|
|
|
|
class TestRmtreeContainment(unittest.TestCase):
|
|
"""A recursive delete built through safe_join must not reach a parent.
|
|
|
|
shutil.rmtree on a path ending in ".." deletes the parent's contents before
|
|
failing on the final rmdir, so the guard has to run before the call.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.root = tempfile.mkdtemp()
|
|
self.clips = os.path.join(self.root, "clips")
|
|
os.makedirs(os.path.join(self.clips, "model1"))
|
|
os.makedirs(os.path.join(self.root, "recordings"))
|
|
|
|
with open(os.path.join(self.root, "recordings", "seg.mp4"), "w") as f:
|
|
f.write("recording")
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.root, ignore_errors=True)
|
|
|
|
def test_traversal_name_never_yields_a_path_to_delete(self):
|
|
for value in DOT_DOT_VARIANTS:
|
|
with self.subTest(value=value):
|
|
self.assertIsNone(safe_join(self.clips, value))
|
|
|
|
self.assertTrue(
|
|
os.path.exists(os.path.join(self.root, "recordings", "seg.mp4"))
|
|
)
|
|
|
|
def test_ordinary_name_still_deletes_its_own_directory(self):
|
|
target = safe_join(self.clips, "model1")
|
|
self.assertIsNotNone(target)
|
|
|
|
shutil.rmtree(target)
|
|
|
|
self.assertFalse(os.path.exists(os.path.join(self.clips, "model1")))
|
|
self.assertTrue(
|
|
os.path.exists(os.path.join(self.root, "recordings", "seg.mp4"))
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|