Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2026-08-03 - [Fix Path Traversal in Zip Generation]
**Vulnerability:** Client-side Path Traversal (Zip Slip) possible on Windows due to preserving backslashes in filenames when creating ZIP archive.
**Learning:** `Path.name` on POSIX does not treat `\` as a directory separator, meaning Windows path payloads preserve their traversal sequences in the ZIP `arcname`.
**Prevention:** Explicitly sanitize filenames by standardizing `\` to `/` before using `Path().name`.
## 2026-05-28 - [Sentinel Fixes: Temp Files & Injection]
**Vulnerability:** Predictable Temp Files (CWE-377) and Insecure Default Permissions (CWE-276), plus Command Injection via FFmpeg Filtergraph (CWE-20).
**Learning:** Python's `Path.with_name` plus a suffix string to make a temp file opens a race condition because it's predictable and the permissions default to system `umask` which might expose secret `0600` data. Additionally, interpolating variables directly into FFmpeg filtergraph strings allows arbitrary filter injection.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
### Added
- ๋‹ค์ค‘ ํŒŒ์ผ ์—…๋กœ๋“œ ์„ ํƒ ์‹œ ์ฆ‰๊ฐ์ ์ธ ํŒŒ์ผ ๊ฐœ์ˆ˜ ํ”ผ๋“œ๋ฐฑ ๋ฐ ์ œํ•œ ์ดˆ๊ณผ ๊ฒฝ๊ณ  ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€
- ์ผ๊ด„ ์—…๋กœ๋“œ ํผ์— ๋Œ€์ƒ ๋ฐ”์ดํŠธ ํ”„๋ฆฌ์…‹ ๋ฒ„ํŠผ๊ณผ ์ด ํŒŒ์ผ ํฌ๊ธฐ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์‚ฌ์šฉ์„ฑ์„ ๊ฐœ์„ ํ–ˆ์Šต๋‹ˆ๋‹ค.
- **๋ณด์•ˆ ์ˆ˜์ •:** ZIP ์•„์นด์ด๋ธŒ ์ƒ์„ฑ ์‹œ ๋ฐฑ์Šฌ๋ž˜์‹œ(`\`)๊ฐ€ ํฌํ•จ๋œ ํŒŒ์ผ ์ด๋ฆ„์œผ๋กœ ์ธํ•ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” Windows ํด๋ผ์ด์–ธํŠธ ์ธก ๊ฒฝ๋กœ ํƒ์ƒ‰(Zip Slip) ์ทจ์•ฝ์ ์„ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.
6 changes: 4 additions & 2 deletions saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,8 @@ def _persist_upload(file: UploadFile) -> tuple[Path, Path, Path, Path]:
input_dir.mkdir()
output_dir.mkdir()

safe_filename = Path(file.filename).name
raw_name = getattr(file, "filename", "") or ""
safe_filename = Path(raw_name.replace("\\", "/")).name
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "upload.tmp"

Expand Down Expand Up @@ -595,7 +596,8 @@ def shrink_media_batch(
try:
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_STORED) as archive:
for index, upload in enumerate(files):
safe_filename = Path(upload.filename or "").name
raw_name = getattr(upload, "filename", "") or ""
safe_filename = Path(raw_name.replace("\\", "/")).name
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "upload.tmp"
entry = {
Expand Down
23 changes: 23 additions & 0 deletions tests/test_saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,29 @@ def test_shrink_batch_handles_archive_failure(self, _mock_zipfile):
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json(), {"error": "Upload processing failed"})

@patch("saas_web.media_shrinker.convert_file")
def test_shrink_batch_sanitizes_windows_path_traversal(self, mock_convert_file):
def fake_convert(source, root, output_dir, target_bytes):
output_path = Path(output_dir) / (Path(source).stem + ".flac")
output_path.write_bytes(b"shrunk")
result = MagicMock(spec=ConversionResult)
result.output_path = output_path
return [result]

mock_convert_file.side_effect = fake_convert

response = client.post(
"/shrink-batch",
files=[
("files", ("..\\..\\windows\\system32\\cmd.exe", b"audio", "audio/wav")),
],
data={"target_bytes": 10000},
)
self.assertEqual(response.status_code, 200)
archive = zipfile.ZipFile(io.BytesIO(response.content))
names = archive.namelist()
self.assertIn("01_cmd.flac", names)

@patch("saas_web.media_shrinker.convert_file")
def test_shrink_batch_uses_safe_fallback_filename(self, mock_convert_file):
mock_convert_file.return_value = []
Expand Down
Loading