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