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]
|
## [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
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
8
uv.lock
8
uv.lock
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue