HexBrain

Weblate · Report #3887969

SSRF via URL Parser Differential in `normalize_request_url` (wlc)

Validstatus
highseverity
43upvotes
2 Sept 2026disclosed

How it was found

Target: https://github.com/WeblateOrg/wlc Weakness: CWE-918 (Server-Side Request Forgery) Suggested Severity: Critical CVSS 3.1 Vector: AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N (9.3) File / Function: wlc/client.py — normalize_request_url, get_origin, list_factory Commit reviewed: d0ddc3d2c16b727d4f8ec2b576d0b7d209a53770 (2026-06-30 11:36:12 +0200)


Summary

wlc validates that outgoing API requests stay on the configured server's origin by parsing URLs with urllib.parse.urlparse and comparing (scheme, hostname, port) against the known-good origin. The actual HTTP request, however, is dispatched by the requests library, which relies on urllib3's own URL parser internally.

These two parsers disagree on how to handle a backslash placed before an @ character in a URL. urlparse treats everything before the backslash as part of the userinfo component and correctly extracts the real host after @. urllib3's parser instead treats the backslash as a path separator, so it extracts the first token as the host.

A malicious or compromised Weblate server can exploit this by returning a paginated API next URL crafted with this pattern. The origin check in normalize_request_url passes (because urlparse resolves it to the legitimate host), but the actual outbound request is sent to an attacker-chosen host instead.

Impact

An attacker who controls, or has compromised, a Weblate server — or who can get a user to point wlc at a malicious/untrusted server URL — can force the wlc client to issue HTTP requests to arbitrary hosts, including:

  • Internal-network-only services otherwise unreachable from outside
  • Cloud instance metadata endpoints (e.g. AWS IMDS at 169.254.169.254, GCP, Azure), potentially exposing IAM credentials or instance metadata
  • Any other host reachable from the machine running wlc

This bypasses the origin/host validation that wlc explicitly implements to prevent exactly this class of attack.

Technical Analysis

get_origin computes a normalized (scheme, hostname, port) tuple used for the comparison:

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

normalize_request_url uses this to reject any server-returned path/URL that doesn't match the configured API's origin:

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

list_factory is the code path that feeds attacker-controlled data into this check — it follows the next field from paginated API responses without any additional sanitization:

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() calls into self.request(), which calls normalize_request_url(path) before issuing the actual request via requests.

The parser disagreement, for a URL such as http://169.254.169.254\@127.0.0.1:8000/latest/meta-data/:

ParserUsed forResolved host
urllib.parse.urlparseOrigin validation127.0.0.1
urllib3.util.parse_url (via requests)Actual outbound connection169.254.169.254

Because get_origin compares the full (scheme, hostname, port) tuple — not just the hostname — this was independently re-verified to confirm the port component doesn't defeat the bypass: urlparse resolves both host and port from the segment after the backslash, so the computed origin (http, 127.0.0.1, 8000) matches the real API origin exactly, and the check passes.

Proof of Concept

Verified component-level (confirmed):

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

This was additionally confirmed at the requests layer (not just raw urllib3), by building a PreparedRequest for the same URL and inspecting the resulting connection-pool target:

prepared url:          http://169.254.169.254/%[email protected]:8000/...
connection pool host:  169.254.169.254   port: 80

This confirms the host requests actually opens a TCP connection to is the attacker-controlled one, not the one that passed origin validation.

Not yet verified end-to-end: the above confirms the parser differential and validation bypass at the component level (urlparse/urllib3/requests behavior in isolation). A full end-to-end run — a live/mock malicious Weblate server returning this next value, driving an actual wlc list-projects invocation, with the outbound request observed on the wire — has not been performed as part of this report. Steps to reproduce end-to-end:

  1. Stand up a mock Weblate API server that, on a paginated listing endpoint, returns a next field set to http://169.254.169.254\@127.0.0.1:8000/latest/meta-data/ (or any other off-origin target for testing).
  2. Point wlc at this server: wlc --url http://<mock-server>/api/ list-projects.
  3. Observe (e.g. via network capture or a listener on the target) that wlc sends a request to the attacker-chosen host rather than the mock server's own host.

Suggested Remediation

  1. Validate using the same parser that performs the actual request. Either parse with urllib3.util.parse_url for the origin check, or cross-check that both parsers agree on the resolved host before proceeding:
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. Reject requests to private/internal/link-local IP ranges (including 169.254.169.254) unless the configured API URL itself points there.

  2. Consider a deny-by-default model: only allow the exact configured API hostname rather than origin-tuple matching, which is more resistant to parser-level ambiguity in general.

Assumptions

  • The attacker controls or has compromised a Weblate server the victim connects wlc to, or can otherwise get the victim to point wlc at an untrusted server URL.
  • Default local-dev configuration (http://127.0.0.1:8000/api/) was used for the component-level PoC; the differential is not dependent on this and applies to both HTTP and HTTPS.
  • End-to-end network-level confirmation (see PoC section) is left for the report reviewer to reproduce or request, and was not independently observed by the reporter beyond the component-level verification described above.

Impact

An attacker who controls, or has compromised, a Weblate server — or who can get a user to point wlc at a malicious/untrusted server URL — can force the wlc client to issue HTTP requests to arbitrary hosts, including:

  • Internal-network-only services otherwise unreachable from outside
  • Cloud instance metadata endpoints (e.g. AWS IMDS at 169.254.169.254, GCP, Azure), potentially exposing IAM credentials or instance metadata
  • Any other host reachable from the machine running wlc

This bypasses the origin/host validation that wlc explicitly implements to prevent exactly this class of attack.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original