Files
ObsiGate/backend/services/regex_safety.py
T
bruno 162a5b4acc
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m21s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m48s
fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022]
- rate-limit/lockout MFA [BUG-023]
- isolation vaults par segments [BUG-024]
- caps regex ReDoS [BUG-025]
- SSRF webhooks + secrets externalises [BUG-026]
- rotation/revocation des jetons [BUG-027]
- politique de mot de passe + invalidation sessions [BUG-028]
- verrous users.json [BUG-029]
- IP reelle dans les audits [BUG-030]
- rate-limit par compte [BUG-031]
- symlinks hors vault ignores [BUG-032]
- recherche simple via inverted index [BUG-033]
- token en memoire + cookie HttpOnly, CSP durcie [BUG-034]

Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
2026-09-13 10:51:42 -04:00

68 lines
2.2 KiB
Python

"""Regex safety helpers (BUG-025).
User-supplied regular expressions are applied to large amounts of indexed
content. Python's :mod:`re` has no timeout, so a malicious pattern such as
``(a+)+$`` can pin a CPU for a long time (ReDoS). Without adding a native
dependency we mitigate by:
* bounding the pattern length,
* rejecting nested-quantifier constructs (the classic catastrophic form),
* capping the amount of text a single regex pass may scan,
* capping the number of matches collected.
"""
from __future__ import annotations
import re
__all__ = [
"MAX_PATTERN_LENGTH",
"MAX_REGEX_CONTENT",
"MAX_REGEX_MATCHES",
"truncate_for_regex",
"validate_regex",
]
MAX_PATTERN_LENGTH = 500
MAX_REGEX_CONTENT = 200_000
MAX_REGEX_MATCHES = 1000
# A quantified group whose body already contains a quantifier, immediately
# followed by another quantifier: ``(a+)+``, ``(.*)*``, ``(a+){2,}``, ...
_NESTED_QUANTIFIER_RE = re.compile(r"\([^()]*[+*][^()]*\)\s*(?:[+*?]|\{)")
# Backreferences combined with quantifiers are a common ReDoS vector too.
_BACKREF_QUANTIFIER_RE = re.compile(r"\\[1-9][0-9]*\s*(?:[+*]|\{)")
def validate_regex(pattern: str) -> str:
"""Validate a user-supplied regex against the safety policy.
Args:
pattern: Raw regex pattern.
Returns:
The pattern unchanged when acceptable.
Raises:
ValueError: When the pattern is empty, too long, or uses a construct
known to cause catastrophic backtracking.
"""
if not pattern:
raise ValueError("Expression régulière vide")
if len(pattern) > MAX_PATTERN_LENGTH:
raise ValueError(f"Expression régulière trop longue (max {MAX_PATTERN_LENGTH})")
if _NESTED_QUANTIFIER_RE.search(pattern) or _BACKREF_QUANTIFIER_RE.search(pattern):
raise ValueError("Expression régulière refusée (quantificateurs imbriqués)")
try:
re.compile(pattern)
except re.error as e:
raise ValueError(f"Expression régulière invalide : {e}") from e
return pattern
def truncate_for_regex(content: str, limit: int = MAX_REGEX_CONTENT) -> str:
"""Return at most *limit* characters to bound a single regex pass."""
if content and len(content) > limit:
return content[:limit]
return content