diff --git a/src/documents/regex.py b/src/documents/regex.py index 0c5f4aac6..6e6ef7eb8 100644 --- a/src/documents/regex.py +++ b/src/documents/regex.py @@ -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, diff --git a/src/documents/tests/test_regex.py b/src/documents/tests/test_regex.py index d3aa04be5..ed9c4bd60 100644 --- a/src/documents/tests/test_regex.py +++ b/src/documents/tests/test_regex.py @@ -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( diff --git a/src/documents/views.py b/src/documents/views.py index 69d9616c9..3966300b1 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -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( { diff --git a/src/documents/zone_ocr.py b/src/documents/zone_ocr.py index 3ee520420..1bc1757b6 100644 --- a/src/documents/zone_ocr.py +++ b/src/documents/zone_ocr.py @@ -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'",