fix: prevent RCE via SSTI, path traversal file write, and LFI file read

- Bump ai-prompter to >=0.4.0 which uses Jinja2 SandboxedEnvironment,
  preventing arbitrary code execution via user-provided transformation prompts
- Sanitize uploaded filenames with os.path.basename() and validate resolved
  path stays within upload directory to prevent path traversal
- Validate file_path in source creation is within UPLOADS_FOLDER to prevent
  arbitrary file read via Local File Inclusion
This commit is contained in:
Luis Novo 2026-04-09 11:58:16 -03:00
parent 89eac04c63
commit 70a466a640
5 changed files with 42 additions and 13 deletions

View file

@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [1.8.3] - 2026-04-07
### Security ### Security

View file

@ -43,21 +43,30 @@ def generate_unique_filename(original_filename: str, upload_folder: str) -> str:
file_path = Path(upload_folder) file_path = Path(upload_folder)
file_path.mkdir(parents=True, exist_ok=True) 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 # Split filename and extension
stem = Path(original_filename).stem stem = Path(safe_filename).stem
suffix = Path(original_filename).suffix suffix = Path(safe_filename).suffix
# Check if file exists and generate unique name # Check if file exists and generate unique name
counter = 0 counter = 0
while True: while True:
if counter == 0: if counter == 0:
new_filename = original_filename new_filename = safe_filename
else: else:
new_filename = f"{stem} ({counter}){suffix}" new_filename = f"{stem} ({counter}){suffix}"
full_path = file_path / new_filename full_path = file_path / new_filename
if not full_path.exists(): # Verify resolved path stays within upload folder
return str(full_path) 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 counter += 1
@ -325,6 +334,14 @@ async def create_source(
status_code=400, status_code=400,
detail="File upload or file_path is required for upload type", 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["file_path"] = final_file_path
content_state["delete_source"] = source_data.delete_source content_state["delete_source"] = source_data.delete_source
elif source_data.type == "text": elif source_data.type == "text":

View file

@ -1,6 +1,6 @@
[project] [project]
name = "open-notebook" 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" description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [ authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"} {name = "Luis Novo", email = "lfnovo@gmail.com"}
@ -33,7 +33,7 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"httpx[socks]>=0.27.0", "httpx[socks]>=0.27.0",
"content-core>=1.14.1,<2", "content-core>=1.14.1,<2",
"ai-prompter>=0.3,<1", "ai-prompter>=0.4,<1",
"esperanto>=2.20.0,<3", "esperanto>=2.20.0,<3",
"surrealdb>=1.0.4", "surrealdb>=1.0.4",
"podcast-creator>=0.12.0,<1", "podcast-creator>=0.12.0,<1",

View file

@ -1,10 +1,12 @@
"""Tests for the sources API endpoint.""" """Tests for the sources API endpoint."""
import os
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from open_notebook.config import UPLOADS_FOLDER
from open_notebook.domain.notebook import Source 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=...).""" """POST /sources with type=upload and async_processing=true persists Asset(file_path=...)."""
mock_nb_get.return_value = MagicMock() 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" mock_submit.return_value = "command:123"
saved_sources = [] saved_sources = []
@ -97,7 +99,7 @@ class TestAsyncSourceAssetPersistence:
source = saved_sources[0] source = saved_sources[0]
assert source.asset is not None 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 assert source.asset.url is None
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -9,16 +9,16 @@ resolution-markers = [
[[package]] [[package]]
name = "ai-prompter" name = "ai-prompter"
version = "0.3.1" version = "0.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jinja2" }, { name = "jinja2" },
{ name = "pip" }, { name = "pip" },
{ name = "pydantic" }, { 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 = [ 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]] [[package]]
@ -2126,7 +2126,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "ai-prompter", specifier = ">=0.3,<1" }, { name = "ai-prompter", specifier = ">=0.4,<1" },
{ name = "babel", specifier = ">=2.18.0" }, { name = "babel", specifier = ">=2.18.0" },
{ name = "content-core", specifier = ">=1.14.1,<2" }, { name = "content-core", specifier = ">=1.14.1,<2" },
{ name = "esperanto", specifier = ">=2.20.0,<3" }, { name = "esperanto", specifier = ">=2.20.0,<3" },