mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
9806e21871
commit
685e20859c
6 changed files with 338 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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
|
||||
|
|
@ -756,6 +756,7 @@ const AddGuardrailForm: React.FC<AddGuardrailFormProps> = ({ 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)));
|
||||
|
|
|
|||
|
|
@ -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<ContentFilterConfigurationProps> = ({
|
|||
selectedPatterns,
|
||||
blockedWords,
|
||||
onPatternAdd,
|
||||
onPatternsAdd,
|
||||
onPatternRemove,
|
||||
onPatternActionChange,
|
||||
onBlockedWordAdd,
|
||||
|
|
@ -120,6 +122,8 @@ const ContentFilterConfiguration: React.FC<ContentFilterConfigurationProps> = ({
|
|||
const [newKeywordAction, setNewKeywordAction] = useState<"BLOCK" | "MASK">("BLOCK");
|
||||
const [newKeywordDescription, setNewKeywordDescription] = useState<string>("");
|
||||
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<ContentFilterConfigurationProps> = ({
|
|||
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<ContentFilterConfigurationProps> = ({
|
|||
size="small"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => setPatternModalVisible(true)} icon={<PlusOutlined />}>
|
||||
Add prebuilt pattern
|
||||
</Button>
|
||||
<Button onClick={() => setCustomPatternModalVisible(true)} icon={<PlusOutlined />}>
|
||||
Add custom regex
|
||||
</Button>
|
||||
<Select
|
||||
value={patternFileAction}
|
||||
onChange={(value) => setPatternFileAction(value as "BLOCK" | "MASK")}
|
||||
style={{ width: 100 }}
|
||||
size="middle"
|
||||
>
|
||||
<Select.Option value="BLOCK">Block</Select.Option>
|
||||
<Select.Option value="MASK">Mask</Select.Option>
|
||||
</Select>
|
||||
<Upload beforeUpload={handlePatternFileUpload} accept=".txt" showUploadList={false}>
|
||||
<Button icon={<UploadOutlined />} loading={patternUploadValidating}>
|
||||
Upload regex file
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
<PatternTable
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ const ContentFilterManager: React.FC<ContentFilterManagerProps> = ({
|
|||
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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue