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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user