Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

### Fixed
- 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다.
- 업로드 파일명의 경로 구분자를 정규화하여 POSIX에서도 Windows 형식의 클라이언트 경로가 일관된 basename으로 기록되도록 수정했습니다.
4 changes: 2 additions & 2 deletions saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 = {
Expand Down
42 changes: 35 additions & 7 deletions tests/test_saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
@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 All @@ -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("/")
Expand Down
Loading