mirror of
https://github.com/lfnovo/open-notebook.git
synced 2026-04-28 03:19:59 +00:00
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:
parent
89eac04c63
commit
70a466a640
5 changed files with 42 additions and 13 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
8
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue