test_attach_timelapse_safe_path.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Regression tests for ArchiveService.attach_timelapse path-traversal guard.
  2. ``filename`` ultimately comes from a printer's FTP listing or a query
  3. parameter on ``POST /archives/{id}/timelapse/select``. A compromised printer
  4. that returns a malicious filename (e.g. ``"../../etc/passwd"``) used to land
  5. the write outside the archive directory. The safe-join helper now rejects
  6. such names; this test locks the behaviour in.
  7. """
  8. from __future__ import annotations
  9. from pathlib import Path
  10. from unittest.mock import AsyncMock, MagicMock
  11. import pytest
  12. from backend.app.services.archive import ArchiveService
  13. @pytest.mark.asyncio
  14. async def test_attach_timelapse_rejects_dotdot_filename(tmp_path: Path, monkeypatch):
  15. """A ``..`` traversal in filename must not land bytes outside archive_dir."""
  16. # Stage an archive directory that the service thinks is owned.
  17. archive_dir = tmp_path / "archive" / "1" / "20260101_test"
  18. archive_dir.mkdir(parents=True)
  19. # Repoint settings.base_dir so attach_timelapse's archive_dir = file_path.parent
  20. # resolves to our tmp directory.
  21. # Both the service and the shared archive_dir helper read settings, through
  22. # separate module-level bindings — patch both or the helper keeps the real
  23. # data directory and the write escapes tmp_path.
  24. fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
  25. monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
  26. monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
  27. db = MagicMock()
  28. db.commit = AsyncMock()
  29. service = ArchiveService(db)
  30. # Mock the archive lookup to return a row whose file_path resolves under tmp_path.
  31. fake_archive = MagicMock()
  32. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  33. service.get_archive = AsyncMock(return_value=fake_archive)
  34. # The attacker-controlled filename in the threat model.
  35. malicious = "../../etc/passwd_pwned"
  36. result = await service.attach_timelapse(
  37. archive_id=1,
  38. timelapse_data=b"would-be-attacker-payload",
  39. filename=malicious,
  40. )
  41. # The helper rejected the join → service returns False.
  42. assert result is False
  43. # And no payload landed at the target outside archive_dir.
  44. target_outside = tmp_path / "etc" / "passwd_pwned"
  45. assert not target_outside.exists(), "Attacker payload landed outside archive_dir"
  46. # And no payload landed under archive_dir either (since we rejected before write).
  47. assert not list(archive_dir.glob("*"))
  48. @pytest.mark.asyncio
  49. async def test_attach_timelapse_rejects_absolute_filename(tmp_path: Path, monkeypatch):
  50. """An absolute path in filename must not collapse the join."""
  51. archive_dir = tmp_path / "archive" / "1" / "20260101_test"
  52. archive_dir.mkdir(parents=True)
  53. # Both the service and the shared archive_dir helper read settings, through
  54. # separate module-level bindings — patch both or the helper keeps the real
  55. # data directory and the write escapes tmp_path.
  56. fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
  57. monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
  58. monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
  59. db = MagicMock()
  60. db.commit = AsyncMock()
  61. service = ArchiveService(db)
  62. fake_archive = MagicMock()
  63. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  64. service.get_archive = AsyncMock(return_value=fake_archive)
  65. result = await service.attach_timelapse(
  66. archive_id=1,
  67. timelapse_data=b"x",
  68. filename="/tmp/owned_via_absolute", # nosec B108
  69. )
  70. assert result is False
  71. assert not Path("/tmp/owned_via_absolute").exists() # nosec B108
  72. @pytest.mark.asyncio
  73. async def test_attach_timelapse_accepts_legit_filename(tmp_path: Path, monkeypatch):
  74. """The legitimate happy path must still work — the fix isn't over-strict."""
  75. archive_dir = tmp_path / "archive" / "1" / "20260101_test"
  76. archive_dir.mkdir(parents=True)
  77. # Both the service and the shared archive_dir helper read settings, through
  78. # separate module-level bindings — patch both or the helper keeps the real
  79. # data directory and the write escapes tmp_path.
  80. fake_settings = MagicMock(base_dir=tmp_path, archive_dir=tmp_path / "archive")
  81. monkeypatch.setattr("backend.app.services.archive.settings", fake_settings)
  82. monkeypatch.setattr("backend.app.utils.archive_paths.settings", fake_settings)
  83. db = MagicMock()
  84. db.commit = AsyncMock()
  85. service = ArchiveService(db)
  86. fake_archive = MagicMock()
  87. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  88. fake_archive.timelapse_path = None
  89. service.get_archive = AsyncMock(return_value=fake_archive)
  90. result = await service.attach_timelapse(
  91. archive_id=1,
  92. timelapse_data=b"hello-timelapse",
  93. filename="timelapse_2026-01-01_12-00-00.mp4",
  94. )
  95. assert result is True
  96. landed = archive_dir / "timelapse_2026-01-01_12-00-00.mp4"
  97. assert landed.exists()
  98. assert landed.read_bytes() == b"hello-timelapse"