test_safe_path.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. """Tests for ``backend.app.utils.safe_path.safe_join_under``.
  2. Cover every escape vector documented in the helper plus the legitimate
  3. nested-path use case so the helper's behaviour is locked in.
  4. """
  5. from __future__ import annotations
  6. from pathlib import Path
  7. import pytest
  8. from fastapi import HTTPException
  9. from backend.app.utils.safe_path import (
  10. PathTraversalError,
  11. assert_under,
  12. safe_join_under,
  13. )
  14. @pytest.fixture()
  15. def library(tmp_path: Path) -> Path:
  16. """A real on-disk directory that mimics the "trusted parent" role."""
  17. lib = tmp_path / "library"
  18. lib.mkdir()
  19. return lib
  20. class TestSafeJoinUnder:
  21. def test_simple_filename_is_joined(self, library: Path):
  22. result = safe_join_under(library, "model.3mf")
  23. assert result == (library / "model.3mf").resolve()
  24. def test_nested_path_components_are_joined(self, library: Path):
  25. result = safe_join_under(library, "myfolder", "sub", "file.3mf")
  26. assert result == (library / "myfolder" / "sub" / "file.3mf").resolve()
  27. def test_absolute_path_rejected(self, library: Path):
  28. # The exact shape that produced the original CVE — ``Path("/lib") / "/etc/passwd"``
  29. # collapses to ``Path("/etc/passwd")`` in Python's pathlib.
  30. with pytest.raises(HTTPException) as exc:
  31. safe_join_under(library, "/etc/passwd")
  32. assert exc.value.status_code == 400
  33. def test_absolute_windows_path_rejected(self, library: Path):
  34. with pytest.raises(HTTPException):
  35. safe_join_under(library, "\\\\evil\\share\\x")
  36. def test_parent_traversal_rejected(self, library: Path):
  37. with pytest.raises(HTTPException):
  38. safe_join_under(library, "..", "etc", "passwd")
  39. def test_embedded_parent_traversal_rejected(self, library: Path):
  40. # ``library/foo/../../etc/passwd`` resolves outside ``library``.
  41. with pytest.raises(HTTPException):
  42. safe_join_under(library, "foo", "..", "..", "etc", "passwd")
  43. def test_null_byte_rejected(self, library: Path):
  44. with pytest.raises(HTTPException):
  45. safe_join_under(library, "evil\x00.3mf")
  46. def test_empty_string_part_rejected(self, library: Path):
  47. with pytest.raises(HTTPException):
  48. safe_join_under(library, "")
  49. def test_no_parts_rejected(self, library: Path):
  50. with pytest.raises(HTTPException):
  51. safe_join_under(library)
  52. def test_non_string_part_rejected(self, library: Path):
  53. with pytest.raises(HTTPException):
  54. safe_join_under(library, 42) # type: ignore[arg-type]
  55. def test_http_false_raises_path_traversal_error(self, library: Path):
  56. with pytest.raises(PathTraversalError):
  57. safe_join_under(library, "/etc/passwd", http=False)
  58. def test_http_false_allows_clean_join(self, library: Path):
  59. result = safe_join_under(library, "ok.txt", http=False)
  60. assert result == (library / "ok.txt").resolve()
  61. def test_returned_path_is_resolved(self, library: Path):
  62. # The helper returns a resolved path so callers don't need to do it
  63. # themselves — every downstream is_relative_to/parent check assumes
  64. # a canonical form.
  65. result = safe_join_under(library, "x.txt")
  66. assert result == result.resolve()
  67. class TestAssertUnder:
  68. def test_inside_passes(self, library: Path):
  69. candidate = library / "x" / "y" / "z.txt"
  70. out = assert_under(library, candidate)
  71. assert out == candidate.resolve()
  72. def test_outside_rejects(self, library: Path, tmp_path: Path):
  73. outside = tmp_path / "elsewhere" / "evil.txt"
  74. with pytest.raises(HTTPException):
  75. assert_under(library, outside)
  76. def test_outside_raises_path_traversal_error_with_http_false(self, library: Path, tmp_path: Path):
  77. outside = tmp_path / "elsewhere" / "evil.txt"
  78. with pytest.raises(PathTraversalError):
  79. assert_under(library, outside, http=False)
  80. class TestPocReproducer:
  81. """The exact attacker payload from the advisory.
  82. A directly attacker-controlled folder name pointing at a venv's
  83. site-packages directory used to land a ``.pth`` file on disk. With the
  84. helper in place the join now raises before any write.
  85. """
  86. def test_advisory_poc_target_dir_rejected(self, library: Path):
  87. # Verbatim shape from the advisory POC.
  88. target_dir = "BAMBUDDY_BASE_DIR/bambuddy/venv/lib/python3.14/site-packages"
  89. # Leading slash → absolute → rejected up-front.
  90. with pytest.raises(HTTPException):
  91. safe_join_under(library, "/" + target_dir)
  92. # No leading slash but with ``..`` traversal embedded in the
  93. # follow-up file path — also rejected.
  94. with pytest.raises(HTTPException):
  95. safe_join_under(library, "innocent", "..", "..", "evil.pth")