Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
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-07-28 - [Sentinel: Windows Path Traversal in Uploads]
**Vulnerability:** Path Traversal (CWE-22) via Windows path separators in filenames.
**Learning:** Python`s `Path(file.filename).name` on POSIX systems does not recognize `\` as a directory separator, allowing attackers to upload files containing backslashes (e.g., `..\..\etc\passwd`) that escape intended directories.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
**Prevention:** Explicitly sanitize filenames by replacing all backslashes with forward slashes (`filename.replace("\\", "/")`) before applying `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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@

### Fixed
- 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다.

### 보안
- 업로드된 파일 이름의 윈도우 디렉터리 구분자(`\`)를 악용한 경로 탐색 취약점을 수정했습니다.
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(str(file.filename).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(str(upload.filename or "").replace("\\", "/")).name
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "upload.tmp"
entry = {
Expand Down
34 changes: 34 additions & 0 deletions tests/test_saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,26 @@ def test_shrink_media_handles_temp_dir_failure(self, _mock_mkdtemp):

self.assertEqual(response, {"error": "Upload processing failed"})

@patch("saas_web.media_shrinker.convert_file")
def test_shrink_media_sanitizes_windows_paths(self, mock_convert_file):
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "output.flac"
output.write_bytes(b"audio")
mock_result = MagicMock(spec=ConversionResult)
mock_result.output_path = output
mock_convert_file.return_value = [mock_result]

response = client.post(
"/shrink",
files={"file": ("..\\..\\..\\etc\\passwd", b"evil-content", "audio/wav")},
data={"target_bytes": 10000},
)

self.assertEqual(response.status_code, 200)
mock_convert_file.assert_called_once()
self.assertEqual(mock_convert_file.call_args.kwargs["source"].name, "passwd")

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
@patch("saas_web.media_shrinker.convert_file")
def test_shrink_media_uses_safe_fallback_filename(self, mock_convert_file):
import tempfile
Expand Down Expand Up @@ -560,6 +580,20 @@ 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_paths(self, mock_convert_file):
mock_convert_file.side_effect = self._fake_convert

response = client.post(
"/shrink-batch",
files=[("files", ("..\\..\\..\\etc\\passwd", b"evil", "audio/wav"))],
data={"target_bytes": 10000},
)
self.assertEqual(response.status_code, 200)
names, manifest, _ = self._read_zip(response)
self.assertIn("01_passwd.flac", names)
self.assertEqual(manifest["results"][0]["filename"], "passwd")

@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