mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 06:37:14 +00:00
08307790e5
* fix(cntfilter): allow legacy sensitive-word lists over 64 patterns Legacy sensitive-words.json files shipped ~70 rules. After v4.10.7, BanWordFilter treated the 64-pattern safe_regex cap as a hard failure and blocked every message. Raise the cap only on the sensitive-word path, keep the 50ms CPU budget, and truncate oversized lists with a one-time warning. Fixes #2443 * fix(cntfilter): reject oversized sensitive-word lists --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
187 lines
5.7 KiB
Python
187 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from collections.abc import Sequence
|
|
|
|
import regex
|
|
|
|
|
|
MAX_PATTERN_COUNT = 64
|
|
MAX_PATTERN_CHARS = 1024
|
|
MAX_INPUT_CHARS = 1024 * 1024
|
|
MAX_REPLACEMENT_CHARS = 64
|
|
MAX_MASKED_OUTPUT_CHARS = 2 * 1024 * 1024
|
|
DEFAULT_OPERATION_TIMEOUT_SECONDS = 0.05
|
|
|
|
|
|
class SafeRegexError(ValueError):
|
|
"""Base class for rejected, invalid, or timed-out tenant regex work."""
|
|
|
|
|
|
class SafeRegexLimitError(SafeRegexError):
|
|
"""Raised when a regex operation exceeds a deterministic resource limit."""
|
|
|
|
|
|
class SafeRegexTimeoutError(SafeRegexError):
|
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
|
|
|
|
|
def _validate_patterns(
|
|
patterns: Sequence[str],
|
|
*,
|
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
|
) -> tuple[str, ...]:
|
|
if max_pattern_count < 1:
|
|
raise ValueError('max_pattern_count must be positive')
|
|
if len(patterns) > max_pattern_count:
|
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
|
normalized = tuple(patterns)
|
|
for pattern in normalized:
|
|
if not isinstance(pattern, str):
|
|
raise SafeRegexError('Regex patterns must be strings')
|
|
if len(pattern) > MAX_PATTERN_CHARS:
|
|
raise SafeRegexLimitError(f'Regex patterns may contain at most {MAX_PATTERN_CHARS} characters')
|
|
return normalized
|
|
|
|
|
|
def _validate_input(value: str) -> None:
|
|
if not isinstance(value, str):
|
|
raise SafeRegexError('Regex input must be a string')
|
|
if len(value) > MAX_INPUT_CHARS:
|
|
raise SafeRegexLimitError(f'Regex input may contain at most {MAX_INPUT_CHARS} characters')
|
|
|
|
|
|
def _remaining_seconds(deadline: float) -> float:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise SafeRegexTimeoutError('Regex operation timed out')
|
|
return remaining
|
|
|
|
|
|
def _compile(pattern: str):
|
|
try:
|
|
return regex.compile(pattern)
|
|
except regex.error as exc:
|
|
raise SafeRegexError(f'Invalid regex: {exc}') from exc
|
|
|
|
|
|
def _matches_any_sync(
|
|
patterns: Sequence[str],
|
|
value: str,
|
|
*,
|
|
mode: str,
|
|
timeout_seconds: float,
|
|
) -> bool:
|
|
normalized_patterns = _validate_patterns(patterns)
|
|
_validate_input(value)
|
|
if mode not in {'match', 'search'}:
|
|
raise ValueError(f'Unsupported safe regex mode: {mode}')
|
|
|
|
deadline = time.monotonic() + timeout_seconds
|
|
try:
|
|
for pattern in normalized_patterns:
|
|
compiled = _compile(pattern)
|
|
matcher = compiled.match if mode == 'match' else compiled.search
|
|
if matcher(
|
|
value,
|
|
timeout=_remaining_seconds(deadline),
|
|
concurrent=True,
|
|
):
|
|
return True
|
|
except TimeoutError as exc:
|
|
raise SafeRegexTimeoutError('Regex operation timed out') from exc
|
|
return False
|
|
|
|
|
|
async def matches_any(
|
|
patterns: Sequence[str],
|
|
value: str,
|
|
*,
|
|
mode: str = 'search',
|
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
|
) -> bool:
|
|
"""Match untrusted patterns without blocking the shared event loop."""
|
|
|
|
if timeout_seconds <= 0:
|
|
raise ValueError('timeout_seconds must be positive')
|
|
return await asyncio.to_thread(
|
|
_matches_any_sync,
|
|
patterns,
|
|
value,
|
|
mode=mode,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
|
|
|
|
def _mask_patterns_sync(
|
|
patterns: Sequence[str],
|
|
value: str,
|
|
*,
|
|
mask: str,
|
|
mask_word: str,
|
|
timeout_seconds: float,
|
|
max_pattern_count: int,
|
|
) -> tuple[bool, str]:
|
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
|
_validate_input(value)
|
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
|
|
|
# Reject amplification before invoking a replacement callback. This is
|
|
# deliberately conservative: a hostile replacement must not allocate tens
|
|
# of megabytes before the post-operation output check can run.
|
|
replacement_width = len(mask_word) if mask_word else len(mask)
|
|
if replacement_width * max(1, len(value)) > MAX_MASKED_OUTPUT_CHARS:
|
|
raise SafeRegexLimitError('Regex replacement could exceed the masked output limit')
|
|
|
|
deadline = time.monotonic() + timeout_seconds
|
|
found = False
|
|
current = value
|
|
|
|
def replace(match) -> str:
|
|
nonlocal found
|
|
found = True
|
|
if mask_word:
|
|
return mask_word
|
|
return mask * len(match.group(0))
|
|
|
|
try:
|
|
for pattern in normalized_patterns:
|
|
compiled = _compile(pattern)
|
|
current = compiled.sub(
|
|
replace,
|
|
current,
|
|
timeout=_remaining_seconds(deadline),
|
|
concurrent=True,
|
|
)
|
|
if len(current) > MAX_MASKED_OUTPUT_CHARS:
|
|
raise SafeRegexLimitError('Regex replacement exceeded the masked output limit')
|
|
except TimeoutError as exc:
|
|
raise SafeRegexTimeoutError('Regex operation timed out') from exc
|
|
return found, current
|
|
|
|
|
|
async def mask_patterns(
|
|
patterns: Sequence[str],
|
|
value: str,
|
|
*,
|
|
mask: str,
|
|
mask_word: str,
|
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
|
) -> tuple[bool, str]:
|
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
|
|
|
if timeout_seconds <= 0:
|
|
raise ValueError('timeout_seconds must be positive')
|
|
return await asyncio.to_thread(
|
|
_mask_patterns_sync,
|
|
patterns,
|
|
value,
|
|
mask=mask,
|
|
mask_word=mask_word,
|
|
timeout_seconds=timeout_seconds,
|
|
max_pattern_count=max_pattern_count,
|
|
)
|