diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 858d9d42..7c13e7b9 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfe94a6..80ac63f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,4 @@ ### Added - 다중 파일 업로드 선택 시 즉각적인 파일 개수 피드백 및 제한 초과 경고 메시지 추가 - 일괄 업로드 폼에 대상 바이트 프리셋 버튼과 총 파일 크기 미리보기를 추가하여 사용성을 개선했습니다. +- **보안 수정:** ZIP 아카이브 생성 시 백슬래시(`\`)가 포함된 파일 이름으로 인해 발생할 수 있는 Windows 클라이언트 측 경로 탐색(Zip Slip) 취약점을 수정했습니다. diff --git a/saas_web.py b/saas_web.py index 3a7b0352..85f3dd2b 100644 --- a/saas_web.py +++ b/saas_web.py @@ -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" @@ -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 = { diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 57b879d1..414d85ee 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -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 = []