CVE-2026-50127
Description
Weblate versions 5.15 to 2026.6 allowed bypass of private IP restrictions via transitional IPv6 ranges, enabling SSRF.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Weblate versions 5.15 to 2026.6 allowed bypass of private IP restrictions via transitional IPv6 ranges, enabling SSRF.
Vulnerability
Weblate versions 5.15 through 2026.6 improperly handled certain transitional IPv6 ranges, multicast addresses, and semi-private IPv4 ranges within its VCS_RESTRICT_PRIVATE setting. This allowed addresses that should have been restricted to bypass the checks, potentially enabling Server-Side Request Forgery (SSRF) attacks [2, 3].
Exploitation
An attacker could control the AAAA record of a hostname to point to a 6to4 or NAT64 wrapper of a private IPv4 address. By leveraging this, the attacker could bypass Weblate's outbound URL guard, which was intended to restrict access to private network resources [2].
Impact
Successful exploitation allows an attacker to make Weblate perform requests to arbitrary internal or external hosts, potentially leading to information disclosure, unauthorized access to internal services, or further compromise of the Weblate instance and its network [3].
Mitigation
This vulnerability was fixed in Weblate version 2026.6, released on June 1st, 2026 [1]. Users should upgrade to this version or later. No workarounds are specified in the available references.
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=5.15 <2026.6
Patches
1097866f9c34bfix(utils): harden private IP ranges detection (#19768)
3 files changed · +155 −2
docs/changes.rst+1 −0 modified@@ -26,6 +26,7 @@ Weblate 2026.6 .. rubric:: Bug fixes +* Outbound URL validation now rejects additional non-public targets. * Hardened :http:post:`/api/screenshots/` access checks against private project enumeration. * Registration-attempt account activity e-mails now link to password reset to help users finish account setup. * :ref:`invite-user` links now work for signed-in users whose account owns the invited e-mail address.
weblate/utils/outbound.py+77 −2 modified@@ -17,6 +17,23 @@ ".localhost", ) +# IPv6 transition prefixes whose addresses encode an IPv4 destination. On +# hosts where NAT64 translation is configured, the kernel routes packets sent +# to these addresses to the embedded IPv4 endpoint, so they must be unwrapped +# before consulting ipaddress.is_global - which classifies 64:ff9b::/96 as +# globally routable. +_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96") +_NAT64_LOCAL_USE_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48") +_SIXTOFOUR_PREFIX = ipaddress.IPv6Network("2002::/16") +_IPV4_COMPAT = ipaddress.IPv6Network("::0.0.0.0/96") +_NON_PUBLIC_SPECIAL_USE_PREFIXES: tuple[ + ipaddress.IPv4Network | ipaddress.IPv6Network, ... +] = ( + ipaddress.IPv4Network("192.88.99.0/24"), # 6to4 relay anycast + ipaddress.IPv6Network("5f00::/16"), # IPv6 Segment Routing + ipaddress.IPv6Network("2001:20::/28"), # ORCHIDv2 +) + def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: try: @@ -55,9 +72,67 @@ def _parse_hostname_ip( return ipaddress.IPv4Address(packed) +def _unwrap_ipv6_transition( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + """ + Return the embedded IPv4 destination for an IPv6 transition address. + + Covers IPv4-mapped IPv6 (``::ffff:0:0/96``), IPv4-compatible IPv6 + (``::0.0.0.0/96``, deprecated by RFC 4291 but still routable on hosts + that have not removed the configuration), and the well-known NAT64 prefix + (``64:ff9b::/96`` per RFC 6052). Returns the input unchanged when the + address does not embed an IPv4 destination. + + Without this unwrap, ``ipaddress.IPv6Address.is_global`` classifies + ``64:ff9b::/96`` as globally routable and the outbound-URL guard misses + these forms when an attacker supplies a hostname whose AAAA record points + at a wrapped private IPv4. + """ + if not isinstance(address, ipaddress.IPv6Address): + return address + if address.ipv4_mapped is not None: + return address.ipv4_mapped + if address in _NAT64_PREFIX: + return ipaddress.IPv4Address(address.packed[-4:]) + if address in _IPV4_COMPAT: + # ::N.N.N.N - skip the unspecified address (::), which is also + # technically inside this /96 but is not an embedded IPv4 wrapper. + embedded = ipaddress.IPv4Address(address.packed[-4:]) + if int(embedded) != 0: + return embedded + return address + + def _is_public_ip(value: str) -> bool: address = _parse_ip(value) - return address is not None and address.is_global + if address is None: + return False + return _is_global_address(address) + + +def _is_global_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> bool: + if address.is_multicast: + return False + for network in _NON_PUBLIC_SPECIAL_USE_PREFIXES: + if address.version == network.version and address in network: + return False + if isinstance(address, ipaddress.IPv6Address) and address in _SIXTOFOUR_PREFIX: + return False + if ( + isinstance(address, ipaddress.IPv6Address) + and address in _NAT64_LOCAL_USE_PREFIX + ): + # Legacy compatibility: Python before 3.12.4 classified this local-use + # NAT64 prefix as global. TODO: remove once support for those Python + # versions is dropped. + return False + unwrapped = _unwrap_ipv6_transition(address) + if unwrapped != address: + return _is_global_address(unwrapped) + return address.is_global def validate_runtime_ip(value: str, *, allow_private_targets: bool = True) -> None: @@ -99,7 +174,7 @@ def validate_untrusted_hostname( return if ip_address := _parse_hostname_ip(normalized): - if not ip_address.is_global: + if not _is_global_address(ip_address): raise ValidationError( gettext( "This URL is prohibited because it points to an internal or non-public address."
weblate/utils/tests/test_validators.py+77 −0 modified@@ -681,6 +681,20 @@ def test_validate_runtime_ip_rejects_shared_address_space(self) -> None: validate_runtime_ip("100.64.0.1", allow_private_targets=False) self.assertIn("internal or non-public address", str(error.exception)) + def test_validate_runtime_ip_rejects_special_use_ranges(self) -> None: + for label, address in ( + ("6to4 relay anycast", "192.88.99.1"), + ("IPv4 multicast", "224.0.0.1"), + ("IPv4 multicast documentation", "233.252.0.1"), + ("IPv6 Segment Routing", "5f00::1"), + ("ORCHIDv2", "2001:20::1"), + ("IPv6 multicast", "ff00::1"), + ): + with self.subTest(case=label, address=address): + with self.assertRaises(ValidationError) as error: + validate_runtime_ip(address, allow_private_targets=False) + self.assertIn("internal or non-public address", str(error.exception)) + @patch( "weblate.utils.outbound.socket.getaddrinfo", return_value=[(0, 0, 0, "", ("100.64.0.1", 443))], @@ -699,6 +713,69 @@ def test_validate_runtime_url_rejects_shared_address_space( "shared-address-space.example", None, type=1 ) + def test_validate_runtime_ip_rejects_ipv6_transition_wrappers(self) -> None: + """ + 6to4, NAT64 and IPv4-compatible wrappers must be rejected. + + On hosts where the kernel has NAT64 translation configured, traffic to + the NAT64 well-known prefix is forwarded to the embedded IPv4 endpoint. + """ + for label, wrapped_address in ( + ("6to4 IMDS", "2002:a9fe:a9fe::"), + ("6to4 ECS", "2002:a9fe:aa02::"), + ("6to4 loopback", "2002:7f00:1::"), + ("6to4 RFC1918", "2002:a00:1::"), + ("6to4 public IPv4", "2002:808:808::"), + ("NAT64 IMDS", "64:ff9b::a9fe:a9fe"), + ("NAT64 loopback", "64:ff9b::7f00:1"), + ("NAT64 RFC1918", "64:ff9b::a00:1"), + ("NAT64 multicast", "64:ff9b::e000:1"), + ("NAT64 6to4 relay anycast", "64:ff9b::c058:6301"), + ("IPv4-compat", "::a00:1"), + ("IPv4-compat multicast", "::e000:1"), + ("IPv4-compat 6to4 relay anycast", "::c058:6301"), + ): + with self.subTest(case=label, address=wrapped_address): + with self.assertRaises(ValidationError) as error: + validate_runtime_ip(wrapped_address, allow_private_targets=False) + self.assertIn("internal or non-public address", str(error.exception)) + + def test_validate_runtime_ip_rejects_nat64_local_use_suffix(self) -> None: + with self.assertRaises(ValidationError) as error: + validate_runtime_ip("64:ff9b:1::808:808", allow_private_targets=False) + self.assertIn("internal or non-public address", str(error.exception)) + + def test_validate_runtime_ip_permits_public_ipv6(self) -> None: + """ + Legitimate public IPv6 must still be accepted. + + The unwrap helper must not over-block. + """ + for address in ( + "2606:4700:4700::1111", # Cloudflare 1.1.1.1 + "2001:4860:4860::8888", # Google 8.8.8.8 + ): + with self.subTest(address=address): + validate_runtime_ip(address, allow_private_targets=False) + + @patch( + "weblate.utils.outbound.socket.getaddrinfo", + return_value=[(0, 0, 0, "", ("2002:a9fe:a9fe::", 0, 0, 0))], + ) + def test_validate_runtime_url_rejects_6to4_imds(self, mocked_getaddrinfo) -> None: + """ + Hostnames resolving to 6to4 metadata wrappers must be rejected. + + When a hostname's AAAA record resolves to a 6to4 wrapper of the + AWS / GCP / Azure / Oracle metadata IPv4, validate_runtime_url must + reject it. + """ + with self.assertRaises(ValidationError) as error: + validate_runtime_url( + "https://attacker.example", allow_private_targets=False + ) + self.assertIn("internal or non-public address", str(error.exception)) + class RepoURLValidationTestCase(SimpleTestCase): def test_file_rejected(self):
Vulnerability mechanics
Root cause
"The outbound URL guard incorrectly classified certain IPv6 transition mechanisms as globally routable."
Attack vector
An attacker who controls a hostname's AAAA record can point it to a 6to4 or NAT64 wrapper of a private IPv4 address. This allows them to bypass the outbound URL guard, which is intended to restrict access to private ranges for VCS_RESTRICT_PRIVATE [ref_id=1]. The vulnerability affects versions 5.15 to before 2026.6.
Affected code
The vulnerability lies within the outbound URL guard logic in `weblate/utils/outbound.py`. Specifically, the use of `ipaddress.IPv6Address.is_global` to determine if an address is publicly routable was insufficient. The patch modifies this section to incorporate the new `_unwrap_ipv6_transition` helper function.
What the fix does
The patch introduces a helper function `_unwrap_ipv6_transition` to extract the embedded IPv4 destination from IPv4-mapped, 6to4, and NAT64 prefixes. This function is called before consulting `ipaddress.is_global`, ensuring that addresses tunneled over these mechanisms are correctly identified and rejected if they lead to private IPv4 ranges [patch_id=5507170]. This prevents the bypass of outbound URL restrictions.
Preconditions
- configThe VCS_RESTRICT_PRIVATE setting must be configured.
- inputThe attacker must control a hostname's AAAA record.
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.