diff --git a/litellm/proxy/common_utils/path_utils.py b/litellm/proxy/common_utils/path_utils.py new file mode 100644 index 00000000000..c13f0f90d90 --- /dev/null +++ b/litellm/proxy/common_utils/path_utils.py @@ -0,0 +1,57 @@ +""" +Safe filesystem path construction for user-controlled inputs. + +Use safe_join() instead of os.path.join() whenever a path component +comes from user input (request parameters, uploaded filenames, etc.) +to prevent directory traversal attacks. +""" + +import os +from pathlib import Path + + +def safe_join(base_dir: str, *parts: str) -> str: + """ + Join path components and verify the result stays within base_dir. + + Resolves symlinks and '..' sequences, then checks the final path + is a descendant of base_dir. Raises ValueError if traversal is + detected. + + Args: + base_dir: The trusted base directory. + *parts: User-controlled path components to append. + + Returns: + The resolved absolute path as a string. + + Raises: + ValueError: If the resolved path escapes base_dir. + """ + base = os.path.realpath(base_dir) + resolved = os.path.realpath(os.path.join(base, *parts)) + if not (resolved.startswith(base + os.sep) or resolved == base): + raise ValueError(f"Path escapes base directory") + return resolved + + +def safe_filename(filename: str) -> str: + """ + Extract a safe filename from a user-supplied path. + + Strips all directory components, returning only the final name. + Use this for uploaded file names before writing to disk. + + Args: + filename: User-supplied filename (may contain path separators). + + Returns: + The basename only, with no directory components. + + Raises: + ValueError: If the resulting filename is empty. + """ + name = Path(filename).name + if not name: + raise ValueError("Empty filename") + return name diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index f5ddd4f6742..8d8e89ede0b 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -1358,9 +1358,14 @@ async def get_category_yaml(category_name: str): "categories", ) + from litellm.proxy.common_utils.path_utils import safe_join + # Try to find the file with either .yaml or .json extension - yaml_path = os.path.join(categories_dir, f"{category_name}.yaml") - json_path = os.path.join(categories_dir, f"{category_name}.json") + try: + yaml_path = safe_join(categories_dir, f"{category_name}.yaml") + json_path = safe_join(categories_dir, f"{category_name}.json") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid category name") category_file_path = None file_type = None diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index 483107a3759..5a94c2905ad 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -1356,8 +1356,10 @@ async def convert_prompt_file_to_json( # Read file content file_content = await file.read() - # Create temporary file - temp_file_path = Path(tempfile.mkdtemp()) / file.filename + from litellm.proxy.common_utils.path_utils import safe_filename + + # Create temporary file — use safe_filename to prevent path traversal + temp_file_path = Path(tempfile.mkdtemp()) / safe_filename(file.filename) temp_file_path.write_bytes(file_content) # Create a PromptManager instance just for conversion