RegexCheck causes Uncontrolled Resource Consumption
Summary
Weblate lets users with the built-in "Edit source" role set additional source-string flags, including the regex: quality check and regular-expression placeholders. Those regexes are compiled successfully by validation but later executed in RegexCheck and PlaceholderCheck without the timeout wrapper Weblate uses elsewhere.
When a source unit's extra_flags changes, Weblate immediately runs checks for each linked target unit in the same request. A source editor can store a pathological regex such as ^(a|aa)+$ and cause CPU-bound request stalls when checks process matching translations.
Technical Detail
Root Cause
validate_check_flags() only parses the flag string and validates that typed flags compile. It does not impose a regex timeout, complexity restriction, or pattern length cap for extra_flags values:
# weblate/trans/validators.py:37-43 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
def validate_check_flags(val) -> None:
"""Validate check-influencing flags."""
try:
flags = FlagsValidator(val)
except (ParseException, re.error) as error:
raise ValidationError(gettext("Could not parse flags: %s") % error) from error
flags.validate()RegexCheck later executes the compiled expression with unbounded findall():
# weblate/checks/placeholders.py:197-200 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
def check_target_params(
self, sources: list[str], targets: list[str], unit: Unit, value
):
return any(not value.findall(target) for target in targets)PlaceholderCheck has the same issue through finditer():
# weblate/checks/placeholders.py:70-73 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
@staticmethod
def get_matches(value, text: str):
for match in value.finditer(text, concurrent=True):
yield match.group()This contrasts with the timeout-safe helper used in other regex paths:
# weblate/utils/regex.py:9-23 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
REGEX_TIMEOUT = 0.2
def regex_match(pattern: str | regex.Pattern, value: str) -> regex.Match | None:
compiled = compile_regex(pattern) if isinstance(pattern, str) else pattern
return compiled.match(value, timeout=REGEX_TIMEOUT)
def regex_findall(pattern: str | regex.Pattern, value: str) -> list[object]:
compiled = compile_regex(pattern) if isinstance(pattern, str) else pattern
return compiled.findall(value, timeout=REGEX_TIMEOUT)The low-privilege reachability is through source metadata editing. The built-in role includes source.edit:
# weblate/auth/data.py:193-202 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
ROLES = (
(
pgettext_noop("Access-control role", "Administration"),
[x[0] for x in PERMISSIONS if not x[0].startswith("workspace.")],
),
(
pgettext_noop("Access-control role", "Edit source"),
TRANSLATE_PERMS | {"unit.template", "source.edit"},
),The web form requires source.edit and then saves ContextForm, which includes extra_flags:
# weblate/trans/views/source.py:57-64 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
else:
if not request.user.has_perm("source.edit", unit.translation):
raise PermissionDenied
form = ContextForm(request.POST, instance=unit, user=request.user)
if form.is_valid():
form.save()The API also exposes extra_flags on UnitWriteSerializer and saves it after the same source.edit check:
# weblate/api/serializers.py:2416-2429 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
class UnitWriteSerializer(serializers.ModelSerializer[Unit]):
"""Serializer for updating source unit."""
...
class Meta:
model = Unit
fields = (
"target",
"state",
"explanation",
"extra_flags",
"labels",
)Saving a changed source unit synchronously runs checks for every linked unit:
# weblate/trans/models/unit.py:755-759,938-952 @ 3d7ae58a492c6e62dac590486e5bf7ec2d5def10
if run_checks:
self.run_checks(force_propagate=force_propagate_checks)
if self.is_source and not was_created:
self.source_unit_save()
...
for unit in self.unit_set.select_for_update().prefetch().prefetch_bulk():
unit.translation.component = self.translation.component
unit.update_state()
unit.update_priority()
unit.run_checks()