diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..112353f6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,7 @@ +## 2026-08-15 - [Sentinel: Uncontrolled Resource Consumption in Job Cleanup] +**Vulnerability:** Resource Exhaustion (CWE-400 / CWE-770) via unretrieved job results. +**Learning:** When successful jobs only clean up their temporary directories upon result download, an attacker can intentionally create jobs and abandon them to exhaust disk space or inodes over time. +**Prevention:** Implement an automatic cleanup mechanism (like a background sweep or TTL) for jobs that complete but are never retrieved. ## 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9313538b..a1d2f1eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,6 @@ ### Fixed - 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다. - 업로드 파일명의 경로 구분자를 정규화하여 POSIX에서도 Windows 형식의 클라이언트 경로가 일관된 basename으로 기록되도록 수정했습니다. + +### 보안 +- 완료된 비동기 작업의 결과가 다운로드되지 않을 때 임시 디렉터리가 무한정 쌓이는 리소스 고갈(DoS) 취약점을 방지하기 위해 24시간 후 만료된 작업을 자동 정리하도록 수정했습니다. diff --git a/saas_web.py b/saas_web.py index 63265e94..1c6a2de4 100644 --- a/saas_web.py +++ b/saas_web.py @@ -797,6 +797,20 @@ def _run_job( cleanup_temp_dir(temp_dir_path) +def _cleanup_expired_jobs() -> None: + """Remove jobs and their temporary workspaces that have been finished for over 24 hours.""" + from datetime import timedelta + store = _get_job_store() + now = _now() + for job in store.list_jobs(): + if job["status"] in ("done", "failed"): + try: + updated_at = datetime.fromisoformat(job["updated_at"]) + if now - updated_at > timedelta(hours=24): + _cleanup_job(job["id"]) + except Exception: + logger.exception("Failed to parse updated_at for job %s", job["id"]) + @app.post("/jobs") def submit_job( background_tasks: BackgroundTasks, @@ -804,6 +818,7 @@ def submit_job( target_bytes: int = Form(2_000_000_000), ): """Enqueue a shrink job and return its id for asynchronous status polling.""" + background_tasks.add_task(_cleanup_expired_jobs) error = _validate_request(file, target_bytes) if error is not None: return JSONResponse(status_code=400, content={"error": error}) diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 3b57e033..e2409337 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -1173,6 +1173,31 @@ def test_cleanup_job_removes_workspace(self): self.assertFalse(temp_dir.exists()) self.assertIsNone(saas_web.JOB_STORE.get("c")) + @patch("saas_web._cleanup_job") + @patch("saas_web._get_job_store") + def test_cleanup_expired_jobs(self, mock_get_store, mock_cleanup): + from datetime import datetime, timezone, timedelta + mock_store = MagicMock() + mock_get_store.return_value = mock_store + + now = datetime.now(timezone.utc) + past_time_1 = (now - timedelta(hours=25)).isoformat() + past_time_2 = (now - timedelta(hours=10)).isoformat() + + mock_store.list_jobs.return_value = [ + {"id": "job_1", "status": "done", "updated_at": past_time_1}, + {"id": "job_2", "status": "done", "updated_at": past_time_2}, + {"id": "job_3", "status": "processing", "updated_at": past_time_1}, + {"id": "job_4", "status": "failed", "updated_at": past_time_1}, + {"id": "job_5", "status": "failed", "updated_at": "invalid-time"}, + ] + + saas_web._cleanup_expired_jobs() + + mock_cleanup.assert_any_call("job_1") + mock_cleanup.assert_any_call("job_4") + self.assertEqual(mock_cleanup.call_count, 2) + def test_cleanup_job_tolerates_unknown_job(self): saas_web._cleanup_job("unknown-cleanup-job") self.assertIsNone(saas_web.JOB_STORE.get("unknown-cleanup-job"))