Resumen
translation-finder es la librería de Python que Weblate usa para localizar los ficheros de traducción dentro de un repositorio. Para hacerlo recorre el árbol de directorios, y ese recorrido se implementaba con una función que se llama a sí misma por cada subcarpeta, sin ningún límite de profundidad.
Python impone un máximo de llamadas anidadas (por defecto, alrededor de 1000). Si el repositorio contiene una cadena de carpetas lo bastante profunda, se supera ese máximo y salta un RecursionError. El código solo capturaba OSError, así que la excepción subía hasta discover() / Finder() y abortaba todo el análisis.
El fallo se comprobó en la versión 3.4.1 (commit f5f8bb5, entonces la rama principal), con Python 3.13.9 en Linux x86_64. El programa lo clasificó como severidad baja y la debilidad como consumo de recursos sin control; el investigador lo asocia a CWE-674 (recursión sin control) y, en segundo lugar, CWE-400.
El código afectado está en translation_finder/finder.py, líneas 157–177:
def list_files(self, root: PurePath, files: PathListType, dirs: PathListType) -> None:
with scandir(root) as matches:
for match in matches:
if match.is_symlink():
continue
is_dir = match.is_dir()
path = Path(match.path)
if any(path.match(exclude) for exclude in EXCLUDES):
continue
if is_dir:
dirs.append(self.process_path(path))
try:
self.list_files(path, files, dirs) # <-- line 176: unbounded recursion
except OSError:
continue
else:
files.append(self.process_path(path))
Git admite sin problema árboles de más de 1000 niveles, de modo que cualquiera con permiso para subir contenido a un repositorio que Weblate vigila puede introducir uno.
Pasos de reproducción
- Ejecutar el script de prueba que acompañaba al reporte, indicando la profundidad deseada:
``bash python3 poc_deepnest.py 1000 ``
- El script crea 1000 directorios anidados uno dentro de otro y coloca dos ficheros de traducción pequeños.
- A continuación llama a
discover()sobre ese árbol. - La ejecución termina con un
RecursionError. Estas son las líneas clave de la traza:
`` File "translation_finder/api.py", line 54, in discover finder = Finder(root, mock=mock) File "translation_finder/finder.py", line 73, in __init__ self.list_files(root, files, dirs) File "translation_finder/finder.py", line 176, in list_files self.list_files(path, files, dirs) [Previous line repeated 984 more times] File "translation_finder/finder.py", line 154, in process_path relative = path.relative_to(self.root) RecursionError: maximum recursion depth exceeded ``
Las mediciones del investigador marcan el umbral con claridad:
| Profundidad | Resultado | |---|---| | 960 | funciona | | 985 | funciona | | 990 | RecursionError | | 1000, 1050, 1100 | RecursionError |
El punto exacto varía entre unos 960 y 1000 niveles según la versión de Python y el tamaño de la pila, pero por encima de él el fallo se repite siempre.
Impacto
- La tarea de detección de Weblate muere cada vez que analiza el repositorio: en la importación, en las actualizaciones, al recibir un webhook o al procesar un pull request.
- Los reintentos no sirven: el árbol sigue ahí y cada intento vuelve a fallar, así que la actividad de traducción del componente afectado queda bloqueada.
- Con muchos repositorios preparados así se podría generar además un consumo de CPU acumulado.
El investigador calculó CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (3.7, baja) cuando hace falta que un administrador importe el repositorio, y lo sube a 5.5 (media) con PR:N en instalaciones donde el análisis lo disparan webhooks o pull requests.
Remediación
El reporte figura como resuelto, pero no detalla qué cambio aplicó Weblate. El investigador propuso como mejor opción sustituir la recursión por un recorrido iterativo (os.walk()) o fijar un límite de profundidad (en torno a 256). Como arreglo mínimo, capturar también RecursionError y saltar ese subárbol:
try:
self.list_files(path, files, dirs)
-except OSError:
+except (OSError, RecursionError):
continue
Qué aprender de este caso
- Cualquier función que recorra carpetas, ficheros comprimidos o estructuras anidadas llamándose a sí misma está a merced del contenido: busca en el código patrones como
self.list_files(path, ...)dentro de un bucle sobrescandir()y pregúntate quién controla la profundidad. - Cuando la entrada es un repositorio de Git, asume que su forma es hostil: Git acepta árboles de más de 1000 niveles, así que un límite "razonable" que nadie alcanzaría en la práctica es justo lo que un atacante probará.
- En Python, prefiere
os.walk()o un recorrido con pila explícita a la recursión, y si mantienes la recursión pon un tope de profundidad propio en lugar de depender del límite del intérprete. - Revisa qué excepciones se capturan en el bucle: aquí solo se contemplaba
OSError, y cualquier otra excepción tumbaba el análisis completo en vez de saltarse una sola carpeta.