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]
## [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

View file

@ -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":

View file

@ -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",

View file

@ -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

8
uv.lock generated
View file

@ -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" },