Reports
BAJA[DENEGACIÓN DE SERVICIO]#4044441

Weblate: un nombre de fichero manipulado tumba el descubrimiento de traducciones de translation-finder

translation-finder metía parte del nombre de cada fichero en la cadena de reemplazo de re.sub. Un nombre como x\g<99>-en.po lanzaba una excepción sin capturar y detenía el descubrimiento de todo el repositorio.

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

Weblate se apoya en el paquete translation-finder para localizar los ficheros de traducción de un repositorio. Ese paquete tenía un fallo de neutralización en BaseDiscovery.get_masks(), dentro de translation_finder/discovery/base.py (líneas 388-395 aproximadamente, versión 3.5.0): un fragmento derivado del nombre del fichero acababa dentro de la cadena de reemplazo de re.sub.

El detalle importante es que esa cadena no es texto plano. El módulo re de Python busca en ella referencias a grupos (\g<...>) y secuencias de escape. Si el nombre del fichero contiene una barra invertida, Python la interpreta como sintaxis de sustitución en lugar de tratarla como un carácter más.

Este es el código afectado:

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
            )

Fíjate en la asimetría: el patrón de búsqueda sí pasa por re.escape(), pero wildcard, que sale de get_wildcard(), se interpola sin protección en el reemplazo.

Con el fichero x\g<99>-en.po, el comodín resultante es \g<99>-*. Como no existe un grupo 99, re.sub falla:

re.error: invalid group reference 99 at position 9

Con x\X-en.po el error es otro, porque \X no es un escape válido:

re.error: bad escape \X at position 6

Nada en el flujo de descubrimiento captura esta excepción. Sube por discover() y termina con el proceso para el repositorio entero, no solo para el fichero problemático.

El reporte se envió el 20 de septiembre de 2026 y se divulgó el 30 de octubre de 2026. El investigador indica que, a fecha del envío, no había CVE asignado ni aviso publicado por el proyecto.

Pasos de reproducción

  1. Crea un repositorio de prueba con un fichero de traducción cuyo nombre incluya la secuencia manipulada:
mkdir -p poc/locales
printf 'msgid "x"\nmsgstr "y"\n' > 'poc/locales/x\g<99>-en.po'
  1. Lanza el descubrimiento sobre ese directorio:
weblate-discover poc
  1. Comprueba el resultado. El comando termina con código de salida 1 y esta traza:
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

Estos nombres provocaron el fallo cuando el fichero estaba en el árbol analizado:

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                        invalid group reference 99
x\g<99>_en.po                        invalid group reference 99
x\g<99>-pt_BR.po                     invalid group reference 99
x\g<99>.en.json                      invalid group reference 99
x\g<99>-en.ts                        invalid group reference 99
x\g<99>-en.yaml                      invalid group reference 99

Los tres últimos muestran que el problema alcanza también a otros backends, no solo a los .po.

Estos otros, en cambio, no fallan:

x-en.po
x\g<0>-en.po     (el grupo 0 existe)
x\n-en.po        (escape válido)

Basta con que el fichero aparezca al listar el repositorio. Su contenido no se llega a leer, así que filtrar por contenido no serviría de nada.

Impacto

Quien pueda hacer commits en un repositorio que Weblate analiza puede dejar roto su descubrimiento de traducciones con un solo nombre de fichero. El reporte sitúa ese acceso de commit en la frontera de confianza habitual al importar un componente.

El bloqueo es persistente: el trabajo no se recupera hasta que se elimina el fichero. El efecto se limita a la disponibilidad, ya que el investigador descarta tanto la ejecución de código como la lectura de ficheros.

Remediación

El investigador propuso dejar de interpolar el comodín en la cadena de reemplazo y pasar a re.sub una función, cuyo valor de retorno se usa de forma literal:

mask_parts[i] = match.sub(
    lambda m: f"{m.group(1)}{wildcard}{m.group(2)}",
    current,
)

Según el reporte, en una copia parcheada todos los nombres anteriores se procesaron sin error, y la salida normal no cambió: x-en.po sigue dando locales/x-*.po.

Como defensa adicional, sugirió envolver la sustitución en un try/except re.error para saltarse el nombre conflictivo, rechazar durante el recorrido los nombres de traducción con barras invertidas u otros caracteres no imprimibles, o ambas cosas.

El reporte consta como resuelto, pero el texto divulgado no detalla qué cambio se aplicó finalmente.

Qué aprender de este caso

  • Si auditas código Python, busca re.sub o Pattern.sub con un f-string como reemplazo: cualquier variable interpolada ahí puede inyectar \g<...> o escapes inválidos, aunque el patrón esté bien protegido con re.escape().
  • re.escape() protege el patrón, no el reemplazo. Para insertar texto no confiable en una sustitución, usa una función como reemplazo, igual que la lambda de este caso, porque su resultado no se interpreta.
  • Los nombres de fichero también son entrada del atacante. En herramientas que recorren repositorios de terceros, prueba nombres con barras invertidas y otros caracteres especiales, no solo contenidos maliciosos.
  • Cuando un proceso trabaja por lotes, aísla el error de cada elemento: aquí una excepción sin capturar provocada por un único fichero detuvo el análisis de todo el repositorio, cuando bastaba con saltárselo.