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:
Krrish Dholakia 2026-02-25 20:43:20 -08:00
parent 9806e21871
commit 685e20859c
6 changed files with 338 additions and 5 deletions

View file

@ -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

View file

@ -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

View file

@ -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)));

View file

@ -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

View file

@ -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)))

View file

@ -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