Resumen
translation-finder es la librería que usa Weblate para descubrir qué ficheros de traducción hay en un repositorio. Entre sus tareas está adivinar la codificación de cada fichero encontrado, y ahí estaba el fallo: el método EncodingDiscovery.detect_encoding(), en translation_finder/discovery/base.py, entregaba el descriptor del fichero completo a charset_normalizer.from_fp().
446 for path in chain(*matches):
447 if not isinstance(path, Path):
448 continue
449 with self.finder.open(path, "rb") as handle:
451 detection_result = from_fp(handle).best() # <-- fp.read() whole file
El problema es que from_fp() no lee una muestra, sino todo el contenido de golpe (charset_normalizer/api.py):
826 return from_bytes(
827 fp.read(), # <-- no limit
El resto de rutinas de "olfateo" de formato de la librería limitan la lectura a 1 MiB (FORMAT_SNIFF_MAX_BYTES en discovery/files.py), pero esta se quedó sin tope. Según las mediciones del investigador, el pico de memoria del proceso crece hasta unas tres veces el tamaño del fichero: un cs.properties de 128 MiB lleva el proceso a unos 403 MiB.
Datos del caso:
- Debilidad: CWE-770 (asignación de recursos sin límites).
- Producto:
WeblateOrg/translation-finder3.4.1, y también la ramamainen el commite94e820. - Entorno probado: Python 3.11 o superior, Linux x86_64, charset-normalizer 3.4.3 y 3.5.1.
- Desde dónde se llega: el addon de descubrimiento de componentes de Weblate, el flujo de "añadir componente" y la importación de configuración de Transifex. El descubrimiento se ejecuta tras cada actualización del repositorio (VCS).
- Fechas: enviado el 14 de septiembre de 2026 y divulgado el 16 de octubre de 2026. El reporte figura como resuelto.
Cuándo se lee un fichero entero
Basta con un único fichero que coincida con las máscaras de búsqueda:
- En modo normal, cualquier fichero con nombre de idioma, como
locale/cs.properties. Según el análisis del investigador, si existe un componente.propertiestambién se leen enteros ficheros de nombre genérico comodata.properties, porquedetect_encoding()recorre todas las coincidencias de la máscara. - En modo eager, cualquier fichero de formato conocido.
El coste se suma: from_fp se llama una vez por coincidencia, así que el mismo fichero de 16 MiB repetido en tres directorios supone 48 MiB leídos.
Pasos de reproducción
- Crear un entorno virtual e instalar la versión afectada:
python3 -m venv tfenv && source tfenv/bin/activate
pip install "translation-finder==3.4.1"
- Preparar un repositorio con un fichero de traducción normal y otro de 128 MiB:
mkdir -p /tmp/encpoc/locale
printf 'greeting=Hello\n' > /tmp/encpoc/locale/en.properties
python3 -c "open('/tmp/encpoc/locale/cs.properties','wb').write(b's=' * 67108864)"
- Lanzar el descubrimiento midiendo el pico de memoria residente:
/usr/bin/time -v python3 -c "from translation_finder.api import discover; discover('/tmp/encpoc')" 2>&1 | grep "Maximum resident"
El resultado esperado ronda los 400 MB de pico, frente a unos 12 MB sin el fichero grande.
- Para comprobar que un worker con memoria limitada cae, regenerar
cs.propertiescon 32 MiB (b's=' * 16777216, más rápido) y repetir con un límite de memoria virtual:
bash -c 'ulimit -v 90000; python3 -c "from translation_finder.api import discover; discover(\"/tmp/encpoc\")"' 2>&1 | tail -2
Debe terminar con un MemoryError dentro de charset_normalizer/api.py. Con ulimit -v 130000 el mismo comando acaba sin problemas.
Mediciones
El investigador midió el pico de memoria en un subproceso aislado (ru_maxrss), partiendo de una base de unos 11,8 MiB:
| Fichero | Pico de memoria | Incremento | Proporción | |---|---|---|---| | cs.properties de 32 MiB | 117 MiB | +105 MiB | 3,28× | | cs.properties de 64 MiB | 211 MiB | +199 MiB | 3,11× | | cs.properties de 128 MiB | 403 MiB | +391 MiB | 3,06× |
En una segunda verificación sobre main@e94e820 con charset-normalizer 3.5.1, el fichero de 32 MiB dio un pico de 116,5 MiB (+103,3 MiB, 3,23×), y la instrumentación de from_fp confirmó una sola llamada que leía exactamente el tamaño del fichero. Como contraste, un cs.json de 100 MiB procesado por la ruta que sí tiene tope (_read_binary_sniff_content) solo añadía unos 2 MiB.
Impacto
El daño es solo de disponibilidad: no se filtra ni se modifica información. Un blob de 100 MiB, que cabe dentro del límite de subida de GitHub, ya cuesta unos 300 MiB de memoria en cada escaneo, suficiente para que el sistema mate por falta de memoria a un contenedor de 512 MiB. En forjas autoalojadas se pueden subir ficheros bastante más grandes.
Como el descubrimiento se lanza solo tras cada actualización del repositorio, al atacante le basta con subir el fichero a un repositorio que Weblate vigile; no hace falta ninguna acción adicional.
El investigador propuso una puntuación CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H de 3,7 para los flujos de administración e importación, o de 5,5 con PR:N cuando los escaneos disparados por webhooks o pull requests permiten llegar a él a colaboradores sin privilegios. HackerOne lo clasifica como severidad media.
Remediación
El reporte figura como resuelto, pero no detalla el cambio aplicado. La corrección que propuso el investigador es leer solo una muestra acotada, igual que hacen los demás detectores de formato:
SNIFF_LIMIT = 1024 * 1024 # same as FORMAT_SNIFF_MAX_BYTES
with self.finder.open(path, "rb") as handle:
sample = handle.read(SNIFF_LIMIT)
detection_result = from_bytes(sample).best()
Como alternativa, sugirió reutilizar _read_binary_sniff_content() y _is_sniff_content_over_limit() para descartar directamente los ficheros demasiado grandes. También señaló que la lectura completa de from_fp() es el comportamiento normal de charset-normalizer, así que el arreglo corresponde a translation-finder y no a esa librería.
Qué aprender de este caso
- Si un código pasa a
charset_normalizer.from_fp()ficheros que llegan de fuera, revisa cuánto lee: la función hacefp.read()del fichero entero. Lee tú una muestra conread(N)y pásasela afrom_bytes(). - Cuando una librería ya tiene una constante de límite como
FORMAT_SNIFF_MAX_BYTES, busca todos los sitios que abren ficheros y comprueba que la usan; aquí el que se quedó fuera fue justo el que delegaba la lectura en una librería de terceros. - En servicios que procesan repositorios de forma automática tras cada push, como el descubrimiento de componentes de Weblate, el tamaño de los ficheros lo decide quien sube el contenido. Fija topes de tamaño antes de procesarlos y prueba el consumo de memoria con ficheros grandes, no solo con ficheros normales.