diff --git a/backend/src/api/routes/translate/_helpers.py b/backend/src/api/routes/translate/_helpers.py index 2ee66965..b1fa8a24 100644 --- a/backend/src/api/routes/translate/_helpers.py +++ b/backend/src/api/routes/translate/_helpers.py @@ -37,7 +37,6 @@ def _run_to_response(run: Any) -> dict: # #region _dict_to_response [C:2] [TYPE Function] # @BRIEF Convert a TerminologyDictionary ORM model to a response dict with computed entry_count. -@staticmethod def _dict_to_response(d: Any, entry_count: int = 0) -> dict: return { "id": d.id, diff --git a/backend/src/plugins/translate/dictionary.py b/backend/src/plugins/translate/dictionary.py index f4d7354e..1e50aba7 100644 --- a/backend/src/plugins/translate/dictionary.py +++ b/backend/src/plugins/translate/dictionary.py @@ -658,6 +658,10 @@ class DictionaryManager: if not all_entries: return [] + # PERFORMANCE NOTE: O(rows × entries) regex matching per batch. + # Acceptable for current scale (50-100 rows × <1000 entries). + # For larger batches (>500 rows or >5000 entries), consider + # replacing with an Aho-Corasick automaton (e.g., pyahocorasick). matched: list[dict[str, Any]] = [] seen_source_match: set = set() diff --git a/backend/src/plugins/translate/service.py b/backend/src/plugins/translate/service.py index ea05b00d..558bd492 100644 --- a/backend/src/plugins/translate/service.py +++ b/backend/src/plugins/translate/service.py @@ -825,13 +825,19 @@ class InlineCorrectionService: class BulkFindReplaceService: """Handle bulk find-and-replace on TranslationLanguage entries.""" + MAX_PATTERN_LENGTH = 500 + @staticmethod def _compile_pattern(pattern: str, is_regex: bool) -> re.Pattern: """Compile a search pattern (regex or plain text).""" - import re - if is_regex: - return re.compile(pattern) - return re.compile(re.escape(pattern)) + if len(pattern) > BulkFindReplaceService.MAX_PATTERN_LENGTH: + raise ValueError( + f"Pattern too long (max {BulkFindReplaceService.MAX_PATTERN_LENGTH} characters)" + ) + try: + return re.compile(pattern) if is_regex else re.compile(re.escape(pattern)) + except re.error as e: + raise ValueError(f"Invalid regex pattern: {e}") @staticmethod def _find_matching_entries( diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index 7bbaf755..3039c9cb 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -608,7 +608,7 @@ class OverrideLanguageRequest(BaseModel): # #region BulkFindReplaceRequest [C:1] [TYPE Class] # @BRIEF Schema for bulk find-and-replace within translations for a target language. class BulkFindReplaceRequest(BaseModel): - find_pattern: str = Field(..., description="Text or regex pattern to find") + find_pattern: str = Field(..., description="Text or regex pattern to find", max_length=500) is_regex: bool = Field(False, description="Whether find_pattern is a regular expression") replacement_text: str = Field(..., description="Replacement text") target_language: str = Field(..., description="BCP-47 language code to target") @@ -617,6 +617,14 @@ class BulkFindReplaceRequest(BaseModel): dictionary_id: str | None = Field(None, description="Target dictionary ID for submitting corrections") usage_notes: str | None = Field(None, description="Usage notes for dictionary entries") submit_to_dictionary_with_context: bool = Field(False, description="If true, auto-capture source row context when submitting to dictionary") + + @field_validator("find_pattern") + @classmethod + def validate_pattern_length(cls, v: str) -> str: + """Reject patterns longer than 500 characters to prevent ReDoS.""" + if len(v) > 500: + raise ValueError("Pattern too long (max 500 characters)") + return v # #endregion BulkFindReplaceRequest diff --git a/frontend/src/lib/components/translate/CorrectionCell.svelte b/frontend/src/lib/components/translate/CorrectionCell.svelte index 63db9543..c4317d77 100644 --- a/frontend/src/lib/components/translate/CorrectionCell.svelte +++ b/frontend/src/lib/components/translate/CorrectionCell.svelte @@ -183,11 +183,12 @@ {:else if uxState === 'saved'}
{displayValue} - 📝 + 📝 @@ -204,7 +205,7 @@ {:else if uxState === 'dict_submitted'}
{displayValue} - ✅ Dictionary ✓ + ✅ Dictionary ✓