mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-04 05:46:51 +03:00
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)
|