Files
c01d8e3c12 feat: recursive subfolder filtering + gallery tree navigation (#140)
* feat: recursive subfolder filtering + gallery tree navigation + thumbnail sizing

- Recursive subfolder filtering: selecting an intermediate folder shows all child images
- Gallery tree navigation panel with collapsible folder hierarchy (sticky, resizable)
- Thumbnail size controls (S/M/L) for adjustable grid density
- Fix path collision when multiple gallery roots share overlapping relative paths
- Fix auto-detect mode collapsing nested folders to ambiguous basenames
- Security test suite for path traversal edge cases

Addresses feedback from #126. Fixes code review items P1/P2 from #135.

Co-Authored-By: Kai Bowie <kaigtb@proton.me>

* style: fix Black formatting in test_path_security.py

* feat(gallery): add folder tree sidebar with hierarchical navigation (#126)

- Add collapsible folder tree sidebar to gallery view
- Support hierarchical year/month/date folder navigation
- Include ancestor path expansion for proper tree structure
- Add subfolder filtering to gallery image queries
- Update admin subfolder dropdown with ancestor support
- Fix trailing whitespace in SQL queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kai <noreply@gtb.ai>
Co-authored-by: Kai Bowie <kaigtb@proton.me>
Co-authored-by: Kai <kai@tachi-02.localdomain>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:58:59 -07:00

129 lines
4.6 KiB
Python

"""Tests for path traversal and symlink security in image serving."""
import os
import tempfile
import unittest
from pathlib import Path
class TestPathSecurity(unittest.TestCase):
"""Verify that path resolution and boundary checks prevent escapes."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.output_dir = Path(self.tmpdir) / "output"
self.output_dir.mkdir()
self.secret_dir = Path(self.tmpdir) / "secret"
self.secret_dir.mkdir()
# Create a legitimate image
(self.output_dir / "legit.png").write_bytes(b"PNG")
# Create a secret file outside the output dir
(self.secret_dir / "password.txt").write_text("hunter2")
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _is_safe_path(self, output_path, filepath):
"""Reproduce the safety check from serve_output_image."""
image_path = (output_path / filepath).resolve()
return image_path.is_relative_to(output_path.resolve()) and image_path.exists()
def test_normal_path_allowed(self):
self.assertTrue(self._is_safe_path(self.output_dir, "legit.png"))
def test_dot_dot_traversal_blocked(self):
self.assertFalse(self._is_safe_path(self.output_dir, "../secret/password.txt"))
def test_encoded_dot_dot_blocked(self):
# Even if someone encodes ../ as %2e%2e%2f, Path resolution catches it
self.assertFalse(
self._is_safe_path(self.output_dir, "..%2fsecret%2fpassword.txt")
)
def test_absolute_path_blocked(self):
secret_path = str(self.secret_dir / "password.txt")
self.assertFalse(self._is_safe_path(self.output_dir, secret_path))
def test_symlink_escape_blocked(self):
# Create a symlink inside output_dir pointing outside
symlink_path = self.output_dir / "escape_link"
try:
symlink_path.symlink_to(self.secret_dir / "password.txt")
except OSError:
self.skipTest("Cannot create symlinks on this platform")
# resolve() follows the symlink, so it should resolve to
# secret_dir which is NOT relative_to output_dir
image_path = symlink_path.resolve()
self.assertFalse(image_path.is_relative_to(self.output_dir.resolve()))
def test_symlink_within_root_allowed(self):
# A symlink that stays within the output dir should be fine
subdir = self.output_dir / "subdir"
subdir.mkdir()
(subdir / "img.png").write_bytes(b"PNG")
symlink_path = self.output_dir / "link_to_sub"
try:
symlink_path.symlink_to(subdir / "img.png")
except OSError:
self.skipTest("Cannot create symlinks on this platform")
image_path = symlink_path.resolve()
self.assertTrue(image_path.is_relative_to(self.output_dir.resolve()))
def test_nested_traversal_blocked(self):
# Deep nested traversal: sub/../../secret/password.txt
sub = self.output_dir / "sub"
sub.mkdir()
self.assertFalse(
self._is_safe_path(self.output_dir, "sub/../../secret/password.txt")
)
def test_null_byte_in_path(self):
# Null bytes should not bypass checks
try:
result = self._is_safe_path(self.output_dir, "legit.png\x00.txt")
except ValueError:
# Python raises ValueError for embedded null bytes — this is safe
result = False
self.assertFalse(result)
class TestRootIndexSecurity(unittest.TestCase):
"""Verify that root_index parameter is validated safely."""
def test_negative_index_ignored(self):
"""Negative root index should not select any directory."""
output_dirs = [Path("/fake/dir1"), Path("/fake/dir2")]
idx = -1
# The code checks 0 <= idx < len(output_dirs)
self.assertFalse(0 <= idx < len(output_dirs))
def test_out_of_range_index_ignored(self):
output_dirs = [Path("/fake/dir1")]
idx = 5
self.assertFalse(0 <= idx < len(output_dirs))
def test_valid_index_selects_correct_root(self):
output_dirs = [Path("/fake/dir1"), Path("/fake/dir2"), Path("/fake/dir3")]
idx = 1
self.assertTrue(0 <= idx < len(output_dirs))
self.assertEqual(output_dirs[idx], Path("/fake/dir2"))
def test_non_numeric_index_handled(self):
"""Non-numeric root value should not crash."""
try:
int("abc")
self.fail("Should have raised ValueError")
except ValueError:
pass # This is the expected behavior
if __name__ == "__main__":
unittest.main()