Reports
BAJA[EXPOSICIÓN DE DATOS]#4040149

wlc, el cliente de Weblate, deja descargas a medias y muestra trazas de Python con rutas locales

El cliente de línea de órdenes de Weblate no capturaba los errores del sistema de ficheros: una descarga de proyecto fallida dejaba archivos en disco sin decir qué se había escrito, y los errores salían como trazas de Python con rutas y usuario.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

wlc es el cliente de línea de órdenes de Weblate (https://github.com/WeblateOrg/wlc). La función main() de wlc/main.py tenía manejadores para WLCConfigurationError, WeblateDeniedError, RequestException y (CommandError, WeblateException), pero ninguno para OSError. Cualquier fallo al leer o escribir ficheros escapaba sin control, y de ahí salían dos problemas:

  • Descargas parciales sin aviso (CWE-755). wlc download <proyecto> escribe un archivo comprimido por componente dentro de un bucle. Si un componente falla después de que otros hayan ido bien, los ya escritos se quedan en el directorio de salida, la orden termina con código 1 y no dice qué se escribió y qué no. Un pipeline que publique ese directorio no puede saber si el resultado está completo.
  • Trazas completas en stderr (CWE-209). En lugar del mensaje de una línea Error: <mensaje> que usa el resto de fallos, un error de ficheros imprime la traza entera de Python, con rutas absolutas del equipo, el nombre del usuario (/home/<user>/...) y marcos internos del paquete.

El investigador lo comprobó en la versión 2.2.0 publicada en PyPI y en la rama main (commit 1b865157), como usuario sin privilegios y contra un servidor local que imitaba la API de Weblate. El código implicado:

  • Download.download_component() / _write_download_file() (hacia las líneas 211 y 219): path.parent.mkdir() y os.open() lanzan PermissionError o FileExistsError sin capturar.
  • Upload.run() (hacia las líneas 996 y 1004): open(self.args.input, "rb") lanza FileNotFoundError sin capturar.

En la rama main el código es el mismo, con las líneas desplazadas a 216 y 996. El caso de descargar traducciones con -o apuntando a un directorio existente ya estaba cubierto desde la 2.2.0: _write_download_file() lanza CommandError("Refusing to write downloaded file to non-regular path: ...").

Pasos de reproducción

En las órdenes, <url> y <key> son el endpoint de la API y el token; <project>/<component>/<lang> son objetos que existen en la instancia.

1. Descarga de proyecto que falla a medias

Preparación: un proyecto con dos componentes, en el que el endpoint /file/ del segundo devuelve HTTP 500.

  1. Vaciar el directorio de salida:
mkdir -p /tmp/dl && rm -f /tmp/dl/*
  1. Lanzar la descarga del proyecto:
wlc --url <url> --key <key> download -o /tmp/dl <project>
  1. Resultado observado:
$ wlc --url http://127.0.0.1:18080/api --key TESTKEY --allow-insecure-http download -o /tmp/dl proj
downloaded translations for component: proj/comp1
Error: HTTP error 500: Internal Server Error {'detail': 'boom'}
$ echo $?
1
$ ls -la /tmp/dl/
-rw-rw-r--  1 user user  217 Sep 18 09:28 proj-comp1.zip

Lo esperable sería un juego de archivos completo y validado, o un informe claro de qué componentes han ido bien y cuáles han fallado. En cambio, proj-comp1.zip se queda en disco, no aparece ningún resumen de lo escrito y el código de salida es 1. Los pasos encadenados con && se detienen, pero un paso que ignore el código de salida (o una nueva ejecución del pipeline) seguirá con ese archivo antiguo.

2. Directorio de salida sin permisos de escritura

  1. Crear un directorio de solo lectura:
mkdir /tmp/wlc_ro && chmod 555 /tmp/wlc_ro
  1. Descargar un componente dentro de él:
wlc --url <url> --key <key> download -o /tmp/wlc_ro/out <project>/<component>
  1. Lo esperable sería Error: [Errno 13] Permission denied: '/tmp/wlc_ro/out' en stderr y código de salida 1. Lo que sale es la traza completa, con varias rutas absolutas que incluyen el nombre de usuario, y después el código 1:
Traceback (most recent call last):
  File "/home/<user>/.local/bin/wlc", line 7, in <module>
    sys.exit(main())
  File "/home/<user>/.local/lib/python3.13/site-packages/wlc/main.py", line 1121, in main
    command.run()
  File "/home/<user>/.local/lib/python3.13/site-packages/wlc/main.py", line 219, in _write_download_file
    path.parent.mkdir(exist_ok=True, parents=True)
  File "/usr/lib/python3.13/pathlib/_local.py", line 724, in mkdir
    os.mkdir(self, mode)
PermissionError: [Errno 13] Permission denied: '/tmp/wlc_ro/out'

3. Ruta de salida que coincide con un fichero existente

En descargas de proyecto o componente, -o se trata como directorio sin comprobarlo antes.

  1. Crear un fichero normal con el nombre que se usará como salida:
touch out.zip
  1. Descargar un componente con esa salida:
wlc --url <url> --key <key> download -o out.zip <project>/<component>
  1. Lo esperable sería un Error: ... claro y código de salida 1. Lo que sale:
File ".../wlc/main.py", line 219, in _write_download_file
    path.parent.mkdir(exist_ok=True, parents=True)
File "/usr/lib/python3.13/pathlib/_local.py", line 724, in mkdir
    os.mkdir(self, mode)
FileExistsError: [Errno 17] File exists: 'out.zip'

4. Subida con un fichero de entrada que no existe

  1. Subir un fichero inexistente:
wlc --url <url> --key <key> upload -i /tmp/does_not_exist.po <project>/<component>/<lang>
  1. Lo esperable sería Error: ... y código de salida 1. Lo que sale:
File ".../wlc/main.py", line 1004, in run
    with open(self.args.input, "rb") as handle:
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/does_not_exist.po'

Impacto

  • Integridad del resultado en automatizaciones: cuando una descarga de proyecto falla, el directorio queda con una mezcla de archivos nuevos y antiguos, sin ninguna lista de lo escrito. En un pipeline de CI/CD que publique ese directorio, pueden llegar a producción paquetes de traducciones incompletos sin que nadie lo note, o el archivo de un componente se queda y se reutiliza en la ejecución siguiente aunque otro haya fallado.
  • Fuga de datos del entorno: las trazas escriben en stderr rutas absolutas y el nombre de usuario del operador. Donde stderr se guarda (logs de CI, journald, monitorización), esos datos acaban en sitios donde no deberían estar.

El reporte figura en HackerOne con severidad baja.

Remediación

El reporte figura como resuelto, pero no detalla qué arreglo se aplicó ni en qué versión. Lo que sigue son los tres cambios que propuso el investigador:

  1. Capturar OSError en main(), junto a los demás manejadores, con código de salida 1 (el mismo que CommandError; RequestException usa 10):
except OSError as error:
    print_stderr(f"Error: {error}")
    return 1
  1. En download_component(), comprobar la ruta de salida antes de llamar a _write_download_file(): si --output existe y no es un directorio, lanzar un CommandError breve. La comprobación que ya existía dentro de _write_download_file() se ejecuta después de mkdir y no cubre el caso del paso 3.
  2. En las descargas de proyecto, imprimir un resumen de los ficheros escritos, o escribir en una zona temporal y renombrar solo si todo ha ido bien.

Qué aprender de este caso

  • En herramientas de línea de órdenes, revisa qué excepciones captura el punto de entrada (main()): si hay manejadores para errores de red y de la API pero no para OSError, cualquier mkdir() u open() sobre una ruta del usuario puede soltar una traza completa.
  • Las órdenes que escriben varios ficheros en bucle (uno por componente, proyecto o idioma) deberían escribir en un directorio temporal y moverlo al final, o al menos listar lo escrito; si no, un fallo a mitad deja un resultado que parece válido.
  • Valida que la ruta de -o sea un directorio, o que no exista, antes de crear nada: una comprobación que solo corre después de path.parent.mkdir() llega tarde para el caso de un fichero con ese nombre.
  • En CI, no te fíes solo del código de salida si algún paso usa tee o lo ignora: vacía el directorio de salida antes de descargar para que no se cuelen archivos de ejecuciones anteriores.