| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115 |
- """Tests for to_absolute_path() path-traversal guard in library routes.
- Covers three behaviours added/changed by the GCode viewer PR:
- 1. Relative paths that escape base_dir are rejected with ValueError.
- 2. Path.is_relative_to() is used instead of startswith(str(base)),
- avoiding the /data/app vs /data/app_evil prefix-confusion bug.
- 3. Legacy absolute paths (pre-migration DB rows) are returned verbatim
- instead of raising ValueError.
- """
- from pathlib import Path
- from unittest.mock import patch
- import pytest
- # ---------------------------------------------------------------------------
- # Helpers
- # ---------------------------------------------------------------------------
- def _call(relative_path, base_dir):
- """Call to_absolute_path with base_dir patched to *base_dir*."""
- from backend.app.api.routes.library import to_absolute_path
- with patch("backend.app.api.routes.library.app_settings") as mock_settings:
- mock_settings.base_dir = str(base_dir)
- return to_absolute_path(relative_path)
- # ---------------------------------------------------------------------------
- # None / empty guard
- # ---------------------------------------------------------------------------
- class TestNullInputs:
- def test_none_returns_none(self, tmp_path):
- assert _call(None, tmp_path) is None
- def test_empty_string_returns_none(self, tmp_path):
- assert _call("", tmp_path) is None
- # ---------------------------------------------------------------------------
- # Relative path traversal guard
- # ---------------------------------------------------------------------------
- class TestRelativePathTraversal:
- def test_normal_relative_path_resolves(self, tmp_path):
- """A safe relative path resolves to base_dir / rel."""
- base = tmp_path / "data"
- base.mkdir()
- result = _call("files/model.gcode", base)
- assert result == (base / "files" / "model.gcode").resolve()
- def test_traversal_via_dotdot_raises(self, tmp_path):
- """../etc/passwd must be rejected."""
- base = tmp_path / "data"
- base.mkdir()
- with pytest.raises(ValueError, match="escapes base directory"):
- _call("../etc/passwd", base)
- def test_traversal_via_nested_dotdot_raises(self, tmp_path):
- """files/../../etc/passwd must be rejected."""
- base = tmp_path / "data"
- base.mkdir()
- with pytest.raises(ValueError, match="escapes base directory"):
- _call("files/../../etc/passwd", base)
- def test_prefix_confusion_is_blocked(self, tmp_path):
- """Ensure /data/app_evil/secret is not permitted when base is /data/app.
- A naive startswith(str(base)) check would allow this because
- '/data/app_evil'.startswith('/data/app') is True.
- Path.is_relative_to() must be used instead.
- """
- # Simulate: base = /tmp/.../data_app, sibling = /tmp/.../data_app_evil
- base = tmp_path / "data_app"
- sibling = tmp_path / "data_app_evil"
- base.mkdir()
- sibling.mkdir()
- # Construct a relative path that resolves into the *sibling* dir.
- # from base: ../data_app_evil/secret
- with pytest.raises(ValueError, match="escapes base directory"):
- _call("../data_app_evil/secret", base)
- # ---------------------------------------------------------------------------
- # Legacy absolute path pass-through
- # ---------------------------------------------------------------------------
- class TestLegacyAbsolutePaths:
- def test_absolute_path_inside_base_is_returned(self, tmp_path):
- """An absolute path that happens to be inside base_dir is returned as-is."""
- base = tmp_path / "data"
- base.mkdir()
- abs_path = str(base / "archive" / "old.3mf")
- result = _call(abs_path, base)
- assert result == Path(abs_path).resolve()
- def test_absolute_path_outside_base_is_returned(self, tmp_path):
- """An absolute path outside base_dir is returned verbatim (legacy compat).
- Pre-migration DB rows may store absolute paths that predate the
- base_dir layout. These must NOT raise ValueError; callers are
- responsible for further existence checks.
- """
- base = tmp_path / "data"
- base.mkdir()
- outside = tmp_path / "old_archive" / "legacy.3mf"
- result = _call(str(outside), base)
- assert result == outside.resolve()
|