Weblate: Stored HTML injection in editor search preview
Description
Impact
Weblate's live search preview renders unit source and context as HTML without escaping. Any contributor whose content reaches those fields stores HTML and CSS that runs inside the authenticated editor of every user who runs a matching search.
### Patches * https://github.com/WeblateOrg/weblate/pull/19422
Workarounds
Only the search preview on the selected views is affected.
Resources
Weblate thanks @adrgs for reporting this issue responsibly via GitHub.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Weblate's live search preview renders unit source and context as HTML without escaping, enabling stored XSS via contributed content.
Vulnerability
Weblate's live search preview, used in the editor, renders the source and context fields of translation units as HTML without proper escaping. This allows any contributor whose content reaches those fields to inject arbitrary HTML and CSS. The vulnerability affects all versions prior to 2026.5 [1][3]. The injected code executes in the browser of any authenticated user who runs a matching search [3].
Exploitation
An attacker must be a contributor with the ability to create or modify translation units, thereby setting the source or context fields to malicious HTML. When another user performs a search that matches the injected unit, the search preview renders the unescaped HTML, causing the attacker's payload to execute in the victim's browser [3]. No additional user interaction beyond the search is required.
Impact
Successful exploitation results in stored cross-site scripting (XSS) within the context of the authenticated editor. The attacker can execute arbitrary JavaScript, potentially leading to session hijacking, data exfiltration, or unauthorized actions on behalf of the victim [3].
Mitigation
The vulnerability is fixed in Weblate version 2026.5, released on May 15, 2026 [2]. The fix, implemented in commit 8b0adf1d [1], switches from jQuery to native DOM methods and ensures proper escaping. There is no known workaround other than restricting contributor access, as the issue is limited to the search preview [3]. The CVE is not listed in the Known Exploited Vulnerabilities catalog.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Patches
18b0adf1d0b43fix(client): safer HTML generating
6 files changed · +161 −68
docs/changes.rst+1 −0 modified@@ -19,6 +19,7 @@ Weblate 2026.5 .. rubric:: Bug fixes +* Hardened search previews and :ref:`machine-translation` suggestion origins against XSS. * Database error details are no longer exposed in upload failure messages. * Category :doc:`/admin/announcements` no longer appear across the whole project. * Merge request pushes now refresh stale fork remotes after changing repository hosting.
weblate/static/editor/full.js+7 −3 modified@@ -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);
weblate/static/editor/tools/search.js+98 −62 modified@@ -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}`; }
weblate/static/js/urls.js+51 −0 added@@ -0,0 +1,51 @@ +// Copyright © Michal Čihař <michal@weblate.org> +// +// 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, + }; +})();
weblate/static/styles/main.css+3 −3 modified@@ -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; }
weblate/templates/base.html+1 −0 modified@@ -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>
Vulnerability mechanics
Root cause
"The search preview renders unit `source` and `context` fields as raw HTML via string interpolation without escaping, allowing stored XSS."
Attack vector
An attacker who can contribute content to a translation unit (e.g., as a translator or reviewer) can embed arbitrary HTML and JavaScript in the `source` or `context` fields. When any authenticated user performs a search that matches that unit, the live search preview in the editor constructs an HTML string by directly interpolating `key` and `source` values (e.g., `${key}` and `${source.toString()}`) without sanitization [patch_id=371072]. The injected script executes in the context of the victim's authenticated session, bypassing the same-origin policy. No special network position is required; the attacker only needs the ability to submit content that reaches those fields.
Affected code
The vulnerable code is in `weblate/static/editor/tools/search.js`, specifically in the `showResults` function where `result.context` and `result.source` were interpolated directly into an HTML template string without escaping. The patch also adds a new URL validation module at `weblate/static/js/urls.js` and updates `weblate/static/editor/full.js` to use it for origin URLs.
What the fix does
The patch replaces jQuery-based HTML string concatenation with DOM API methods that treat data as text, not markup. In `search.js`, the old code used template literals like `<small>${key}</small>` and `<div>${source.toString()}</div>` which allowed HTML injection. The new code uses `document.createElement`, sets `textContent` (which automatically escapes HTML entities), and appends elements safely [patch_id=371072]. Additionally, a new `WLT.URLs` module in `urls.js` validates that URLs are well-formed and use http/https before inserting them into `href` attributes, preventing `javascript:` or other scheme-based XSS. The CSS selector is also updated from `#search-result` to `.search-result` to reflect the change from a single ID to a reusable class.
Preconditions
- authAttacker must be able to contribute content (source or context) to a translation unit in Weblate.
- inputAttacker's content must contain malicious HTML/JavaScript payload in the source or context field.
- networkVictim must perform a search that matches the attacker-controlled unit while using the authenticated editor.
Generated on May 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-6wxc-8mgq-w26mghsaADVISORY
- github.com/WeblateOrg/weblate/commit/8b0adf1d0b43dfc0d09da4b878857b2288b84f2dghsa
- github.com/WeblateOrg/weblate/pull/19422ghsa
- github.com/WeblateOrg/weblate/releases/tag/weblate-2026.5ghsa
- github.com/WeblateOrg/weblate/security/advisories/GHSA-6wxc-8mgq-w26mghsa
News mentions
0No linked articles in our index yet.