Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package weblate for openSUSE:Factory checked 
in at 2026-06-17 16:17:53
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/weblate (Old)
 and      /work/SRC/openSUSE:Factory/.weblate.new.1981 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "weblate"

Wed Jun 17 16:17:53 2026 rev:35 rq:1359749 version:5.17.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/weblate/weblate.changes  2026-06-01 
18:08:02.044328645 +0200
+++ /work/SRC/openSUSE:Factory/.weblate.new.1981/weblate.changes        
2026-06-17 16:18:32.827635360 +0200
@@ -1,0 +2,11 @@
+Mon Jun 15 12:27:58 UTC 2026 - Markéta Machová <[email protected]>
+
+- CVE-2026-45106: stored HTML injection in editor search preview
+  (bsc#1268130)
+  * CVE-2026-45106.patch
+- CVE-2026-50127: VCS_RESTRICT_PRIVATE did not properly account for
+  some transitional IPv6 ranges, multicast addresses, or some
+  semi-private IPv4 ranges (bsc#1268134)
+  * CVE-2026-50127.patch
+
+-------------------------------------------------------------------

New:
----
  CVE-2026-45106.patch
  CVE-2026-50127.patch

----------(New B)----------
  New:  (bsc#1268130)
  * CVE-2026-45106.patch
- CVE-2026-50127: VCS_RESTRICT_PRIVATE did not properly account for
  New:  semi-private IPv4 ranges (bsc#1268134)
  * CVE-2026-50127.patch
----------(New E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ weblate.spec ++++++
--- /var/tmp/diff_new_pack.FGGghZ/_old  2026-06-17 16:18:33.951682391 +0200
+++ /var/tmp/diff_new_pack.FGGghZ/_new  2026-06-17 16:18:33.951682391 +0200
@@ -37,6 +37,10 @@
 # skip failing test_ocr and test_ocr_backend
 # most probably some issue on our side
 Patch:          skip-test_ocr.patch
+# PATCH-FIX-UPSTREAM CVE-2026-45106.patch bsc#1268130
+Patch:          CVE-2026-45106.patch
+# PATCH-FIX-UPSTREAM CVE-2026-50127.patch bsc#1268134
+Patch:          CVE-2026-50127.patch
 BuildRequires:  bitstream-vera
 BuildRequires:  borgbackup >= 1.4.0
 BuildRequires:  fdupes

++++++ CVE-2026-45106.patch ++++++
>From 8b0adf1d0b43dfc0d09da4b878857b2288b84f2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= <[email protected]>
Date: Thu, 7 May 2026 16:00:54 +0200
Subject: [PATCH] fix(client): safer HTML generating

- The URL should be sane and provided by Weblate, but add basic
  sanitization to make sure that we don't generate undesired links.
- Construct HTML in a way that XSS is not possible.
- Coverted the search preview from jQuery.
- Use CSS class for the results as there can be more of them.
---
 docs/changes.rst                      |   1 +
 weblate/static/editor/full.js         |  10 +-
 weblate/static/editor/tools/search.js | 160 ++++++++++++++++----------
 weblate/static/js/urls.js             |  51 ++++++++
 weblate/static/styles/main.css        |   6 +-
 weblate/templates/base.html           |   1 +
 6 files changed, 161 insertions(+), 68 deletions(-)
 create mode 100644 weblate/static/js/urls.js

diff --git a/weblate/static/editor/full.js b/weblate/static/editor/full.js
index c247b21aeb73..9f45633811f7 100644
--- a/weblate/static/editor/full.js
+++ b/weblate/static/editor/full.js
@@ -749,13 +749,17 @@
       if (typeof el.origin !== "undefined") {
         service.append(" (");
         let origin;
-        const _deleteUrl = false;
         if (typeof el.origin_detail !== "undefined") {
           origin = $("<abbr/>").text(el.origin).attr("title", 
el.origin_detail);
         } else if (typeof el.origin_url !== "undefined") {
-          origin = $("<a/>").text(el.origin).attr("href", el.origin_url);
+          const originUrl = WLT.URLs.getHttpUrl(el.origin_url);
+          if (originUrl === null) {
+            origin = document.createTextNode(String(el.origin));
+          } else {
+            origin = $("<a/>").text(el.origin).attr("href", originUrl);
+          }
         } else {
-          origin = el.origin;
+          origin = document.createTextNode(String(el.origin));
         }
         if (el.delete_url) {
           this.state.weblateTranslationMemory.add(el.text);
diff --git a/weblate/static/editor/tools/search.js 
b/weblate/static/editor/tools/search.js
index 3d73e5ae3aa2..a791c61b3c42 100644
--- a/weblate/static/editor/tools/search.js
+++ b/weblate/static/editor/tools/search.js
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: GPL-3.0-or-later
 
-$(document).ready(() => {
+document.addEventListener("DOMContentLoaded", () => {
   searchPreview("#replace", "#id_replace_q");
   searchPreview("#bulk-edit", "#id_bulk_q");
   searchPreview("#addon-form", "#id_bulk_q");
@@ -15,20 +15,27 @@ $(document).ready(() => {
    *
    */
   function searchPreview(searchForm, searchElement) {
-    const $searchForm = $(searchForm);
-    const $searchElement = $searchForm.find(searchElement);
+    const form = document.querySelector(searchForm);
+    const searchInput = form?.querySelector(searchElement);
+
+    if (!form || !searchInput) {
+      return;
+    }
 
     // Create the preview element
-    const $searchPreview = $('<div id="search-preview"></div>');
-    $searchElement.parent().parent().parent().after($searchPreview);
+    const searchPreview = document.createElement("div");
+    searchPreview.id = "search-preview";
+    searchInput.parentElement?.parentElement?.parentElement?.after(
+      searchPreview,
+    );
 
     let debounceTimeout = null;
 
     // Update the preview while typing with a debounce of 300ms
-    $searchElement.on("input", () => {
-      $searchPreview.show();
-      const userSearchInput = $searchElement.val();
-      const searchQuery = buildSearchQuery($searchElement);
+    searchInput.addEventListener("input", () => {
+      searchPreview.style.display = "block";
+      const userSearchInput = searchInput.value;
+      const searchQuery = buildSearchQuery(searchInput);
 
       // Clear the previous timeout to prevent the previous
       // request since the user is still typing
@@ -37,56 +44,72 @@ $(document).ready(() => {
       // fetch search results but not too often
       debounceTimeout = setTimeout(() => {
         if (userSearchInput) {
-          $.ajax({
-            url: "/api/units/",
-            method: "GET",
-            data: { q: searchQuery },
-            success: (response) => {
+          const url = `/api/units/?${new URLSearchParams({
+            q: searchQuery,
+          }).toString()}`;
+          fetch(url, {
+            headers: {
+              Accept: "application/json",
+              "X-Requested-With": "XMLHttpRequest",
+            },
+          })
+            .then((response) => {
+              if (!response.ok) {
+                return null;
+              }
+              return response.json();
+            })
+            .then((response) => {
+              if (response === null) {
+                return;
+              }
               // Clear previous search results
-              $searchPreview.html("");
-              $("#results-num").remove();
+              searchPreview.replaceChildren();
+              searchPreview.querySelector("#results-num")?.remove();
               const results = response.results;
               if (!results || results.length === 0) {
-                $searchPreview.text(gettext("No results found"));
+                searchPreview.textContent = gettext("No results found");
               } else {
                 showResults(results, response.count, searchQuery);
               }
-            },
-          });
+            });
         }
       }, 300); // If the user stops typing for 300ms, the search results will 
be fetched
     });
 
     // Show the preview on focus
-    $searchElement.on("focus", () => {
-      if ($searchElement.val() !== "" && $searchPreview.html() !== "") {
-        $searchPreview.show();
-        $("#results-num").show();
+    searchInput.addEventListener("focus", () => {
+      if (searchInput.value !== "" && searchPreview.innerHTML !== "") {
+        searchPreview.style.display = "block";
+        const resultsNumber = searchPreview.querySelector("#results-num");
+        if (resultsNumber) {
+          resultsNumber.style.display = "";
+        }
       }
     });
 
     // Close the preview on form submit, form reset, and form clear
     // or if there is no search query
-    $searchForm.on("input", () => {
-      if ($searchElement.val() === "") {
-        $searchPreview.hide();
-        $("#results-num").remove();
+    form.addEventListener("input", () => {
+      if (searchInput.value === "") {
+        searchPreview.style.display = "none";
+        searchPreview.querySelector("#results-num")?.remove();
       }
     });
-    $searchForm.on("submit", () => {
-      $searchPreview.html("");
-      $searchPreview.hide();
-      $("#results-num").remove();
+    form.addEventListener("submit", () => {
+      searchPreview.replaceChildren();
+      searchPreview.style.display = "none";
+      searchPreview.querySelector("#results-num")?.remove();
     });
-    $searchForm.on("reset", () => {
-      $searchPreview.html("");
-      $searchPreview.hide();
-      $("#results-num").remove();
+    form.addEventListener("reset", () => {
+      searchPreview.replaceChildren();
+      searchPreview.style.display = "none";
+      searchPreview.querySelector("#results-num")?.remove();
     });
-    $searchForm.on("clear", () => {
-      $searchPreview.html("");
-      $("#results-num").remove();
-      $searchPreview.hide();
+    form.addEventListener("clear", () => {
+      searchPreview.replaceChildren();
+      searchPreview.querySelector("#results-num")?.remove();
+      searchPreview.style.display = "none";
     });
 
     /**
@@ -103,30 +126,44 @@ $(document).ready(() => {
           ngettext("%s matching string", "%s matching strings", count),
           [count],
         );
-        const searchUrl = `/search/?q=${encodeURI(searchQuery)}`;
-        const resultsNumber = `<a href="${searchUrl}" target="_blank" 
rel="noopener noreferrer" id="results-num">${t}</a>`;
-        $searchPreview.append(resultsNumber);
+        const searchUrl = `/search/?${new URLSearchParams({
+          q: searchQuery,
+        }).toString()}`;
+        const resultsNumber = document.createElement("a");
+        resultsNumber.setAttribute("href", searchUrl);
+        resultsNumber.target = "_blank";
+        resultsNumber.rel = "noopener noreferrer";
+        resultsNumber.id = "results-num";
+        resultsNumber.textContent = t;
+        searchPreview.append(resultsNumber);
       } else {
-        $("#results-num").remove();
+        searchPreview.querySelector("#results-num")?.remove();
       }
 
       for (const result of results) {
         const key = result.context;
         const source = result.source;
+        const url = WLT.URLs.getLocalPath(result.web_url);
+
+        if (url === null) {
+          continue;
+        }
+
+        const resultElement = document.createElement("a");
+        resultElement.setAttribute("href", url);
+        resultElement.target = "_blank";
+        resultElement.className = "search-result";
+        resultElement.rel = "noopener noreferrer";
+
+        const keyElement = document.createElement("small");
+        keyElement.textContent = String(key);
+        resultElement.append(keyElement);
+
+        const sourceElement = document.createElement("div");
+        sourceElement.textContent = String(source);
+        resultElement.append(sourceElement);
 
-        // Make the URL relative
-        // TODO: is this regexp really needed?
-        const url = result.web_url.replace(/^[a-zA-Z]+:\/\/[^/]+\//, "/");
-        const resultHtml = `
-          <a href="${url}" target="_blank" id="search-result" rel="noopener 
noreferrer">
-            <small>${key}</small>
-            <div>
-              ${source.toString()}
-            </div>
-          </a>
-        `;
-
-        $searchPreview.append(resultHtml);
+        searchPreview.append(resultElement);
       }
     }
   }
@@ -137,24 +174,23 @@ $(document).ready(() => {
    * The path lookup is also added to the search query.
    * Built in the following format: `path:proj/comp filters`.
    *
-   * @param {jQuery} $searchElement - The user input.
+   * @param {HTMLInputElement|HTMLTextAreaElement} searchElement - The user 
input.
    * @returns {string} - The built search query string.
    *
    * */
-  function buildSearchQuery($searchElement) {
+  function buildSearchQuery(searchElement) {
     let builtSearchQuery = "";
 
     // Add path lookup to the search query
-    const projectPath = $searchElement
+    const projectPath = searchElement
       .closest("form")
-      .find("input[name=path]")
-      .val();
+      ?.querySelector("input[name=path]")?.value;
     if (projectPath) {
       builtSearchQuery = `path:${projectPath}`;
     }
 
     // Add filters to the search query
-    const filters = $searchElement.val();
+    const filters = searchElement.value;
     if (filters) {
       builtSearchQuery = `${builtSearchQuery} ${filters}`;
     }
diff --git a/weblate/static/js/urls.js b/weblate/static/js/urls.js
new file mode 100644
index 000000000000..47ceeef978ca
--- /dev/null
+++ b/weblate/static/js/urls.js
@@ -0,0 +1,51 @@
+// Copyright © Michal Čihař <[email protected]>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// biome-ignore lint/correctness/noInvalidUseBeforeDeclaration: Shared Weblate 
namespace.
+var WLT = WLT || {};
+
+WLT.URLs = (() => {
+  function parse(url, base) {
+    try {
+      return new URL(String(url), base);
+    } catch {
+      return null;
+    }
+  }
+
+  function getLocalPath(url) {
+    const urlString = String(url).trim();
+    if (
+      urlString.startsWith("//") ||
+      (!urlString.startsWith("/") && !/^https?:\/\//i.test(urlString))
+    ) {
+      return null;
+    }
+    const parsedUrl = parse(urlString, window.location.origin);
+    if (
+      parsedUrl === null ||
+      (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")
+    ) {
+      return null;
+    }
+    const path = parsedUrl.pathname.replace(/^\/+/, "/");
+    return `${path}${parsedUrl.search}${parsedUrl.hash}`;
+  }
+
+  function getHttpUrl(url) {
+    const parsedUrl = parse(url, window.location.href);
+    if (
+      parsedUrl === null ||
+      (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")
+    ) {
+      return null;
+    }
+    return parsedUrl.href;
+  }
+
+  return {
+    getHttpUrl,
+    getLocalPath,
+  };
+})();
diff --git a/weblate/static/styles/main.css b/weblate/static/styles/main.css
index 7518fcd09f54..61628f3c65e5 100644
--- a/weblate/static/styles/main.css
+++ b/weblate/static/styles/main.css
@@ -2405,7 +2405,7 @@ tbody.warning {
   box-shadow: 1px 2px 4px #00000020;
 }
 
-#search-preview > #search-result {
+#search-preview > .search-result {
   display: block;
   padding: 5px;
   margin-bottom: 5px;
@@ -2414,11 +2414,11 @@ tbody.warning {
   border-radius: 4px;
 }
 
-#search-preview > #search-result:hover {
+#search-preview > .search-result:hover {
   background-color: #cccccc50;
 }
 
-#search-preview > #search-result > div {
+#search-preview > .search-result > div {
   margin-left: 10px;
 }
 
diff --git a/weblate/templates/base.html b/weblate/templates/base.html
index 2948f2f4c9e2..c1ad5e67b1a8 100644
--- a/weblate/templates/base.html
+++ b/weblate/templates/base.html
@@ -79,6 +79,7 @@
       <script defer
               data-cfasync="false"
               src="{% static 'js/keyboard-shortcuts.js' %}{{ cache_param 
}}"></script>
+      <script defer data-cfasync="false" src="{% static 'js/urls.js' %}{{ 
cache_param }}"></script>
       <script defer
               data-cfasync="false"
               src="{% static 'editor/tools/search.js' %}{{ cache_param 
}}"></script>

++++++ CVE-2026-50127.patch ++++++
>From 097866f9c34b40f7d9e632a403a3aa479ac88d10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= <[email protected]>
Date: Thu, 28 May 2026 11:09:08 +0200
Subject: [PATCH] fix(utils): harden private IP ranges detection (#19768)

Harden outbound URL validation by rejecting IPv6 transition, multicast, and 
special-use addresses that must not be treated as public targets, while 
unwrapping NAT64 well-known-prefix addresses to validate the embedded IPv4 
destination. Adds regression coverage for the blocked address classes.
---
 docs/changes.rst                       |  1 +
 weblate/utils/outbound.py              | 79 +++++++++++++++++++++++++-
 weblate/utils/tests/test_validators.py | 77 +++++++++++++++++++++++++
 3 files changed, 155 insertions(+), 2 deletions(-)

diff --git a/weblate/utils/outbound.py b/weblate/utils/outbound.py
index da4ca6824cb5..8aa6b7dd6979 100644
--- a/weblate/utils/outbound.py
+++ b/weblate/utils/outbound.py
@@ -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."
diff --git a/weblate/utils/tests/test_validators.py 
b/weblate/utils/tests/test_validators.py
index 03d0310186dc..5a8d0c72a76d 100644
--- a/weblate/utils/tests/test_validators.py
+++ b/weblate/utils/tests/test_validators.py
@@ -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):

Reply via email to