From 5c03220c560a93a912690e3e5d6b949d09733d19 Mon Sep 17 00:00:00 2001 From: shin-bot-litellm Date: Mon, 2 Feb 2026 19:15:13 +0000 Subject: [PATCH] Fix: Path traversal vulnerability in guardrails endpoints - Sanitize category_name in GET /guardrails/ui/category_yaml/{category_name} to reject path traversal sequences and validate resolved path stays within the expected categories directory. - Add file path validation for presidio_ad_hoc_recognizers in the Presidio guardrail hook to prevent arbitrary JSON file reading via POST /guardrails. Rejects path traversal (..), enforces .json extension, and resolves symlinks. Customer: Take2 Games --- .../proxy/guardrails/guardrail_endpoints.py | 16 ++++++++ .../guardrails/guardrail_hooks/presidio.py | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 3ce819439cb..1b240d6eeff 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -743,6 +743,14 @@ async def get_category_yaml(category_name: str): The raw YAML content of the category file """ import os + import re + + # Validate category_name to prevent path traversal + # Only allow alphanumeric characters, hyphens, and underscores + if not re.match(r'^[a-zA-Z0-9_-]+$', category_name): + raise HTTPException( + status_code=400, detail="Invalid category name. Only alphanumeric characters, hyphens, and underscores are allowed." + ) # Get the categories directory path categories_dir = os.path.join( @@ -755,6 +763,14 @@ async def get_category_yaml(category_name: str): # Construct the file path category_file_path = os.path.join(categories_dir, f"{category_name}.yaml") + # Resolve to absolute path and verify it stays within categories_dir + resolved_path = os.path.realpath(category_file_path) + resolved_categories_dir = os.path.realpath(categories_dir) + if not resolved_path.startswith(resolved_categories_dir + os.sep): + raise HTTPException( + status_code=400, detail="Invalid category name." + ) + if not os.path.exists(category_file_path): raise HTTPException( status_code=404, detail=f"Category file not found: {category_name}" diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 71ad9819146..dce99b571b1 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -123,6 +123,8 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): ad_hoc_recognizers = presidio_ad_hoc_recognizers if ad_hoc_recognizers is not None: try: + # Sanitize file path to prevent path traversal attacks + ad_hoc_recognizers = self._validate_recognizer_file_path(ad_hoc_recognizers) with open(ad_hoc_recognizers, "r") as file: self.ad_hoc_recognizers = json.load(file) except FileNotFoundError: @@ -140,6 +142,42 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): presidio_anonymizer_api_base=presidio_anonymizer_api_base, ) + @staticmethod + def _validate_recognizer_file_path(file_path: str) -> str: + """ + Validate and sanitize the ad-hoc recognizers file path to prevent + path traversal attacks. + + Ensures: + 1. No path traversal sequences (../) + 2. File must have .json extension + 3. Path is resolved to absolute and checked + """ + import os + + # Reject paths containing path traversal sequences + if ".." in file_path: + raise ValueError( + f"Invalid file path: path traversal sequences are not allowed. file_path={file_path}" + ) + + # Ensure the file has a .json extension + if not file_path.endswith(".json"): + raise ValueError( + f"Invalid file path: only .json files are allowed. file_path={file_path}" + ) + + # Resolve to absolute path + resolved_path = os.path.realpath(file_path) + + # Ensure resolved path still ends with .json (in case of symlink tricks) + if not resolved_path.endswith(".json"): + raise ValueError( + f"Invalid file path: resolved path must be a .json file. file_path={file_path}" + ) + + return resolved_path + def validate_environment( self, presidio_analyzer_api_base: Optional[str] = None,