Skip to content

Commit d89da49

Browse files
🪤 fix: Patch Path Traversal Vulnerabilities in File Embedding Endpoints (#227)
* fix(CVE-2025-68413): restrict local file embeds to upload directory * fix(CVE_2025_68414): validate file path for embed and embed-upload endpoints * fix(security): additional endpoint for same issue as CVE_2025_68414 per @valiumaggelein * fix(CVE-2025-68414): enhance file path validation to prevent traversal attacks and add comprehensive tests * fix: improve file path validation in embed and extract endpoints to prevent path traversal vulnerabilities; add tests for entity_id parameter handling --------- Co-authored-by: Danny Avila <danny@librechat.ai>
1 parent 1d6ef08 commit d89da49

3 files changed

Lines changed: 485 additions & 22 deletions

File tree

‎app/routes/document_routes.py‎

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# app/routes/document_routes.py
22
import os
3+
from pathlib import Path
34
import hashlib
45
import traceback
56
import aiofiles
67
import aiofiles.os
78
from shutil import copyfileobj
8-
from typing import List, Iterable, TYPE_CHECKING
9+
from typing import List, Iterable, Optional, TYPE_CHECKING
910
from concurrent.futures import ThreadPoolExecutor
1011
from fastapi import (
1112
APIRouter,
@@ -109,6 +110,19 @@ def save_upload_file_sync(file: UploadFile, temp_file_path: str) -> None:
109110
)
110111

111112

113+
def validate_file_path(base_dir: str, file_path: str) -> Optional[str]:
114+
"""Validate that file_path resolves within base_dir. Returns resolved absolute path or None."""
115+
if not file_path or not file_path.strip():
116+
return None
117+
try:
118+
allowed = Path(base_dir).resolve()
119+
requested = Path(os.path.join(base_dir, file_path)).resolve()
120+
requested.relative_to(allowed)
121+
return str(requested)
122+
except (ValueError, RuntimeError, TypeError, OSError):
123+
return None
124+
125+
112126
async def load_file_content(
113127
filename: str, content_type: str, file_path: str, executor
114128
) -> tuple:
@@ -702,8 +716,11 @@ async def store_data_in_vector_db(
702716
async def embed_local_file(
703717
document: StoreDocument, request: Request, entity_id: str = None
704718
):
705-
# Check if the file exists
706-
if not os.path.exists(document.filepath):
719+
file_path = validate_file_path(RAG_UPLOAD_DIR, document.filepath)
720+
721+
# Check if the file exists and if it is within the allowed upload directory
722+
if file_path is None or not os.path.exists(file_path):
723+
logger.warning("Path validation failed for local embed: %s", document.filepath)
707724
raise HTTPException(
708725
status_code=status.HTTP_404_NOT_FOUND,
709726
detail=ERROR_MESSAGES.FILE_NOT_FOUND,
@@ -716,7 +733,7 @@ async def embed_local_file(
716733

717734
try:
718735
loader, known_type, file_ext = get_loader(
719-
document.filename, document.file_content_type, document.filepath
736+
document.filename, document.file_content_type, file_path
720737
)
721738
data = await run_in_executor(request.app.state.thread_pool, loader.load)
722739

@@ -776,17 +793,26 @@ async def embed_file(
776793
known_type = None
777794

778795
user_id = get_user_id(request, entity_id)
779-
temp_base_path = os.path.join(RAG_UPLOAD_DIR, user_id)
780-
os.makedirs(temp_base_path, exist_ok=True)
781-
temp_file_path = os.path.join(RAG_UPLOAD_DIR, user_id, file.filename)
796+
validated_file_path = validate_file_path(
797+
RAG_UPLOAD_DIR, os.path.join(user_id, file.filename)
798+
)
782799

783-
await save_upload_file_async(file, temp_file_path)
800+
if validated_file_path is None:
801+
logger.warning("Path validation failed for embed: %s", file.filename)
802+
raise HTTPException(
803+
status_code=status.HTTP_400_BAD_REQUEST,
804+
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
805+
)
806+
807+
os.makedirs(os.path.dirname(validated_file_path), exist_ok=True)
808+
809+
await save_upload_file_async(file, validated_file_path)
784810

785811
try:
786812
data, known_type, file_ext = await load_file_content(
787813
file.filename,
788814
file.content_type,
789-
temp_file_path,
815+
validated_file_path,
790816
request.app.state.thread_pool,
791817
)
792818

@@ -837,7 +863,7 @@ async def embed_file(
837863
detail=f"Error during file processing: {str(e)}",
838864
)
839865
finally:
840-
await cleanup_temp_file_async(temp_file_path)
866+
await cleanup_temp_file_async(validated_file_path)
841867

842868
return {
843869
"status": response_status,
@@ -904,15 +930,27 @@ async def embed_file_upload(
904930
entity_id: str = Form(None),
905931
):
906932
user_id = get_user_id(request, entity_id)
907-
temp_file_path = os.path.join(RAG_UPLOAD_DIR, uploaded_file.filename)
908933

909-
await save_upload_file_async(uploaded_file, temp_file_path)
934+
validated_temp_file_path = validate_file_path(
935+
RAG_UPLOAD_DIR, uploaded_file.filename
936+
)
937+
938+
if validated_temp_file_path is None:
939+
logger.warning(
940+
"Path validation failed for embed-upload: %s", uploaded_file.filename
941+
)
942+
raise HTTPException(
943+
status_code=status.HTTP_400_BAD_REQUEST,
944+
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
945+
)
946+
947+
await save_upload_file_async(uploaded_file, validated_temp_file_path)
910948

911949
try:
912950
data, known_type, file_ext = await load_file_content(
913951
uploaded_file.filename,
914952
uploaded_file.content_type,
915-
temp_file_path,
953+
validated_temp_file_path,
916954
request.app.state.thread_pool,
917955
)
918956

@@ -948,7 +986,7 @@ async def embed_file_upload(
948986
detail=f"Error during file processing: {str(e)}",
949987
)
950988
finally:
951-
await cleanup_temp_file_async(temp_file_path)
989+
await cleanup_temp_file_async(validated_temp_file_path)
952990

953991
return {
954992
"status": True,
@@ -1015,17 +1053,27 @@ async def extract_text_from_file(
10151053
Returns the raw text content for text parsing purposes.
10161054
"""
10171055
user_id = get_user_id(request, entity_id)
1018-
temp_base_path = os.path.join(RAG_UPLOAD_DIR, user_id)
1019-
os.makedirs(temp_base_path, exist_ok=True)
1020-
temp_file_path = os.path.join(RAG_UPLOAD_DIR, user_id, file.filename)
1056+
validated_temp_file_path = validate_file_path(
1057+
RAG_UPLOAD_DIR, os.path.join(user_id, file.filename)
1058+
)
10211059

1022-
await save_upload_file_async(file, temp_file_path)
1060+
if validated_temp_file_path is None:
1061+
logger.warning("Path validation failed for text extraction: %s", file.filename)
1062+
raise HTTPException(
1063+
status_code=status.HTTP_400_BAD_REQUEST,
1064+
detail=ERROR_MESSAGES.DEFAULT("Invalid request"),
1065+
)
1066+
1067+
# create base directory only if the file path is valid
1068+
os.makedirs(os.path.dirname(validated_temp_file_path), exist_ok=True)
1069+
1070+
await save_upload_file_async(file, validated_temp_file_path)
10231071

10241072
try:
10251073
data, known_type, file_ext = await load_file_content(
10261074
file.filename,
10271075
file.content_type,
1028-
temp_file_path,
1076+
validated_temp_file_path,
10291077
request.app.state.thread_pool,
10301078
)
10311079

@@ -1064,4 +1112,4 @@ async def extract_text_from_file(
10641112
detail=f"Error during text extraction: {str(e)}",
10651113
)
10661114
finally:
1067-
await cleanup_temp_file_async(temp_file_path)
1115+
await cleanup_temp_file_async(validated_temp_file_path)

‎tests/test_main.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from concurrent.futures import ThreadPoolExecutor
88

99
from main import app
10+
from app.routes import document_routes
1011

1112
client = TestClient(app)
1213

@@ -173,12 +174,15 @@ def test_query_embeddings_by_file_id(auth_headers):
173174

174175

175176
def test_embed_local_file(tmp_path, auth_headers, monkeypatch):
176-
# Create a temporary file.
177+
# Monkeypatch RAG_UPLOAD_DIR so the file is within the allowed directory.
178+
monkeypatch.setattr(document_routes, "RAG_UPLOAD_DIR", str(tmp_path))
179+
180+
# Create a temporary file inside the patched upload dir.
177181
test_file = tmp_path / "test.txt"
178182
test_file.write_text("This is a test document.")
179183

180184
data = {
181-
"filepath": str(test_file),
185+
"filepath": "test.txt",
182186
"filename": "test.txt",
183187
"file_content_type": "text/plain",
184188
"file_id": "testid1",

0 commit comments

Comments
 (0)