test_attach_timelapse_safe_path.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  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. monkeypatch.setattr(
  22. "backend.app.services.archive.settings",
  23. MagicMock(base_dir=tmp_path),
  24. )
  25. db = MagicMock()
  26. db.commit = AsyncMock()
  27. service = ArchiveService(db)
  28. # Mock the archive lookup to return a row whose file_path resolves under tmp_path.
  29. fake_archive = MagicMock()
  30. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  31. service.get_archive = AsyncMock(return_value=fake_archive)
  32. # The attacker-controlled filename in the threat model.
  33. malicious = "../../etc/passwd_pwned"
  34. result = await service.attach_timelapse(
  35. archive_id=1,
  36. timelapse_data=b"would-be-attacker-payload",
  37. filename=malicious,
  38. )
  39. # The helper rejected the join → service returns False.
  40. assert result is False
  41. # And no payload landed at the target outside archive_dir.
  42. target_outside = tmp_path / "etc" / "passwd_pwned"
  43. assert not target_outside.exists(), "Attacker payload landed outside archive_dir"
  44. # And no payload landed under archive_dir either (since we rejected before write).
  45. assert not list(archive_dir.glob("*"))
  46. @pytest.mark.asyncio
  47. async def test_attach_timelapse_rejects_absolute_filename(tmp_path: Path, monkeypatch):
  48. """An absolute path in filename must not collapse the join."""
  49. archive_dir = tmp_path / "archive" / "1" / "20260101_test"
  50. archive_dir.mkdir(parents=True)
  51. monkeypatch.setattr(
  52. "backend.app.services.archive.settings",
  53. MagicMock(base_dir=tmp_path),
  54. )
  55. db = MagicMock()
  56. db.commit = AsyncMock()
  57. service = ArchiveService(db)
  58. fake_archive = MagicMock()
  59. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  60. service.get_archive = AsyncMock(return_value=fake_archive)
  61. result = await service.attach_timelapse(
  62. archive_id=1,
  63. timelapse_data=b"x",
  64. filename="/tmp/owned_via_absolute",
  65. )
  66. assert result is False
  67. assert not Path("/tmp/owned_via_absolute").exists()
  68. @pytest.mark.asyncio
  69. async def test_attach_timelapse_accepts_legit_filename(tmp_path: Path, monkeypatch):
  70. """The legitimate happy path must still work — the fix isn't over-strict."""
  71. archive_dir = tmp_path / "archive" / "1" / "20260101_test"
  72. archive_dir.mkdir(parents=True)
  73. monkeypatch.setattr(
  74. "backend.app.services.archive.settings",
  75. MagicMock(base_dir=tmp_path),
  76. )
  77. db = MagicMock()
  78. db.commit = AsyncMock()
  79. service = ArchiveService(db)
  80. fake_archive = MagicMock()
  81. fake_archive.file_path = "archive/1/20260101_test/file.3mf"
  82. fake_archive.timelapse_path = None
  83. service.get_archive = AsyncMock(return_value=fake_archive)
  84. result = await service.attach_timelapse(
  85. archive_id=1,
  86. timelapse_data=b"hello-timelapse",
  87. filename="timelapse_2026-01-01_12-00-00.mp4",
  88. )
  89. assert result is True
  90. landed = archive_dir / "timelapse_2026-01-01_12-00-00.mp4"
  91. assert landed.exists()
  92. assert landed.read_bytes() == b"hello-timelapse"