Reports
MEDIA[CONFIGURACIÓN]#3825141

wlc, el cliente de Weblate, enviaba el token de $WLC_KEY a la URL que fijase un fichero .weblate no confiable

wlc tomaba la URL de la API de un fichero .weblate del directorio actual o de cualquier directorio superior, y le mandaba el token de $WLC_KEY sin comprobar el origen. Un .weblate malicioso bastaba para robar el token.

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. Para saber a qué servidor conectarse, lee dos tipos de configuración: la del usuario o global y, además, un fichero de proyecto (.weblate, .weblate.ini o weblate.ini). El problema estaba en cómo busca ese fichero de proyecto y en cómo decide qué token de API envía.

La función WeblateConfig.find_project_config busca el fichero en el directorio de trabajo y, si no lo encuentra, en todos los directorios superiores hasta la raíz. En cuanto encuentra uno, lo lee después de la configuración del usuario, así que su valor url tiene prioridad:

# wlc/config.py:67-94  @ 7c01e2f
@staticmethod
def find_project_config() -> str | None:
    """Find the nearest project configuration file."""
    cwd = os.path.abspath(".")
    prev = None
    while cwd != prev:                       # walk CWD and every ancestor
        for name in (".weblate", ".weblate.ini", "weblate.ini"):
            conf_name = os.path.join(cwd, name)
            if os.path.isfile(conf_name):
                return conf_name             # untrusted file controls [weblate] url
        prev = cwd
        cwd = os.path.dirname(cwd)
    return None

def load(self, path=None) -> None:
    if path:
        ...
    else:
        if config := self.find_config():           # user/global config
            self.read(config)
        if config := self.find_project_config():    # project config OVERRIDES url
            self.read(config)
    ...

El token, en cambio, se obtiene sin tener en cuenta de dónde salió la URL:

# wlc/config.py:101-113  @ 7c01e2f
def get_url_key(self) -> tuple[str, str]:
    url = (
        self.cli_url
        or os.environ.get("WLC_URL", "")
        or cast("str", self.get(self.section, "url"))   # <- from .weblate
    )
    key = (
        self.cli_key
        or os.environ.get("WLC_KEY", "")                # <- the secret, origin-unbound
        or cast("str", self.get("keys", url, fallback=""))
    )
    return url, key

Si el token está en la sección [keys], sí va asociado a una URL concreta (self.get("keys", url)): los mantenedores ya querían que cada clave solo se usase con su servidor, y el investigador relaciona ese endurecimiento con el CVE-2026-22251. Pero si existe la variable de entorno $WLC_KEY, que es lo que la propia documentación recomienda en integración continua, esa comprobación no se hace: el token se devuelve sea cual sea la URL, aunque venga de un .weblate ajeno. Después, invoke_request lo añade a la petición como Authorization: Token {self.key} (client.py:240-241) y lo envía a esa URL.

La función normalize_request_url no lo impide. Solo controla las URL que devuelve el servidor en relación con la URL base configurada, y aquí la URL base es precisamente la que ha puesto el atacante. En palabras del investigador, la garantía de que el token no salga hacia un origen inesperado existía para las URL que devuelve el servidor, pero no para la URL configurada.

Cualquier persona que pudiera colocar un .weblate en el camino de búsqueda podía redirigir el token: un pull request malicioso a un repositorio público, un repositorio clonado que no es de fiar o un fichero plantado en cualquier directorio superior.

Pasos de reproducción

  1. Clonar wlc, situarse en el commit afectado y preparar un entorno virtual con las dependencias:
git clone https://github.com/WeblateOrg/wlc && cd wlc
git checkout 7c01e2f5a5bed48e50a134c96951a5b212f446a4
python3 -m venv venv && . venv/bin/activate
pip install "requests>=2.32" "urllib3>=2.0" python-dateutil pyxdg argcomplete
  1. Ejecutar el script de prueba del investigador:
python3 c01_weblate_config_exfil.py

El reporte no incluye el código del script, pero sí describe lo que hace: crea un repositorio temporal con un .weblate controlado por el atacante que contiene url = http://127.0.0.1:9100/api/, entra con cd en ese repositorio, exporta la variable WLC_KEY y ejecuta wlc list-projects.

  1. Observar lo que recibe el servidor del atacante en 127.0.0.1:9100. La cabecera Authorization llega con el token de WLC_KEY (en la prueba era un valor de ejemplo; aquí se ha sustituido por [REDACTADO]):
attacker collector captured:
  Host header  : 127.0.0.1:9100
  Authorization: Token [REDACTADO]
RESULT: *** WLC_KEY LEAKED TO REPO-CONTROLLED URL ***

Impacto

  • Robo del token de API de Weblate por parte de cualquiera que pueda meter o modificar un fichero de configuración en el camino de búsqueda de la víctima.
  • Con ese token, el atacante tiene los mismos permisos que la víctima en la API de Weblate: leer y modificar traducciones, lanzar commit, push, pull y reset sobre los repositorios de control de versiones enlazados y borrar objetos. Si la cuenta tiene privilegios, también puede gestionar proyectos y componentes.
  • En integración continua el token es un secreto del pipeline. Perderlo puede afectar también a los repositorios de código a los que el proyecto de Weblate hace push.
  • Escenario realista: un proyecto público ejecuta wlc push o wlc pull en su CI, con WLC_KEY guardado como secreto y la URL sacada del repositorio descargado, que es el patrón documentado. El atacante abre un pull request que añade o modifica .weblate para que url apunte a su servidor. Si el runner es propio, también puede apuntar a cualquier máquina interna accesible. Cuando la CI ejecuta wlc, el token acaba en manos del atacante.

Datos del reporte: severidad media; debilidad clasificada como Information Disclosure; CVE que figura en los metadatos de HackerOne: CVE-2026-22251. Se envió el 25 de junio de 2026 y se divulgó el 4 de septiembre de 2026, con estado "resuelto". El reporte no detalla cómo se corrigió.