Feb 21, 2026·4 min read·3 visits
The 'wlc' tool checked if a URL started with '127.0.0.1' to disable SSL. Attackers could register domains like '127.0.0.1.evil.com' to bypass encryption checks and steal API tokens.
A logic flaw in the Weblate command-line client (wlc) allowed attackers to bypass SSL verification by using specially crafted domain names that mimic local IP addresses. This vulnerability, stemming from a naive string prefix check, enables potential Man-in-the-Middle attacks if a developer is tricked into using a malicious configuration.
Developers love shortcuts. We hate setting up valid certificates for local development environments, so tools often contain a "dev mode" logic: if the target is localhost, skip the tedious SSL verification. It sounds reasonable until you realize that computers are pedantic literalists that will do exactly what you tell them, not what you mean.
CVE-2026-22250 is a textbook example of what happens when you treat network security like a string matching problem. It affects wlc, the Weblate command-line client, effectively turning its SSL validation into a polite suggestion rather than a rule. The vulnerability isn't a complex buffer overflow or a heap spray; it's a simple logical fallacy hidden in a few lines of Python that opened the door for Adversary-in-the-Middle (AiTM) attacks against developers.
The root cause here belongs in the Hall of Fame of Python pitfalls. The application needed to determine if a user was connecting to a local instance (e.g., 127.0.0.1) so it could relax security settings. However, the implementation relied on the startswith() string method rather than parsing the network address properly.
> [!NOTE] > In security, partial matches are almost always a vulnerability. "Close enough" counts in horseshoes and hand grenades, not in SSL verification.
The code checked if the URL's network location string began with "127.0.0.1". The problem? DNS doesn't care about your prefixes. The domain 127.0.0.1.attacker-site.com is a perfectly valid public domain that resolves to a remote server, yet it technically starts with the magic string. By confusing a string prefix with a network identity, wlc was tricked into trusting remote, malicious servers as if they were safe, local environments.
Let's look at the smoking gun in wlc/__init__.py. The vulnerable code attempts to parse the URL and check the netloc (network location, which includes domain and port).
# The Vulnerable Logic
LOCALHOST_NETLOC = "127.0.0.1"
@staticmethod
def _should_verify_ssl(path):
url = urlparse(path)
# THE BUG: Naive prefix check
is_localhost = url.netloc.startswith(LOCALHOST_NETLOC)
# If it looks like localhost, disable SSL verification
return url.scheme == "https" and (not is_localhost)If an attacker provides the URL https://127.0.0.1.evil.com/api, url.netloc becomes 127.0.0.1.evil.com. The check startswith("127.0.0.1") returns True. Consequently, _should_verify_ssl returns False. The client proceeds to connect to evil.com without validating the server's certificate, allowing the attacker to present any self-signed garbage certificate they want.
While the CVSS score is low (2.5) because it requires configuration manipulation, the impact on a compromised workflow is total. This isn't a remote exploit you fire blindly; it's a social engineering trap.
The Attack Scenario:
.wlcrc file pointing to https://127.0.0.1.intercept.xyz.wlc push to upload translation keys.wlc client sees the "local" prefix, disables SSL verification, and sends the developer's Weblate API token in plaintext (or over an unverified encrypted tunnel) to the attacker's server.Once the attacker has the API token, they have the same rights as the developer—potentially allowing them to overwrite translations, inject malicious scripts into localized content, or delete projects.
The remediation in version 1.17.0 is a lesson in defensive programming. The patch moves away from string slicing and adopts strict allow-listing. Instead of checking what the URL looks like, it checks exactly what it is.
# The Fixed Logic
# Explicit set of allowed local addresses
LOCALHOST_ADDRESSES = {"127.0.0.1", "localhost", "::1", "[::1]"}
@staticmethod
def should_verify_ssl(path: str) -> bool:
url = urlparse(path)
# FIX 1: Use .hostname to ignore ports
# FIX 2: Exact set membership check
return url.hostname not in LOCALHOST_ADDRESSESBy using url.hostname (which strips the port) and checking for membership in a strictly defined set (in LOCALHOST_ADDRESSES), the ambiguity is eliminated. 127.0.0.1.evil.com is simply not in the set, so SSL verification remains enforced.
CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:C/C:L/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
wlc Weblate | < 1.17.0 | 1.17.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-295 (Improper Certificate Validation) |
| CVSS v3.1 | 2.5 (Low) |
| Attack Vector | Local / Config Manipulation |
| Impact | Confidentiality (API Token Leak) |
| Exploit Status | None (No public PoC) |
| Patch Date | 2026-01-12 |
The software does not validate, or incorrectly validates, a certificate.