test_library_file_path_guard.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """Tests for to_absolute_path() path-traversal guard in library routes.
  2. Covers three behaviours added/changed by the GCode viewer PR:
  3. 1. Relative paths that escape base_dir are rejected with ValueError.
  4. 2. Path.is_relative_to() is used instead of startswith(str(base)),
  5. avoiding the /data/app vs /data/app_evil prefix-confusion bug.
  6. 3. Legacy absolute paths (pre-migration DB rows) are returned verbatim
  7. instead of raising ValueError.
  8. """
  9. from pathlib import Path
  10. from unittest.mock import patch
  11. import pytest
  12. # ---------------------------------------------------------------------------
  13. # Helpers
  14. # ---------------------------------------------------------------------------
  15. def _call(relative_path, base_dir):
  16. """Call to_absolute_path with base_dir patched to *base_dir*."""
  17. from backend.app.api.routes.library import to_absolute_path
  18. with patch("backend.app.api.routes.library.app_settings") as mock_settings:
  19. mock_settings.base_dir = str(base_dir)
  20. return to_absolute_path(relative_path)
  21. # ---------------------------------------------------------------------------
  22. # None / empty guard
  23. # ---------------------------------------------------------------------------
  24. class TestNullInputs:
  25. def test_none_returns_none(self, tmp_path):
  26. assert _call(None, tmp_path) is None
  27. def test_empty_string_returns_none(self, tmp_path):
  28. assert _call("", tmp_path) is None
  29. # ---------------------------------------------------------------------------
  30. # Relative path traversal guard
  31. # ---------------------------------------------------------------------------
  32. class TestRelativePathTraversal:
  33. def test_normal_relative_path_resolves(self, tmp_path):
  34. """A safe relative path resolves to base_dir / rel."""
  35. base = tmp_path / "data"
  36. base.mkdir()
  37. result = _call("files/model.gcode", base)
  38. assert result == (base / "files" / "model.gcode").resolve()
  39. def test_traversal_via_dotdot_raises(self, tmp_path):
  40. """../etc/passwd must be rejected."""
  41. base = tmp_path / "data"
  42. base.mkdir()
  43. with pytest.raises(ValueError, match="escapes base directory"):
  44. _call("../etc/passwd", base)
  45. def test_traversal_via_nested_dotdot_raises(self, tmp_path):
  46. """files/../../etc/passwd must be rejected."""
  47. base = tmp_path / "data"
  48. base.mkdir()
  49. with pytest.raises(ValueError, match="escapes base directory"):
  50. _call("files/../../etc/passwd", base)
  51. def test_prefix_confusion_is_blocked(self, tmp_path):
  52. """Ensure /data/app_evil/secret is not permitted when base is /data/app.
  53. A naive startswith(str(base)) check would allow this because
  54. '/data/app_evil'.startswith('/data/app') is True.
  55. Path.is_relative_to() must be used instead.
  56. """
  57. # Simulate: base = /tmp/.../data_app, sibling = /tmp/.../data_app_evil
  58. base = tmp_path / "data_app"
  59. sibling = tmp_path / "data_app_evil"
  60. base.mkdir()
  61. sibling.mkdir()
  62. # Construct a relative path that resolves into the *sibling* dir.
  63. # from base: ../data_app_evil/secret
  64. with pytest.raises(ValueError, match="escapes base directory"):
  65. _call("../data_app_evil/secret", base)
  66. # ---------------------------------------------------------------------------
  67. # Legacy absolute path pass-through
  68. # ---------------------------------------------------------------------------
  69. class TestLegacyAbsolutePaths:
  70. def test_absolute_path_inside_base_is_returned(self, tmp_path):
  71. """An absolute path that happens to be inside base_dir is returned as-is."""
  72. base = tmp_path / "data"
  73. base.mkdir()
  74. abs_path = str(base / "archive" / "old.3mf")
  75. result = _call(abs_path, base)
  76. assert result == Path(abs_path).resolve()
  77. def test_absolute_path_outside_base_is_returned(self, tmp_path):
  78. """An absolute path outside base_dir is returned verbatim (legacy compat).
  79. Pre-migration DB rows may store absolute paths that predate the
  80. base_dir layout. These must NOT raise ValueError; callers are
  81. responsible for further existence checks.
  82. """
  83. base = tmp_path / "data"
  84. base.mkdir()
  85. outside = tmp_path / "old_archive" / "legacy.3mf"
  86. result = _call(str(outside), base)
  87. assert result == outside.resolve()