From d786d9e71b3a278d86a6f3a7dc1c7d844ad66e83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:05:03 +0900 Subject: [PATCH 1/7] feat: setup FastAPI backend foundation --- backend/main.py | 7 +++++++ backend/requirements.txt | 4 ++++ backend/tests/__init__.py | 0 backend/tests/test_main.py | 9 +++++++++ 4 files changed, 20 insertions(+) create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_main.py diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 000000000..b2851773b --- /dev/null +++ b/backend/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +app = FastAPI(title="AI Email Client API") + +@app.get("/") +def read_root(): + return {"status": "ok", "message": "AI Email Client API"} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..6394691e6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pytest +httpx \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 000000000..893bbe7ad --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,9 @@ +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "message": "AI Email Client API"} \ No newline at end of file From 287011c0755bcdf4207198784dda6b962f9fb9d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:08:23 +0900 Subject: [PATCH 2/7] fix: address code quality review feedback --- backend/main.py | 11 ++++++++++- backend/requirements.txt | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index b2851773b..85745bc2b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,16 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="AI Email Client API") +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + @app.get("/") -def read_root(): +def read_root() -> dict[str, str]: return {"status": "ok", "message": "AI Email Client API"} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 6394691e6..4d7b0ddd2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -fastapi -uvicorn -pytest -httpx \ No newline at end of file +fastapi==0.109.0 +uvicorn==0.27.0 +pytest==8.0.0 +httpx==0.26.0 \ No newline at end of file From b55ca585fad99579a8373c79fa9647ee25745671 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:09:49 +0900 Subject: [PATCH 3/7] feat: implement zip archive extractor for email backups --- backend/services/__init__.py | 0 backend/services/archive.py | 12 ++++++++++++ backend/tests/test_archive.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 backend/services/__init__.py create mode 100644 backend/services/archive.py create mode 100644 backend/tests/test_archive.py diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/services/archive.py b/backend/services/archive.py new file mode 100644 index 000000000..b3a463600 --- /dev/null +++ b/backend/services/archive.py @@ -0,0 +1,12 @@ +import zipfile +import os +from typing import List + +def extract_backup(zip_path: str, output_dir: str) -> List[str]: + os.makedirs(output_dir, exist_ok=True) + extracted_paths = [] + with zipfile.ZipFile(zip_path, 'r') as z: + z.extractall(output_dir) + for name in z.namelist(): + extracted_paths.append(os.path.join(output_dir, name)) + return extracted_paths diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py new file mode 100644 index 000000000..23bd41992 --- /dev/null +++ b/backend/tests/test_archive.py @@ -0,0 +1,18 @@ +import os +import zipfile +import tempfile +from services.archive import extract_backup + +def test_extract_backup(): + # Create a dummy zip file + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, "test.zip") + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("test.eml", b"Subject: Test Email") + + out_dir = os.path.join(tmpdir, "output") + extracted_files = extract_backup(zip_path, out_dir) + + assert len(extracted_files) == 1 + assert extracted_files[0].endswith("test.eml") + assert os.path.exists(extracted_files[0]) From f7760c3d9d886c51a6a91b6ec5825955e6626706 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:13:52 +0900 Subject: [PATCH 4/7] fix: address code quality review feedback for archive service --- backend/services/archive.py | 60 +++++++++++++++++++++++++++++----- backend/services/exceptions.py | 11 +++++++ backend/tests/test_archive.py | 59 +++++++++++++++++++++++++-------- 3 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 backend/services/exceptions.py diff --git a/backend/services/archive.py b/backend/services/archive.py index b3a463600..8f2afee51 100644 --- a/backend/services/archive.py +++ b/backend/services/archive.py @@ -1,12 +1,56 @@ import zipfile -import os -from typing import List +from pathlib import Path +from typing import List, Union -def extract_backup(zip_path: str, output_dir: str) -> List[str]: - os.makedirs(output_dir, exist_ok=True) +from .exceptions import InvalidArchiveError, ArchiveSizeExceededError + +MAX_EXTRACT_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB + +def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> List[str]: + """ + Extracts a zip archive to the specified output directory. + + WARNING: This function performs blocking I/O operations. When called from + an async web layer (e.g. FastAPI), it must be executed in a thread pool using + `fastapi.concurrency.run_in_threadpool` or `asyncio.to_thread()`. + + Args: + zip_path: The path to the zip archive to extract. + output_dir: The directory where the contents should be extracted. + + Returns: + A list of string paths to the extracted files (excluding directories). + + Raises: + InvalidArchiveError: If the zip file is not found or corrupted. + ArchiveSizeExceededError: If the uncompressed size exceeds the maximum allowed limit. + """ + zip_path = Path(zip_path) + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) extracted_paths = [] - with zipfile.ZipFile(zip_path, 'r') as z: - z.extractall(output_dir) - for name in z.namelist(): - extracted_paths.append(os.path.join(output_dir, name)) + + try: + with zipfile.ZipFile(zip_path, 'r') as z: + total_size = 0 + + # Check for zip bomb + for info in z.infolist(): + total_size += info.file_size + if total_size > MAX_EXTRACT_SIZE: + raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.") + + # Extract files + z.extractall(output_dir) + + for info in z.infolist(): + if not info.is_dir(): + # Resolve to absolute or relative path string + extracted_path = output_dir / info.filename + extracted_paths.append(str(extracted_path)) + + except (zipfile.BadZipFile, FileNotFoundError) as e: + raise InvalidArchiveError(f"Failed to extract archive: {e}") from e + return extracted_paths diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py new file mode 100644 index 000000000..3cdeee29f --- /dev/null +++ b/backend/services/exceptions.py @@ -0,0 +1,11 @@ +class ArchiveError(Exception): + """Base exception for archive operations.""" + pass + +class InvalidArchiveError(ArchiveError): + """Raised when an archive is invalid, corrupted, or not found.""" + pass + +class ArchiveSizeExceededError(ArchiveError): + """Raised when an archive exceeds the maximum allowed extracted size.""" + pass diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py index 23bd41992..fd9be2de5 100644 --- a/backend/tests/test_archive.py +++ b/backend/tests/test_archive.py @@ -1,18 +1,49 @@ import os import zipfile -import tempfile +import pytest +from pathlib import Path + from services.archive import extract_backup +from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError + +def test_extract_backup_success(tmp_path): + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("test.eml", b"Subject: Test Email") + z.writestr("folder/", b"") + z.writestr("folder/test2.eml", b"Subject: Test Email 2") + + out_dir = tmp_path / "output" + extracted_files = extract_backup(zip_path, out_dir) + + # Check only files are returned, not directories + assert len(extracted_files) == 2 + assert any(f.endswith("test.eml") for f in extracted_files) + assert any(f.endswith("test2.eml") for f in extracted_files) + assert not any(f.endswith("folder/") for f in extracted_files) + + for f in extracted_files: + assert os.path.exists(f) + assert os.path.isfile(f) + +def test_extract_backup_file_not_found(tmp_path): + with pytest.raises(InvalidArchiveError, match="Failed to extract archive"): + extract_backup(tmp_path / "missing.zip", tmp_path / "output") + +def test_extract_backup_bad_zip_file(tmp_path): + bad_zip = tmp_path / "bad.zip" + bad_zip.write_text("This is not a zip file") + + with pytest.raises(InvalidArchiveError, match="Failed to extract archive"): + extract_backup(bad_zip, tmp_path / "output") -def test_extract_backup(): - # Create a dummy zip file - with tempfile.TemporaryDirectory() as tmpdir: - zip_path = os.path.join(tmpdir, "test.zip") - with zipfile.ZipFile(zip_path, 'w') as z: - z.writestr("test.eml", b"Subject: Test Email") - - out_dir = os.path.join(tmpdir, "output") - extracted_files = extract_backup(zip_path, out_dir) - - assert len(extracted_files) == 1 - assert extracted_files[0].endswith("test.eml") - assert os.path.exists(extracted_files[0]) +def test_extract_backup_size_exceeded(tmp_path, monkeypatch): + import services.archive + monkeypatch.setattr(services.archive, "MAX_EXTRACT_SIZE", 10) # 10 bytes limit + + zip_path = tmp_path / "test.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("large.txt", b"A" * 20) # 20 bytes + + with pytest.raises(ArchiveSizeExceededError, match="Archive exceeds maximum allowed extraction size"): + extract_backup(zip_path, tmp_path / "output") From f31af47b7cda60b979aed52c95492fb53829b226 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:16:57 +0900 Subject: [PATCH 5/7] fix: resolve remaining code quality review feedback --- backend/services/archive.py | 17 ++++++++++------- backend/services/exceptions.py | 4 ++++ backend/tests/test_archive.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/backend/services/archive.py b/backend/services/archive.py index 8f2afee51..861947a2e 100644 --- a/backend/services/archive.py +++ b/backend/services/archive.py @@ -2,9 +2,10 @@ from pathlib import Path from typing import List, Union -from .exceptions import InvalidArchiveError, ArchiveSizeExceededError +from .exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError MAX_EXTRACT_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB +MAX_FILE_COUNT = 100000 def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> List[str]: """ @@ -34,20 +35,22 @@ def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> try: with zipfile.ZipFile(zip_path, 'r') as z: total_size = 0 + file_count = 0 - # Check for zip bomb + # Check for zip bomb and file count limit for info in z.infolist(): total_size += info.file_size if total_size > MAX_EXTRACT_SIZE: raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.") + + file_count += 1 + if file_count > MAX_FILE_COUNT: + raise ArchiveFileCountExceededError(f"Archive exceeds maximum allowed file count of {MAX_FILE_COUNT}.") - # Extract files - z.extractall(output_dir) - + # Extract files securely for info in z.infolist(): if not info.is_dir(): - # Resolve to absolute or relative path string - extracted_path = output_dir / info.filename + extracted_path = z.extract(info, output_dir) extracted_paths.append(str(extracted_path)) except (zipfile.BadZipFile, FileNotFoundError) as e: diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py index 3cdeee29f..9ef033232 100644 --- a/backend/services/exceptions.py +++ b/backend/services/exceptions.py @@ -9,3 +9,7 @@ class InvalidArchiveError(ArchiveError): class ArchiveSizeExceededError(ArchiveError): """Raised when an archive exceeds the maximum allowed extracted size.""" pass + +class ArchiveFileCountExceededError(ArchiveError): + """Raised when an archive exceeds the maximum allowed number of files.""" + pass diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py index fd9be2de5..c25cf46ee 100644 --- a/backend/tests/test_archive.py +++ b/backend/tests/test_archive.py @@ -4,7 +4,7 @@ from pathlib import Path from services.archive import extract_backup -from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError +from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError def test_extract_backup_success(tmp_path): zip_path = tmp_path / "test.zip" @@ -47,3 +47,34 @@ def test_extract_backup_size_exceeded(tmp_path, monkeypatch): with pytest.raises(ArchiveSizeExceededError, match="Archive exceeds maximum allowed extraction size"): extract_backup(zip_path, tmp_path / "output") + +def test_extract_backup_malformed_path(tmp_path): + zip_path = tmp_path / "malformed.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + # Create a file with a relative path trying to escape directory + z.writestr("../malformed.eml", b"Subject: Malformed Email") + + out_dir = tmp_path / "output" + extracted_files = extract_backup(zip_path, out_dir) + + assert len(extracted_files) == 1 + # zipfile.extract automatically strips the unsafe parts + assert "malformed.eml" in extracted_files[0] + assert ".." not in extracted_files[0] + for f in extracted_files: + assert os.path.exists(f) + assert os.path.isfile(f) + +def test_extract_backup_file_count_exceeded(tmp_path, monkeypatch): + import services.archive + monkeypatch.setattr(services.archive, "MAX_FILE_COUNT", 2) + + zip_path = tmp_path / "test_count.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("file1.txt", b"1") + z.writestr("file2.txt", b"2") + z.writestr("file3.txt", b"3") + + with pytest.raises(ArchiveFileCountExceededError, match="Archive exceeds maximum allowed file count"): + extract_backup(zip_path, tmp_path / "output") + From b419b7cadef1d6680878a6d971dd2d94a697752f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:20:28 +0900 Subject: [PATCH 6/7] fix: address final review feedback --- backend/services/archive.py | 53 +++++++++++++++++++++++++---------- backend/tests/test_archive.py | 34 ++++++++++++++-------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/backend/services/archive.py b/backend/services/archive.py index 861947a2e..4461a29f2 100644 --- a/backend/services/archive.py +++ b/backend/services/archive.py @@ -1,13 +1,13 @@ +import asyncio import zipfile from pathlib import Path -from typing import List, Union from .exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError MAX_EXTRACT_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB MAX_FILE_COUNT = 100000 -def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> List[str]: +def extract_backup(zip_path: str | Path, output_dir: str | Path) -> list[Path]: """ Extracts a zip archive to the specified output directory. @@ -20,14 +20,15 @@ def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> output_dir: The directory where the contents should be extracted. Returns: - A list of string paths to the extracted files (excluding directories). + A list of Path objects for the extracted files (excluding directories). Raises: InvalidArchiveError: If the zip file is not found or corrupted. ArchiveSizeExceededError: If the uncompressed size exceeds the maximum allowed limit. + ArchiveFileCountExceededError: If the archive exceeds the maximum allowed file count. """ zip_path = Path(zip_path) - output_dir = Path(output_dir) + output_dir = Path(output_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) extracted_paths = [] @@ -37,23 +38,45 @@ def extract_backup(zip_path: Union[str, Path], output_dir: Union[str, Path]) -> total_size = 0 file_count = 0 - # Check for zip bomb and file count limit for info in z.infolist(): - total_size += info.file_size - if total_size > MAX_EXTRACT_SIZE: - raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.") - + if info.is_dir(): + continue + file_count += 1 if file_count > MAX_FILE_COUNT: raise ArchiveFileCountExceededError(f"Archive exceeds maximum allowed file count of {MAX_FILE_COUNT}.") - - # Extract files securely - for info in z.infolist(): - if not info.is_dir(): - extracted_path = z.extract(info, output_dir) - extracted_paths.append(str(extracted_path)) + + # Sanitize path to prevent traversal + parts = [p for p in info.filename.replace('\\', '/').split('/') if p not in ('', '.', '..')] + if not parts: + continue + safe_name = "/".join(parts) + + target_path = (output_dir / safe_name).resolve() + + # Double-check that it is within output_dir + if not str(target_path).startswith(str(output_dir)): + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + + with z.open(info) as source, open(target_path, "wb") as target: + while chunk := source.read(8192): + total_size += len(chunk) + if total_size > MAX_EXTRACT_SIZE: + raise ArchiveSizeExceededError(f"Archive exceeds maximum allowed extraction size of {MAX_EXTRACT_SIZE} bytes.") + target.write(chunk) + + extracted_paths.append(target_path) except (zipfile.BadZipFile, FileNotFoundError) as e: raise InvalidArchiveError(f"Failed to extract archive: {e}") from e return extracted_paths + +async def extract_backup_async(zip_path: str | Path, output_dir: str | Path) -> list[Path]: + """ + Async wrapper for extract_backup to be used safely in async contexts. + """ + return await asyncio.to_thread(extract_backup, zip_path, output_dir) + diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py index c25cf46ee..f08149b06 100644 --- a/backend/tests/test_archive.py +++ b/backend/tests/test_archive.py @@ -1,9 +1,10 @@ import os import zipfile import pytest +import asyncio from pathlib import Path -from services.archive import extract_backup +from services.archive import extract_backup, extract_backup_async from services.exceptions import InvalidArchiveError, ArchiveSizeExceededError, ArchiveFileCountExceededError def test_extract_backup_success(tmp_path): @@ -18,13 +19,13 @@ def test_extract_backup_success(tmp_path): # Check only files are returned, not directories assert len(extracted_files) == 2 - assert any(f.endswith("test.eml") for f in extracted_files) - assert any(f.endswith("test2.eml") for f in extracted_files) - assert not any(f.endswith("folder/") for f in extracted_files) + assert any(f.name == "test.eml" for f in extracted_files) + assert any(f.name == "test2.eml" for f in extracted_files) + assert not any(f.name == "folder" for f in extracted_files) for f in extracted_files: - assert os.path.exists(f) - assert os.path.isfile(f) + assert f.exists() + assert f.is_file() def test_extract_backup_file_not_found(tmp_path): with pytest.raises(InvalidArchiveError, match="Failed to extract archive"): @@ -58,12 +59,11 @@ def test_extract_backup_malformed_path(tmp_path): extracted_files = extract_backup(zip_path, out_dir) assert len(extracted_files) == 1 - # zipfile.extract automatically strips the unsafe parts - assert "malformed.eml" in extracted_files[0] - assert ".." not in extracted_files[0] + assert extracted_files[0].name == "malformed.eml" + assert ".." not in str(extracted_files[0]) for f in extracted_files: - assert os.path.exists(f) - assert os.path.isfile(f) + assert f.exists() + assert f.is_file() def test_extract_backup_file_count_exceeded(tmp_path, monkeypatch): import services.archive @@ -78,3 +78,15 @@ def test_extract_backup_file_count_exceeded(tmp_path, monkeypatch): with pytest.raises(ArchiveFileCountExceededError, match="Archive exceeds maximum allowed file count"): extract_backup(zip_path, tmp_path / "output") +def test_extract_backup_async(tmp_path): + zip_path = tmp_path / "test_async.zip" + with zipfile.ZipFile(zip_path, 'w') as z: + z.writestr("test.eml", b"Subject: Test Email") + + out_dir = tmp_path / "output" + extracted_files = asyncio.run(extract_backup_async(zip_path, out_dir)) + + assert len(extracted_files) == 1 + assert extracted_files[0].name == "test.eml" + assert extracted_files[0].exists() + From 227f59ded87c8f9d98b2a05c05837d785068a36c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 27 Apr 2026 14:35:35 +0900 Subject: [PATCH 7/7] chore: ignore secret_fixtures directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 434ee8ad1..c485eb0cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees +secret_fixtures/