Resumen
wlc es el cliente de línea de comandos de Weblate. Para impedir que un servidor le haga seguir URL de otros dominios, antes de cada petición comprueba que la URL de destino tiene el mismo origen (esquema, host, puerto) que la API configurada. El problema es que esa comprobación y la petición real no usan el mismo analizador de URL:
- La validación (
normalize_request_urlyget_originenwlc/client.py) usaurllib.parse.urlparse, de la biblioteca estándar de Python. - La conexión la abre
requests, que por dentro usa el analizador deurllib3.
Ambos interpretan de forma distinta una barra invertida (\) colocada justo antes de una @. Para urlparse, todo lo anterior a la @ son credenciales de usuario y el host es lo que va detrás. Para urllib3, la barra invertida marca el inicio de la ruta, así que el host es lo que va antes. Con una URL construida así, la validación ve el servidor legítimo mientras la conexión va a un host que elige el atacante.
get_origin calcula la tupla que se compara:
@classmethod
def get_origin(cls, url: ParseResult) -> Origin:
"""Return normalized origin tuple for a parsed URL."""
return (url.scheme, url.hostname, cls.get_effective_port(url))
La vía de entrada es list_factory, que recorre los listados paginados siguiendo el campo next que devuelve el servidor sin sanearlo más allá de esa comprobación de origen:
def list_factory(
self,
path: str,
parser: type[LazyObjectT],
params: RequestPayload | None = None,
) -> Iterator[LazyObjectT]:
"""Listing object wrapper."""
while path is not None:
data = self.get(path, params=params)
params = None
if isinstance(data, list):
for item in data:
yield parser(weblate=self, **item)
break
for item in data["results"]:
yield parser(weblate=self, **item)
path = data["next"]
self.get() llama a self.request(), que pasa la ruta por normalize_request_url antes de enviarla con requests:
def normalize_request_url(self, path: str) -> str:
"""Resolve a request path and reject cross-origin targets."""
url = urlparse(urljoin(self.url, path))
if self.get_origin(url) != self.api_origin:
raise WeblateException(
"Server returned a URL outside the configured API origin."
)
return url.geturl()
Con la URL http://169.254.169.254\@127.0.0.1:8000/latest/meta-data/ cada analizador llega a una conclusión distinta:
| Analizador | Para qué se usa | Host resultante | |---|---|---| | urllib.parse.urlparse | Validación del origen | 127.0.0.1 | | urllib3.util.parse_url (vía requests) | Conexión real | 169.254.169.254 |
Comparar también el puerto no evita el bypass: urlparse toma host y puerto del fragmento posterior a la barra invertida, así que el origen calculado (http, 127.0.0.1, 8000) coincide exactamente con el de la API configurada y la comprobación se supera.
El investigador revisó el commit d0ddc3d2c16b727d4f8ec2b576d0b7d209a53770 del repositorio WeblateOrg/wlc y propuso una puntuación CVSS 3.1 de 9.3 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N). El programa la clasificó como severidad alta. El reporte se envió el 24 de julio de 2026 y se hizo público el 2 de septiembre de 2026.
Pasos de reproducción
Comprobación de la discrepancia entre analizadores (verificada por el investigador):
- Ejecuta este script, que simula lo que ocurre con una URL
nextmaliciosa respecto a una API enhttp://127.0.0.1:8000/api/:
from urllib.parse import urlparse, urljoin
from urllib3.util import parse_url
BASE_URL = "http://127.0.0.1:8000/api/"
MALICIOUS_NEXT = "http://169.254.169.254\\@127.0.0.1:8000/latest/meta-data/"
joined = urljoin(BASE_URL, MALICIOUS_NEXT)
# What normalize_request_url's validation sees:
parsed = urlparse(joined)
print("urlparse hostname (validation):", parsed.hostname) # 127.0.0.1
# What urllib3 / requests actually connects to:
u3 = parse_url(joined)
print("urllib3 host (actual request):", u3.host) # 169.254.169.254
- Comprueba que la validación obtiene
127.0.0.1yurllib3obtiene169.254.169.254. - Para confirmarlo en la capa de
requests, construye unPreparedRequestcon esa misma URL e inspecciona el destino del pool de conexiones. El investigador obtuvo:
prepared url: http://169.254.169.254/%[email protected]:8000/...
connection pool host: 169.254.169.254 port: 80
Es decir, la conexión TCP se abre contra el host del atacante y no contra el que pasó la validación.
Reproducción de extremo a extremo (propuesta por el investigador, pero no ejecutada en el reporte):
- Levanta un servidor que imite la API de Weblate y que, en un listado paginado, devuelva en
nextel valorhttp://169.254.169.254\@127.0.0.1:8000/latest/meta-data/(o cualquier otro destino fuera del origen). - Apunta
wlca ese servidor:
wlc --url http://<mock-server>/api/ list-projects
- Observa con una captura de red, o con un listener en el destino, que
wlcenvía la petición al host elegido por el atacante y no al servidor simulado.
Impacto
Quien controle o haya comprometido un servidor Weblate, o consiga que la víctima configure wlc contra un servidor que no es de fiar, puede hacer que el cliente envíe peticiones HTTP a cualquier host al que llegue la máquina donde se ejecuta wlc. Entre ellos:
- Servicios accesibles solo desde la red interna.
- Los endpoints de metadatos de instancias en la nube (por ejemplo, IMDS de AWS en
169.254.169.254, o los equivalentes de GCP y Azure), que pueden exponer credenciales IAM o datos de la instancia. - Cualquier otro host alcanzable desde esa máquina.
Con esto se anula precisamente la validación de origen que wlc implementa para evitar este tipo de ataque. Según el investigador, la discrepancia no depende de la configuración usada en la prueba y afecta tanto a HTTP como a HTTPS.
Remediación
El reporte figura como resuelto, pero no explica qué cambio se aplicó. El investigador propuso:
- Validar con el mismo analizador que hace la petición: usar
urllib3.util.parse_urlpara comprobar el origen, o exigir que ambos analizadores coincidan en el host antes de seguir:
from urllib3.util import parse_url
@staticmethod
def _validate_url_parser_consistency(url: ParseResult) -> None:
"""Guard against URL parser differentials between stdlib and urllib3."""
u3_url = parse_url(url.geturl())
if u3_url.host != url.hostname:
raise WeblateException(
"Server returned a URL that resolves differently across parsers."
)
- Rechazar peticiones a rangos privados, internos o link-local (incluido
169.254.169.254), salvo que la propia URL de la API configurada apunte ahí. - Denegar por defecto: permitir solo el nombre de host exacto de la API configurada en lugar de comparar tuplas de origen, algo más resistente a ambigüedades entre analizadores.