diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 858d9d42..9c9d083b 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,8 @@ +## 2026-07-25 - [Cross-platform upload basename normalization] +**Behavior:** Upload metadata now interprets both forward slashes and backslashes as path separators before extracting a basename. +**Learning:** On POSIX systems, `pathlib.Path(filename).name` retains backslashes because they are ordinary characters there. That caused inconsistent manifest and converter filenames for Windows-style client paths. The upload itself is still written inside a trusted temporary workspace, and batch archive entry names are generated outputs; this change does not establish a filesystem traversal or archive-entry escape. +**Prevention:** Normalize client path separators before extracting a basename, retain the existing empty/`.`/`..` fallback, and test the persisted source name and manifest metadata. Treat the normalization as cross-platform consistency and defense in depth, not as evidence of a demonstrated Zip Slip exploit. + ## 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 4cca1ced..9313538b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ ### Fixed - 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다. +- 업로드 파일명의 경로 구분자를 정규화하여 POSIX에서도 Windows 형식의 클라이언트 경로가 일관된 basename으로 기록되도록 수정했습니다. diff --git a/saas_web.py b/saas_web.py index 0ef95a1e..63265e94 100644 --- a/saas_web.py +++ b/saas_web.py @@ -486,7 +486,7 @@ def _persist_upload(file: UploadFile) -> tuple[Path, Path, Path, Path]: input_dir.mkdir() output_dir.mkdir() - safe_filename = Path(file.filename).name + safe_filename = Path((file.filename or "").replace("\\", "/")).name if not safe_filename or safe_filename in (".", ".."): safe_filename = "upload.tmp" @@ -619,7 +619,7 @@ 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 + safe_filename = Path((upload.filename or "").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 cd45dbc3..f574a3cb 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -560,6 +560,32 @@ 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_uses_safe_fallback_filename_with_backslashes(self, mock_convert_file): + mock_convert_file.return_value = [] + + response = saas_web.shrink_media_batch( + BackgroundTasks(), + files=[ + SimpleNamespace( + filename="..\\..\\windows.ini", + content_type="audio/wav", + file=io.BytesIO(b"dummy"), + ) + ], + target_bytes=10000, + ) + + try: + with zipfile.ZipFile(response.path) as archive: + manifest = json.loads(archive.read("results.json")) + self.assertEqual(manifest["results"][0]["filename"], "windows.ini") + self.assertEqual( + mock_convert_file.call_args.kwargs["source"].name, "windows.ini" + ) + finally: + saas_web.cleanup_temp_dir(Path(response.path).parent) + @patch("saas_web.media_shrinker.convert_file") def test_shrink_batch_uses_safe_fallback_filename(self, mock_convert_file): mock_convert_file.return_value = [] @@ -576,13 +602,15 @@ def test_shrink_batch_uses_safe_fallback_filename(self, mock_convert_file): target_bytes=10000, ) - archive = zipfile.ZipFile(response.path) - manifest = json.loads(archive.read("results.json")) - self.assertEqual(manifest["results"][0]["filename"], "upload.tmp") - self.assertEqual( - mock_convert_file.call_args.kwargs["source"].name, "upload.tmp" - ) - saas_web.cleanup_temp_dir(Path(response.path).parent) + try: + with zipfile.ZipFile(response.path) as archive: + manifest = json.loads(archive.read("results.json")) + self.assertEqual(manifest["results"][0]["filename"], "upload.tmp") + self.assertEqual( + mock_convert_file.call_args.kwargs["source"].name, "upload.tmp" + ) + finally: + saas_web.cleanup_temp_dir(Path(response.path).parent) def test_get_ui_includes_batch_upload_form(self): response = client.get("/")