safe regex

This commit is contained in:
shamoon committed 2026-10-20 11:05:49 -07:00
1 parent 8100a8e1eb
commit 77a7fb40ec
4 files changed
+51 -8

No files matched your search

+27
View File
@@ -77,6 +77,33 @@ def safe_regex_match(pattern: str, text: str, *, flags: int = 0):
return None
def safe_regex_fullmatch(pattern: str, text: str, *, flags: int = 0):
"""
Run a regex full match with a timeout. Returns a match object or None.
Validation errors and timeouts are logged and treated as no match.
"""
try:
validate_regex_pattern(pattern)
compiled = regex.compile(pattern, flags=flags)
except (regex.error, ValueError) as exc:
logger.exception(
"Error while processing regular expression %s: %s",
textwrap.shorten(pattern, width=80, placeholder="…"),
exc,
)
return None
try:
return compiled.fullmatch(text, timeout=REGEX_TIMEOUT_SECONDS)
except TimeoutError:
logger.warning(
"Regular expression matching timed out for pattern %s",
textwrap.shorten(pattern, width=80, placeholder="…"),
)
return None
def safe_regex_sub(pattern: str, repl: str, text: str, *, flags: int = 0) -> str | None:
"""
Run a regex substitution with a timeout. Returns the substituted string,
+18 -1
View File
@@ -4,6 +4,7 @@ from django.conf import settings
from pytest_mock import MockerFixture
from documents.regex import safe_regex_finditer
from documents.regex import safe_regex_fullmatch
from documents.regex import safe_regex_match
from documents.regex import safe_regex_search
from documents.regex import safe_regex_sub
@@ -26,7 +27,7 @@ class TestValidateRegexPattern:
class TestSafeRegexSearchAndMatch:
"""Tests for safe_regex_search and safe_regex_match (same contract)."""
"""Tests for safe_regex_search, _match and _fullmatch (same contract)."""
@pytest.mark.parametrize(
("func", "pattern", "text", "expected_group"),
@@ -45,6 +46,13 @@ class TestSafeRegexSearchAndMatch:
"123",
id="match-match-found",
),
pytest.param(
safe_regex_fullmatch,
r"\d+",
"123",
"123",
id="fullmatch-match-found",
),
],
)
def test_match_found(self, func, pattern, text, expected_group) -> None:
@@ -57,6 +65,12 @@ class TestSafeRegexSearchAndMatch:
[
pytest.param(safe_regex_search, r"\d+", "abcdef", id="search-no-match"),
pytest.param(safe_regex_match, r"\d+", "abc123", id="match-no-match"),
pytest.param(
safe_regex_fullmatch,
r"\d+",
"123abc",
id="fullmatch-no-match",
),
],
)
def test_no_match(self, func, pattern, text) -> None:
@@ -67,6 +81,7 @@ class TestSafeRegexSearchAndMatch:
[
pytest.param(safe_regex_search, id="search"),
pytest.param(safe_regex_match, id="match"),
pytest.param(safe_regex_fullmatch, id="fullmatch"),
],
)
def test_invalid_pattern_returns_none(self, func) -> None:
@@ -77,6 +92,7 @@ class TestSafeRegexSearchAndMatch:
[
pytest.param(safe_regex_search, id="search"),
pytest.param(safe_regex_match, id="match"),
pytest.param(safe_regex_fullmatch, id="fullmatch"),
],
)
def test_flags_respected(self, func) -> None:
@@ -87,6 +103,7 @@ class TestSafeRegexSearchAndMatch:
[
pytest.param(safe_regex_search, "search", id="search"),
pytest.param(safe_regex_match, "match", id="match"),
pytest.param(safe_regex_fullmatch, "fullmatch", id="fullmatch"),
],
)
def test_timeout_returns_none(
+4 -6
View File
@@ -186,6 +186,7 @@ from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_objects
from documents.permissions import user_is_unrestricted
from documents.plugins.date_parsing import get_date_parser
from documents.regex import safe_regex_fullmatch
from documents.schema import generate_object_with_permissions_schema
from documents.search import SearchHit
from documents.serialisers import AcknowledgeTasksViewSerializer
@@ -5995,12 +5996,9 @@ class OcrTemplateViewSet(ModelViewSet):
regex_match = None
if zone.validation_regex and result.get("value") is not None:
try:
regex_match = (
re.fullmatch(zone.validation_regex, result["value"]) is not None
)
except re.error:
regex_match = None
regex_match = (
safe_regex_fullmatch(zone.validation_regex, result["value"]) is not None
)
return Response(
{
+2 -1
View File
@@ -26,6 +26,7 @@ from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import OcrTemplate
from documents.models import OcrTemplateZone
from documents.regex import safe_regex_fullmatch
logger = logging.getLogger("paperless.zone_ocr")
@@ -165,7 +166,7 @@ def _process_template(
if (
extracted is not None
and zone.validation_regex
and not re.fullmatch(zone.validation_regex, extracted)
and not safe_regex_fullmatch(zone.validation_regex, extracted)
):
logger.info(
"Zone OCR: '%s' value %r rejected by regex '%s'",