Weblate · Report #4044441
Translation discovery crashes on crafted filenames (regex substitution injection)
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.subreplacement 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
- 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'
- Run discovery:
weblate-discover poc
- 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):
| Filename | Error |
|---|---|
x\g<99>-en.po | invalid group reference 99 |
x\g<9>-en.po | invalid group reference 9 |
x\X-en.po | bad escape \X |
x\g<99>.en.po / x\g<99>_en.po / x\g<99>-pt_BR.po | invalid group reference 99 |
x\g<99>.en.json / x\g<99>-en.ts / x\g<99>-en.yaml | invalid 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.errorand skip the offending name, and/or - Reject translation filenames containing backslashes or other non-printable characters during traversal.
References
- Affected project: https://github.com/WeblateOrg/translation-finder
- No CVE assigned and no upstream advisory found for this issue as of 2026-09-20.
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.