HexBrain

Weblate · Report #4044441

Translation discovery crashes on crafted filenames (regex substitution injection)

Validstatus
lowseverity
11upvotes
30 Oct 2026disclosed

How it was found

Summary

The translation-finder package (used by Weblate for translation file discovery) raises an uncaught re.error and aborts discovery for the whole repository when a scanned repository contains a translation file whose name embeds backslash-escaped language tokens, e.g. x\g<99>-en.po or x\X-en.po. An attacker with commit access to a scanned repository can keep the discovery job permanently broken (denial of service) by pushing a single crafted filename.

Weakness: CWE-1336 (Improper Neutralization of Special Elements Used in a Template Engine) / CWE-625; effect is CWE-248 (uncaught exception).

Affected Asset

  • Repository: https://github.com/WeblateOrg/translation-finder
  • Affected code: translation_finder/discovery/base.py, BaseDiscovery.get_masks() (lines ~388-395)
  • Version: 3.5.0 (current HEAD at time of testing; the vulnerable re.sub replacement logic predates this release)

Description

In BaseDiscovery.get_masks():

wildcard = self.get_wildcard(part)
if wildcard:
    mask_parts = parts.copy()
    match = re.compile(f"(^|[._-]){re.escape(part)}($|[._-])")
    for i, current in enumerate(mask_parts):
        if match.findall(current):
            skip.add(i)
            mask_parts[i] = match.sub(
                f"\\g<1>{wildcard}\\g<2>", current
            )

get_wildcard() derives a language wildcard from the filename path part. This wildcard is then interpolated into the replacement string of re.sub. The re module parses replacement strings for backreferences (\g<...>) and escape sequences, so any backslash remaining in the filename token is interpreted as substitution syntax instead of being treated as a literal.

A filename such as x\g<99>-en.po produces the wildcard \g<99>-*. When re.sub parses that replacement string, it raises:

re.error: invalid group reference 99 at position 9

A filename such as x\X-en.po raises:

re.error: bad escape \X at position 6

The exception is not caught anywhere in the discovery pipeline. It propagates through discover() and terminates discovery for the entire repository.

Steps to Reproduce

  1. Create a repository containing a translation file with a crafted name:
mkdir -p poc/locales
printf 'msgid "x"\nmsgstr "y"\n' > 'poc/locales/x\g<99>-en.po'
  1. Run discovery:
weblate-discover poc
  1. Observed result - the command exits with code 1 and a traceback:
Traceback (most recent call last):
  File "/.../translation_finder/discovery/base.py", line 392, in get_masks
    mask_parts[i] = match.sub(
re.error: invalid group reference 99 at position 9

Confirmed trigger set (each crashes discovery when the file exists in the scanned tree):

FilenameError
x\g<99>-en.poinvalid group reference 99
x\g<9>-en.poinvalid group reference 9
x\X-en.pobad escape \X
x\g<99>.en.po / x\g<99>_en.po / x\g<99>-pt_BR.poinvalid group reference 99
x\g<99>.en.json / x\g<99>-en.ts / x\g<99>-en.yamlinvalid group reference 99 (other backends)

Control cases that do not crash: x-en.po, x\g<0>-en.po (group 0 exists), x\n-en.po (valid escape).

The crash is triggered purely by the filename while the file is being listed; file contents are never read, so content-based filtering does not help.

Suggested Fix

Pass the wildcard as a literal replacement via a callback instead of interpolating it into the replacement string:

mask_parts[i] = match.sub(
    lambda m: f"{m.group(1)}{wildcard}{m.group(2)}",
    current,
)

Verified against a patched copy: every trigger above completes without error, and normal output (e.g. x-en.po -> locales/x-*.po) is unchanged.

Defense in depth:

  • Wrap the substitution in try/except re.error and skip the offending name, and/or
  • Reject translation filenames containing backslashes or other non-printable characters during traversal.

References

Impact

  • Denial of service of translation discovery for any repository containing a crafted filename.
  • An attacker with commit access to a repo scanned by Weblate (the standard trust boundary for component import) can permanently break the discovery job; it only recovers after the offending file is removed.
  • A single-byte filename difference is sufficient; no file content manipulation needed.
  • No code execution, no file disclosure. Availability impact only.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original