Weblate · Report #3887969
SSRF via URL Parser Differential in `normalize_request_url` (wlc)
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/:
| Parser | Used for | Resolved host |
|---|---|---|
urllib.parse.urlparse | Origin validation | 127.0.0.1 |
urllib3.util.parse_url (via requests) | Actual outbound connection | 169.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:
- Stand up a mock Weblate API server that, on a paginated listing endpoint, returns a
nextfield set tohttp://169.254.169.254\@127.0.0.1:8000/latest/meta-data/(or any other off-origin target for testing). - Point
wlcat this server:wlc --url http://<mock-server>/api/ list-projects. - Observe (e.g. via network capture or a listener on the target) that
wlcsends a request to the attacker-chosen host rather than the mock server's own host.
Suggested Remediation
- Validate using the same parser that performs the actual request. Either parse with
urllib3.util.parse_urlfor 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."
)
-
Reject requests to private/internal/link-local IP ranges (including
169.254.169.254) unless the configured API URL itself points there. -
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
wlcto, or can otherwise get the victim to pointwlcat 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.