From 685e20859c6c1851965b1aebaae706fc468a39e2 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 25 Feb 2026 20:43:20 -0800 Subject: [PATCH] Add regex pattern file upload to content filter guardrail Allow users to upload a file of newline-separated regex patterns for the content filter guardrail, with ReDoS protections including nested quantifier detection, compilation timeout, and size/count limits. - Backend: Add POST /guardrails/validate_patterns_file endpoint with security mitigations - Frontend: Add validatePatternsFile networking function - UI: Add file upload button, action selector to Pattern Detection card - Wire onPatternsAdd prop in ContentFilterManager and add_guardrail_form - Tests: 11 tests covering valid files, invalid regex, nested quantifiers, size limits Co-Authored-By: Claude Haiku 4.5 --- .../proxy/guardrails/guardrail_endpoints.py | 148 +++++++++++++++++- .../guardrails/test_guardrail_endpoints.py | 99 +++++++++++- .../guardrails/add_guardrail_form.tsx | 1 + .../ContentFilterConfiguration.tsx | 65 +++++++- .../content_filter/ContentFilterManager.tsx | 1 + .../src/components/networking.tsx | 29 ++++ 6 files changed, 338 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 20f6e6f1d39..46a48423654 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -14,7 +14,6 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry from litellm.proxy.guardrails.guardrail_hooks.custom_code.code_validator import ( CustomCodeValidationError, validate_custom_code, @@ -22,6 +21,7 @@ from litellm.proxy.guardrails.guardrail_hooks.custom_code.code_validator import from litellm.proxy.guardrails.guardrail_hooks.custom_code.primitives import ( get_custom_code_primitives, ) +from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry from litellm.proxy.guardrails.usage_endpoints import router as guardrails_usage_router from litellm.types.guardrails import ( PII_ENTITY_CATEGORIES_MAP, @@ -1002,6 +1002,152 @@ async def validate_blocked_words_file(request: Dict[str, str]): return {"valid": False, "error": f"Validation error: {str(e)}"} +# --- Regex pattern file validation --- + +MAX_PATTERNS_FILE_SIZE = 100 * 1024 # 100 KB +MAX_PATTERNS_PER_FILE = 100 +MAX_PATTERN_LENGTH = 500 + +# Heuristic to detect nested quantifiers that cause catastrophic backtracking. +# Matches groups with a quantifier inside followed by an outer quantifier, +# e.g. (a+)+, (.*)+, (x*)* +import re as _re + +_NESTED_QUANTIFIER_RE = _re.compile( + r"\([^)]*[*+]\)[*+?]|\([^)]*[*+]\)\{", +) + + +def _check_redos_heuristic(pattern: str) -> Optional[str]: + """Return an error message if the pattern looks like it could cause ReDoS.""" + if _NESTED_QUANTIFIER_RE.search(pattern): + return "pattern contains nested quantifiers which can cause catastrophic backtracking" + return None + + +def _try_compile_regex(pattern: str, timeout: float = 1.0) -> Optional[str]: + """ + Try to compile a regex pattern with a timeout. + + Returns None on success, or an error string on failure/timeout. + """ + import re + + # First, fast static check for ReDoS patterns + redos_err = _check_redos_heuristic(pattern) + if redos_err: + return redos_err + + def _compile(): + re.compile(pattern, re.IGNORECASE) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(_compile) + try: + future.result(timeout=timeout) + except concurrent.futures.TimeoutError: + return "pattern compilation timed out (possible ReDoS)" + except re.error as e: + return f"invalid regex: {e}" + return None + + +@router.post( + "/guardrails/validate_patterns_file", + tags=["Guardrails"], + dependencies=[Depends(user_api_key_auth)], +) +async def validate_patterns_file(request: Dict[str, str]): + """ + Validate a regex patterns file (newline-separated). + + Each non-empty, non-comment line is either: + - A regex pattern, e.g. ``\\d{3}-\\d{2}-\\d{4}`` + - A pipe-delimited name|regex, e.g. ``employee_id|EMP-\\d{5}`` + + Lines starting with ``#`` are treated as comments and ignored. + + Limits: max 100 KB file, 100 patterns, 500 chars per pattern. + Patterns with nested quantifiers (ReDoS risk) are rejected. + + Args: + request: Dictionary with ``file_content`` key containing the file text. + + Returns: + ``{"valid": true, "patterns": [...], "message": "..."}`` on success. + ``{"valid": false, "errors": [...]}`` on failure. + """ + try: + file_content = request.get("file_content", "") + if not file_content: + return {"valid": False, "error": "No file content provided"} + + if len(file_content.encode("utf-8")) > MAX_PATTERNS_FILE_SIZE: + return { + "valid": False, + "error": f"File too large (max {MAX_PATTERNS_FILE_SIZE // 1024} KB)", + } + + lines = file_content.splitlines() + patterns: List[Dict[str, str]] = [] + errors: List[str] = [] + + for line_num, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + # Check max pattern count early + if len(patterns) + 1 > MAX_PATTERNS_PER_FILE: + errors.append( + f"Line {line_num}: exceeds maximum of {MAX_PATTERNS_PER_FILE} patterns per file" + ) + break + + # Parse optional name|regex format + if "|" in stripped: + parts = stripped.split("|", 1) + name = parts[0].strip() + regex_str = parts[1].strip() + else: + name = None + regex_str = stripped + + if not regex_str: + errors.append(f"Line {line_num}: empty regex pattern") + continue + + if len(regex_str) > MAX_PATTERN_LENGTH: + errors.append( + f"Line {line_num}: pattern exceeds maximum length of {MAX_PATTERN_LENGTH} characters" + ) + continue + + compile_err = _try_compile_regex(regex_str) + if compile_err: + errors.append(f"Line {line_num}: {compile_err}") + continue + + patterns.append( + {"name": name or f"pattern_line_{line_num}", "pattern": regex_str} + ) + + if errors: + return {"valid": False, "errors": errors} + + if not patterns: + return {"valid": False, "error": "No patterns found in file"} + + return { + "valid": True, + "message": f"Valid file with {len(patterns)} pattern(s)", + "patterns": patterns, + } + except Exception as e: + verbose_proxy_logger.exception("Error validating patterns file") + return {"valid": False, "error": f"Validation error: {str(e)}"} + + def _get_field_type_from_annotation(field_annotation: Any) -> str: """ Convert a Python type annotation to a UI-friendly type string diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index c0f16c8b953..bf20a234c8f 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -24,6 +24,7 @@ from litellm.proxy.guardrails.guardrail_endpoints import ( list_guardrails_v2, patch_guardrail, update_guardrail, + validate_patterns_file, ) from litellm.proxy.guardrails.guardrail_registry import ( IN_MEMORY_GUARDRAIL_HANDLER, @@ -1100,4 +1101,100 @@ async def test_get_guardrail_info_endpoint_db_guardrail(mocker): assert isinstance(result, GuardrailInfoResponse) assert result.guardrail_id == "test-db-guardrail" assert result.guardrail_name == "Test DB Guardrail" - assert result.guardrail_definition_location == "db" \ No newline at end of file + assert result.guardrail_definition_location == "db" + + +class TestValidatePatternsFile: + """Tests for the validate_patterns_file endpoint.""" + + @pytest.mark.asyncio + async def test_validate_patterns_valid_simple(self): + """Valid file with simple regex patterns.""" + file_content = "\\d{3}-\\d{2}-\\d{4}\n[A-Z]{2}\\d{6}\n\\b\\w+@\\w+\\.\\w+\\b" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is True + assert len(result["patterns"]) == 3 + assert result["patterns"][0]["name"] == "pattern_line_1" + assert result["patterns"][0]["pattern"] == "\\d{3}-\\d{2}-\\d{4}" + + @pytest.mark.asyncio + async def test_validate_patterns_valid_named(self): + """Valid file with named name|regex patterns.""" + file_content = "ssn|\\d{3}-\\d{2}-\\d{4}\nemployee_id|EMP-\\d{5}" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is True + assert len(result["patterns"]) == 2 + assert result["patterns"][0]["name"] == "ssn" + assert result["patterns"][0]["pattern"] == "\\d{3}-\\d{2}-\\d{4}" + assert result["patterns"][1]["name"] == "employee_id" + + @pytest.mark.asyncio + async def test_validate_patterns_invalid_regex(self): + """Invalid regex syntax returns errors with line numbers.""" + file_content = "\\d{3}\n[invalid\n\\w+" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False + assert "errors" in result + assert len(result["errors"]) == 1 + assert "Line 2" in result["errors"][0] + + @pytest.mark.asyncio + async def test_validate_patterns_empty_file(self): + """Empty file returns error.""" + result = await validate_patterns_file({"file_content": ""}) + assert result["valid"] is False + + @pytest.mark.asyncio + async def test_validate_patterns_no_content(self): + """Missing file_content key returns error.""" + result = await validate_patterns_file({}) + assert result["valid"] is False + + @pytest.mark.asyncio + async def test_validate_patterns_comments_and_blanks(self): + """Comments and blank lines are ignored.""" + file_content = "# This is a comment\n\n\\d{3}\n\n# Another comment\n\\w+" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is True + assert len(result["patterns"]) == 2 + + @pytest.mark.asyncio + async def test_validate_patterns_max_length_exceeded(self): + """Pattern exceeding max length is rejected.""" + long_pattern = "a" * 501 + file_content = f"{long_pattern}" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False + assert any("maximum length" in e for e in result.get("errors", [])) + + @pytest.mark.asyncio + async def test_validate_patterns_too_many(self): + """More than 100 patterns is rejected.""" + lines = [f"pattern_{i}" for i in range(101)] + file_content = "\n".join(lines) + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False + assert any("maximum" in e.lower() for e in result.get("errors", [])) + + @pytest.mark.asyncio + async def test_validate_patterns_nested_quantifier_rejected(self): + """Patterns with nested quantifiers (ReDoS risk) are rejected.""" + file_content = "(a+)+b" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False + assert any("nested quantifier" in e.lower() for e in result.get("errors", [])) + + @pytest.mark.asyncio + async def test_validate_patterns_file_too_large(self): + """File exceeding 100KB is rejected.""" + file_content = "a\n" * 60000 # >100KB + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False + assert "too large" in result.get("error", "").lower() + + @pytest.mark.asyncio + async def test_validate_patterns_only_comments(self): + """File with only comments returns no patterns error.""" + file_content = "# comment 1\n# comment 2\n" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is False \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index 183763667f9..780278d0f93 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -756,6 +756,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a selectedPatterns={selectedPatterns} blockedWords={blockedWords} onPatternAdd={(pattern) => setSelectedPatterns([...selectedPatterns, pattern])} + onPatternsAdd={(patterns) => setSelectedPatterns([...selectedPatterns, ...patterns])} onPatternRemove={(id) => setSelectedPatterns(selectedPatterns.filter((p) => p.id !== id))} onPatternActionChange={(id, action) => { setSelectedPatterns(selectedPatterns.map((p) => (p.id === id ? { ...p, action } : p))); diff --git a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx index 99100677b2a..3859a03a745 100644 --- a/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/content_filter/ContentFilterConfiguration.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; -import { Typography, Space, Upload, Card, Button } from "antd"; +import { Typography, Space, Upload, Card, Button, Select } from "antd"; import { PlusOutlined, UploadOutlined } from "@ant-design/icons"; -import { validateBlockedWordsFile } from "../../networking"; +import { validateBlockedWordsFile, validatePatternsFile } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import PatternModal from "./PatternModal"; import CustomPatternModal from "./CustomPatternModal"; @@ -59,6 +59,7 @@ interface ContentFilterConfigurationProps { selectedPatterns: Pattern[]; blockedWords: BlockedWord[]; onPatternAdd: (pattern: Pattern) => void; + onPatternsAdd?: (patterns: Pattern[]) => void; onPatternRemove: (id: string) => void; onPatternActionChange: (id: string, action: "BLOCK" | "MASK") => void; onBlockedWordAdd: (word: BlockedWord) => void; @@ -88,6 +89,7 @@ const ContentFilterConfiguration: React.FC = ({ selectedPatterns, blockedWords, onPatternAdd, + onPatternsAdd, onPatternRemove, onPatternActionChange, onBlockedWordAdd, @@ -120,6 +122,8 @@ const ContentFilterConfiguration: React.FC = ({ const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK"); const [newKeywordDescription, setNewKeywordDescription] = useState(""); const [uploadValidating, setUploadValidating] = useState(false); + const [patternFileAction, setPatternFileAction] = useState<"BLOCK" | "MASK">("BLOCK"); + const [patternUploadValidating, setPatternUploadValidating] = useState(false); const handleAddPrebuiltPattern = () => { if (!selectedPatternName) { @@ -206,6 +210,47 @@ const ContentFilterConfiguration: React.FC = ({ return false; }; + const handlePatternFileUpload = async (file: File) => { + setPatternUploadValidating(true); + try { + const content = await file.text(); + + if (accessToken) { + const result = await validatePatternsFile(accessToken, content); + if (result.valid && result.patterns) { + const newPatterns: Pattern[] = result.patterns.map( + (p: { name: string; pattern: string }, index: number) => ({ + id: `file-pattern-${Date.now()}-${index}`, + type: "custom" as const, + name: p.name, + pattern: p.pattern, + action: patternFileAction, + }) + ); + if (onPatternsAdd) { + onPatternsAdd(newPatterns); + } else { + newPatterns.forEach((p) => onPatternAdd(p)); + } + NotificationsManager.success( + result.message || `${newPatterns.length} patterns uploaded successfully` + ); + } else { + const errorMessage = + result.error || + (result.errors && result.errors.join("\n")) || + "Invalid file"; + NotificationsManager.error(`Validation failed: ${errorMessage}`); + } + } + } catch (error) { + NotificationsManager.error(`Failed to upload file: ${error}`); + } finally { + setPatternUploadValidating(false); + } + return false; + }; + const showPatterns = !showStep || showStep === "patterns"; const showKeywords = !showStep || showStep === "keywords"; const showCategories = !showStep || showStep === "categories"; @@ -237,13 +282,27 @@ const ContentFilterConfiguration: React.FC = ({ size="small" >
- + + + + +
= ({ selectedPatterns={selectedPatterns} blockedWords={blockedWords} onPatternAdd={(pattern) => setSelectedPatterns([...selectedPatterns, pattern])} + onPatternsAdd={(patterns) => setSelectedPatterns([...selectedPatterns, ...patterns])} onPatternRemove={(id) => setSelectedPatterns(selectedPatterns.filter((p) => p.id !== id))} onPatternActionChange={(id, action) => setSelectedPatterns(selectedPatterns.map((p) => (p.id === id ? { ...p, action } : p))) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a20170a0fb0..27b454f55b4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8546,6 +8546,35 @@ export const validateBlockedWordsFile = async (accessToken: string, fileContent: } }; +export const validatePatternsFile = async (accessToken: string, fileContent: string) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/validate_patterns_file` + : `/guardrails/validate_patterns_file`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ file_content: fileContent }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Failed to validate patterns file"); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to validate patterns file:", error); + throw error; + } +}; + export const getSSOSettings = async (accessToken: string) => { try { // Construct base URL