Skip to content
NusaSec
Research / Disclosure

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()