diff --git a/CHANGELOG.md b/CHANGELOG.md index fe263bf..e763d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.4] - 2026-04-09 + +### Security +- Fix Remote Code Execution (RCE) via Jinja2 Server-Side Template Injection in transformations (CVSS 9.2 Critical) +- Fix arbitrary file write via path traversal in file upload (CVSS 7.0 High) +- Fix arbitrary file read via Local File Inclusion in source creation (CVSS 8.2 High) + +### Dependencies +- Bump ai-prompter to >=0.4.0 (uses Jinja2 SandboxedEnvironment to prevent SSTI) + ## [1.8.3] - 2026-04-07 ### Security diff --git a/api/routers/sources.py b/api/routers/sources.py index 38e75a3..e6d6eff 100644 --- a/api/routers/sources.py +++ b/api/routers/sources.py @@ -43,21 +43,30 @@ def generate_unique_filename(original_filename: str, upload_folder: str) -> str: file_path = Path(upload_folder) file_path.mkdir(parents=True, exist_ok=True) + # Strip directory components to prevent path traversal + safe_filename = os.path.basename(original_filename) + if not safe_filename: + raise ValueError("Invalid filename") + # Split filename and extension - stem = Path(original_filename).stem - suffix = Path(original_filename).suffix + stem = Path(safe_filename).stem + suffix = Path(safe_filename).suffix # Check if file exists and generate unique name counter = 0 while True: if counter == 0: - new_filename = original_filename + new_filename = safe_filename else: new_filename = f"{stem} ({counter}){suffix}" full_path = file_path / new_filename - if not full_path.exists(): - return str(full_path) + # Verify resolved path stays within upload folder + resolved = full_path.resolve() + if not str(resolved).startswith(str(file_path.resolve())): + raise ValueError("Invalid filename: path traversal detected") + if not resolved.exists(): + return str(resolved) counter += 1 @@ -325,6 +334,14 @@ async def create_source( status_code=400, detail="File upload or file_path is required for upload type", ) + # Validate file_path is within the uploads directory to prevent LFI + uploads_resolved = Path(UPLOADS_FOLDER).resolve() + file_resolved = Path(final_file_path).resolve() + if not str(file_resolved).startswith(str(uploads_resolved)): + raise HTTPException( + status_code=400, + detail="Invalid file path: must be within the uploads directory", + ) content_state["file_path"] = final_file_path content_state["delete_source"] = source_data.delete_source elif source_data.type == "text": diff --git a/pyproject.toml b/pyproject.toml index f526a90..8814dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.8.3" +version = "1.8.4" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"} @@ -33,7 +33,7 @@ dependencies = [ "python-dotenv>=1.0.1", "httpx[socks]>=0.27.0", "content-core>=1.14.1,<2", - "ai-prompter>=0.3,<1", + "ai-prompter>=0.4,<1", "esperanto>=2.20.0,<3", "surrealdb>=1.0.4", "podcast-creator>=0.12.0,<1", diff --git a/tests/test_sources_api.py b/tests/test_sources_api.py index 06db1c4..f33cf6b 100644 --- a/tests/test_sources_api.py +++ b/tests/test_sources_api.py @@ -1,10 +1,12 @@ """Tests for the sources API endpoint.""" +import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient +from open_notebook.config import UPLOADS_FOLDER from open_notebook.domain.notebook import Source @@ -71,7 +73,7 @@ class TestAsyncSourceAssetPersistence: ): """POST /sources with type=upload and async_processing=true persists Asset(file_path=...).""" mock_nb_get.return_value = MagicMock() - mock_upload.return_value = "/tmp/uploads/video.mp4" + mock_upload.return_value = os.path.join(os.path.abspath(UPLOADS_FOLDER), "video.mp4") mock_submit.return_value = "command:123" saved_sources = [] @@ -97,7 +99,7 @@ class TestAsyncSourceAssetPersistence: source = saved_sources[0] assert source.asset is not None - assert source.asset.file_path == "/tmp/uploads/video.mp4" + assert source.asset.file_path == os.path.join(os.path.abspath(UPLOADS_FOLDER), "video.mp4") assert source.asset.url is None @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 10608e2..a2235db 100644 --- a/uv.lock +++ b/uv.lock @@ -9,16 +9,16 @@ resolution-markers = [ [[package]] name = "ai-prompter" -version = "0.3.1" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "pip" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/88/cadc58aac8bbb599d8c6d25f2cd357e938ddabe3756b1eed68e8860eed22/ai_prompter-0.3.1.tar.gz", hash = "sha256:cec7fddac5edf7d836c13fe77613e16b12b23aea625133846d11ae22455ed397", size = 84837, upload-time = "2025-06-20T19:31:36.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/a6/0703f5de846d4ccdc6cdfc7e44473f2722125e95316a50d44303b80edbe6/ai_prompter-0.4.0.tar.gz", hash = "sha256:63a8f2c19f13c0313ee7cef58f07cc00e948546c83c335c42737e076ae764385", size = 86469, upload-time = "2026-04-09T14:54:32.243Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/f6/490bf534d6b04755998142ec7412b78e7466dc6d9a3f31e90541506b7971/ai_prompter-0.3.1-py3-none-any.whl", hash = "sha256:ec26566f7f246f511325e1c0f260970963a6e49fb19ed0767152d30b82cd2b79", size = 13386, upload-time = "2025-06-20T19:31:34.952Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/881c31c26a7a131ed9e1a2c1c3838b5bb95e3822ac88c68fcda7738c5395/ai_prompter-0.4.0-py3-none-any.whl", hash = "sha256:230662484a27de34e8677ebe20a8220c78f8b605cb68005253c5dbf3e5cc254e", size = 13777, upload-time = "2026-04-09T14:54:33.017Z" }, ] [[package]] @@ -2126,7 +2126,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ai-prompter", specifier = ">=0.3,<1" }, + { name = "ai-prompter", specifier = ">=0.4,<1" }, { name = "babel", specifier = ">=2.18.0" }, { name = "content-core", specifier = ">=1.14.1,<2" }, { name = "esperanto", specifier = ">=2.20.0,<3" },