Files
Vito Sansevero 30fc6aa7a7 fix(local_image_loader): refuse filesystem roots and home as saved paths
save_paths is reachable without auth, so saving "/" made every media
file on the machine servable. Reject filesystem/drive roots, the home
directory and its ancestors (realpath-resolved, so symlinks count), and
ignore such entries already in config.json.
2026-10-13 06:56:49 -07:00

163 lines
5.5 KiB
Python

"""Path-security tests for the Local Image Loader (logic helpers)."""
import os
import pytest
from kikotools.tools.local_image_loader.logic import (
is_allowed_image_path,
is_too_broad_root,
is_within_roots,
list_subdirectories,
resolve_path,
)
@pytest.fixture
def tree(tmp_path):
root = tmp_path / "root"
root.mkdir()
(root / "a.png").write_bytes(b"x")
(root / "notes.txt").write_text("x")
(root / "sub").mkdir()
(root / "sub" / "b.JPG").write_bytes(b"x")
sibling = tmp_path / "root_evil" # shares a string prefix with root
sibling.mkdir()
(sibling / "c.png").write_bytes(b"x")
return tmp_path
class TestResolvePath:
@pytest.mark.parametrize("bad", [None, "", 5, "a\0b", b"bytes"])
def test_invalid_input_returns_none(self, bad):
assert resolve_path(bad) is None
def test_resolves_dotdot_and_symlinks(self, tree):
link = tree / "link"
link.symlink_to(tree / "root")
got = resolve_path(str(link / "sub" / ".." / "a.png"))
assert got == os.path.realpath(tree / "root" / "a.png")
class TestIsWithinRoots:
def test_inside_root(self, tree):
assert is_within_roots(str(tree / "root" / "sub"), [str(tree / "root")])
def test_root_itself_allowed(self, tree):
assert is_within_roots(str(tree / "root"), [str(tree / "root")])
def test_prefix_sibling_rejected(self, tree):
assert not is_within_roots(
str(tree / "root_evil" / "c.png"), [str(tree / "root")]
)
def test_dotdot_escape_rejected(self, tree):
escape = str(tree / "root" / ".." / "root_evil" / "c.png")
assert not is_within_roots(escape, [str(tree / "root")])
def test_symlink_escape_rejected(self, tree):
(tree / "root" / "out").symlink_to(tree / "root_evil")
assert not is_within_roots(
str(tree / "root" / "out" / "c.png"), [str(tree / "root")]
)
def test_empty_or_invalid_roots_ignored(self, tree):
assert not is_within_roots(str(tree), [])
assert not is_within_roots(str(tree), [None, "", 3, "a\0b"])
def test_invalid_path(self):
assert not is_within_roots(None, ["/"])
class TestIsAllowedImagePath:
def test_image_in_root(self, tree):
roots = [str(tree / "root")]
assert is_allowed_image_path(str(tree / "root" / "a.png"), roots)
assert is_allowed_image_path(str(tree / "root" / "sub" / "b.JPG"), roots)
def test_non_image_in_root_rejected(self, tree):
assert not is_allowed_image_path(
str(tree / "root" / "notes.txt"), [str(tree / "root")]
)
def test_image_outside_root_rejected(self, tree):
assert not is_allowed_image_path(
str(tree / "root_evil" / "c.png"), [str(tree / "root")]
)
def test_root_set_to_filesystem_root_still_requires_image_ext(self):
assert not is_allowed_image_path("/etc/passwd", ["/"])
def test_symlink_with_image_name_to_secret_rejected(self, tree):
secret = tree / "root_evil" / "id_rsa"
secret.write_text("secret")
(tree / "root" / "fake.png").symlink_to(secret)
assert not is_allowed_image_path(
str(tree / "root" / "fake.png"), [str(tree / "root"), str(tree)]
)
def test_invalid_path(self):
assert not is_allowed_image_path(None, ["/"])
class TestListSubdirectories:
def test_trailing_sep_lists_children_dirs_only(self, tree):
got = list_subdirectories(str(tree / "root") + os.sep)
assert got == [os.path.join(str(tree / "root") + os.sep, "sub")]
def test_prefix_match_in_parent(self, tree):
got = list_subdirectories(str(tree / "ro"))
assert got == [str(tree / "root"), str(tree / "root_evil")]
def test_missing_dir_returns_empty(self, tree):
assert list_subdirectories(str(tree / "nope") + os.sep) == []
assert list_subdirectories(str(tree / "nope" / "x")) == []
def test_limit(self, tree):
for i in range(5):
(tree / f"d{i}").mkdir()
assert len(list_subdirectories(str(tree) + os.sep, limit=3)) == 3
def test_relative_without_parent_returns_empty(self):
assert list_subdirectories("zzz_not_here") == []
def test_top_level_prefix(self):
got = list_subdirectories("/e")
assert all(os.path.isdir(p) for p in got)
assert all(p.startswith("/e") for p in got)
class TestTooBroadRoot:
"""Saved roots that would expose everything are refused."""
@pytest.fixture
def home(self, tmp_path):
home = tmp_path / "home" / "user"
home.mkdir(parents=True)
return str(home)
def test_filesystem_root(self, home):
assert is_too_broad_root(os.path.abspath(os.sep), home=home)
def test_home_itself(self, home):
assert is_too_broad_root(home, home=home)
assert is_too_broad_root(home + os.sep, home=home)
def test_ancestor_of_home(self, home):
assert is_too_broad_root(os.path.dirname(home), home=home)
def test_folder_inside_home_ok(self, home):
assert not is_too_broad_root(os.path.join(home, "Pictures"), home=home)
def test_unrelated_folder_ok(self, home, tmp_path):
assert not is_too_broad_root(str(tmp_path / "srv" / "media"), home=home)
def test_symlink_to_home_refused(self, home, tmp_path):
link = tmp_path / "link"
link.symlink_to(home)
assert is_too_broad_root(str(link), home=home)
@pytest.mark.parametrize("bad", [None, "", 5, "a\0b"])
def test_unusable_values_refused(self, home, bad):
assert is_too_broad_root(bad, home=home)