Skip to content

Commit 8eab683

Browse files
committed
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.
1 parent cab6fe7 commit 8eab683

6 files changed

Lines changed: 161 additions & 68 deletions

File tree

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Weblate 2026.5
1919

2020
.. rubric:: Bug fixes
2121

22+
* Hardened search previews and :ref:`machine-translation` suggestion origins against XSS.
2223
* Database error details are no longer exposed in upload failure messages.
2324
* Category :doc:`/admin/announcements` no longer appear across the whole project.
2425
* Merge request pushes now refresh stale fork remotes after changing repository hosting.

weblate/static/editor/full.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -749,13 +749,17 @@
749749
if (typeof el.origin !== "undefined") {
750750
service.append(" (");
751751
let origin;
752-
const _deleteUrl = false;
753752
if (typeof el.origin_detail !== "undefined") {
754753
origin = $("<abbr/>").text(el.origin).attr("title", el.origin_detail);
755754
} else if (typeof el.origin_url !== "undefined") {
756-
origin = $("<a/>").text(el.origin).attr("href", el.origin_url);
755+
const originUrl = WLT.URLs.getHttpUrl(el.origin_url);
756+
if (originUrl === null) {
757+
origin = document.createTextNode(String(el.origin));
758+
} else {
759+
origin = $("<a/>").text(el.origin).attr("href", originUrl);
760+
}
757761
} else {
758-
origin = el.origin;
762+
origin = document.createTextNode(String(el.origin));
759763
}
760764
if (el.delete_url) {
761765
this.state.weblateTranslationMemory.add(el.text);

weblate/static/editor/tools/search.js

Lines changed: 98 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// SPDX-License-Identifier: GPL-3.0-or-later
44

5-
$(document).ready(() => {
5+
document.addEventListener("DOMContentLoaded", () => {
66
searchPreview("#replace", "#id_replace_q");
77
searchPreview("#bulk-edit", "#id_bulk_q");
88
searchPreview("#addon-form", "#id_bulk_q");
@@ -15,20 +15,27 @@ $(document).ready(() => {
1515
*
1616
*/
1717
function searchPreview(searchForm, searchElement) {
18-
const $searchForm = $(searchForm);
19-
const $searchElement = $searchForm.find(searchElement);
18+
const form = document.querySelector(searchForm);
19+
const searchInput = form?.querySelector(searchElement);
20+
21+
if (!form || !searchInput) {
22+
return;
23+
}
2024

2125
// Create the preview element
22-
const $searchPreview = $('<div id="search-preview"></div>');
23-
$searchElement.parent().parent().parent().after($searchPreview);
26+
const searchPreview = document.createElement("div");
27+
searchPreview.id = "search-preview";
28+
searchInput.parentElement?.parentElement?.parentElement?.after(
29+
searchPreview,
30+
);
2431

2532
let debounceTimeout = null;
2633

2734
// Update the preview while typing with a debounce of 300ms
28-
$searchElement.on("input", () => {
29-
$searchPreview.show();
30-
const userSearchInput = $searchElement.val();
31-
const searchQuery = buildSearchQuery($searchElement);
35+
searchInput.addEventListener("input", () => {
36+
searchPreview.style.display = "block";
37+
const userSearchInput = searchInput.value;
38+
const searchQuery = buildSearchQuery(searchInput);
3239

3340
// Clear the previous timeout to prevent the previous
3441
// request since the user is still typing
@@ -37,56 +44,72 @@ $(document).ready(() => {
3744
// fetch search results but not too often
3845
debounceTimeout = setTimeout(() => {
3946
if (userSearchInput) {
40-
$.ajax({
41-
url: "/api/units/",
42-
method: "GET",
43-
data: { q: searchQuery },
44-
success: (response) => {
47+
const url = `/api/units/?${new URLSearchParams({
48+
q: searchQuery,
49+
}).toString()}`;
50+
fetch(url, {
51+
headers: {
52+
Accept: "application/json",
53+
"X-Requested-With": "XMLHttpRequest",
54+
},
55+
})
56+
.then((response) => {
57+
if (!response.ok) {
58+
return null;
59+
}
60+
return response.json();
61+
})
62+
.then((response) => {
63+
if (response === null) {
64+
return;
65+
}
4566
// Clear previous search results
46-
$searchPreview.html("");
47-
$("#results-num").remove();
67+
searchPreview.replaceChildren();
68+
searchPreview.querySelector("#results-num")?.remove();
4869
const results = response.results;
4970
if (!results || results.length === 0) {
50-
$searchPreview.text(gettext("No results found"));
71+
searchPreview.textContent = gettext("No results found");
5172
} else {
5273
showResults(results, response.count, searchQuery);
5374
}
54-
},
55-
});
75+
});
5676
}
5777
}, 300); // If the user stops typing for 300ms, the search results will be fetched
5878
});
5979

6080
// Show the preview on focus
61-
$searchElement.on("focus", () => {
62-
if ($searchElement.val() !== "" && $searchPreview.html() !== "") {
63-
$searchPreview.show();
64-
$("#results-num").show();
81+
searchInput.addEventListener("focus", () => {
82+
if (searchInput.value !== "" && searchPreview.innerHTML !== "") {
83+
searchPreview.style.display = "block";
84+
const resultsNumber = searchPreview.querySelector("#results-num");
85+
if (resultsNumber) {
86+
resultsNumber.style.display = "";
87+
}
6588
}
6689
});
6790

6891
// Close the preview on form submit, form reset, and form clear
6992
// or if there is no search query
70-
$searchForm.on("input", () => {
71-
if ($searchElement.val() === "") {
72-
$searchPreview.hide();
73-
$("#results-num").remove();
93+
form.addEventListener("input", () => {
94+
if (searchInput.value === "") {
95+
searchPreview.style.display = "none";
96+
searchPreview.querySelector("#results-num")?.remove();
7497
}
7598
});
76-
$searchForm.on("submit", () => {
77-
$searchPreview.html("");
78-
$searchPreview.hide();
79-
$("#results-num").remove();
99+
form.addEventListener("submit", () => {
100+
searchPreview.replaceChildren();
101+
searchPreview.style.display = "none";
102+
searchPreview.querySelector("#results-num")?.remove();
80103
});
81-
$searchForm.on("reset", () => {
82-
$searchPreview.html("");
83-
$searchPreview.hide();
84-
$("#results-num").remove();
104+
form.addEventListener("reset", () => {
105+
searchPreview.replaceChildren();
106+
searchPreview.style.display = "none";
107+
searchPreview.querySelector("#results-num")?.remove();
85108
});
86-
$searchForm.on("clear", () => {
87-
$searchPreview.html("");
88-
$("#results-num").remove();
89-
$searchPreview.hide();
109+
form.addEventListener("clear", () => {
110+
searchPreview.replaceChildren();
111+
searchPreview.querySelector("#results-num")?.remove();
112+
searchPreview.style.display = "none";
90113
});
91114

92115
/**
@@ -103,30 +126,44 @@ $(document).ready(() => {
103126
ngettext("%s matching string", "%s matching strings", count),
104127
[count],
105128
);
106-
const searchUrl = `/search/?q=${encodeURI(searchQuery)}`;
107-
const resultsNumber = `<a href="${searchUrl}" target="_blank" rel="noopener noreferrer" id="results-num">${t}</a>`;
108-
$searchPreview.append(resultsNumber);
129+
const searchUrl = `/search/?${new URLSearchParams({
130+
q: searchQuery,
131+
}).toString()}`;
132+
const resultsNumber = document.createElement("a");
133+
resultsNumber.setAttribute("href", searchUrl);
134+
resultsNumber.target = "_blank";
135+
resultsNumber.rel = "noopener noreferrer";
136+
resultsNumber.id = "results-num";
137+
resultsNumber.textContent = t;
138+
searchPreview.append(resultsNumber);
109139
} else {
110-
$("#results-num").remove();
140+
searchPreview.querySelector("#results-num")?.remove();
111141
}
112142

113143
for (const result of results) {
114144
const key = result.context;
115145
const source = result.source;
146+
const url = WLT.URLs.getLocalPath(result.web_url);
147+
148+
if (url === null) {
149+
continue;
150+
}
151+
152+
const resultElement = document.createElement("a");
153+
resultElement.setAttribute("href", url);
154+
resultElement.target = "_blank";
155+
resultElement.className = "search-result";
156+
resultElement.rel = "noopener noreferrer";
157+
158+
const keyElement = document.createElement("small");
159+
keyElement.textContent = String(key);
160+
resultElement.append(keyElement);
161+
162+
const sourceElement = document.createElement("div");
163+
sourceElement.textContent = String(source);
164+
resultElement.append(sourceElement);
116165

117-
// Make the URL relative
118-
// TODO: is this regexp really needed?
119-
const url = result.web_url.replace(/^[a-zA-Z]+:\/\/[^/]+\//, "/");
120-
const resultHtml = `
121-
<a href="${url}" target="_blank" id="search-result" rel="noopener noreferrer">
122-
<small>${key}</small>
123-
<div>
124-
${source.toString()}
125-
</div>
126-
</a>
127-
`;
128-
129-
$searchPreview.append(resultHtml);
166+
searchPreview.append(resultElement);
130167
}
131168
}
132169
}
@@ -137,24 +174,23 @@ $(document).ready(() => {
137174
* The path lookup is also added to the search query.
138175
* Built in the following format: `path:proj/comp filters`.
139176
*
140-
* @param {jQuery} $searchElement - The user input.
177+
* @param {HTMLInputElement|HTMLTextAreaElement} searchElement - The user input.
141178
* @returns {string} - The built search query string.
142179
*
143180
* */
144-
function buildSearchQuery($searchElement) {
181+
function buildSearchQuery(searchElement) {
145182
let builtSearchQuery = "";
146183

147184
// Add path lookup to the search query
148-
const projectPath = $searchElement
185+
const projectPath = searchElement
149186
.closest("form")
150-
.find("input[name=path]")
151-
.val();
187+
?.querySelector("input[name=path]")?.value;
152188
if (projectPath) {
153189
builtSearchQuery = `path:${projectPath}`;
154190
}
155191

156192
// Add filters to the search query
157-
const filters = $searchElement.val();
193+
const filters = searchElement.value;
158194
if (filters) {
159195
builtSearchQuery = `${builtSearchQuery} ${filters}`;
160196
}

weblate/static/js/urls.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright © Michal Čihař <michal@weblate.org>
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
// biome-ignore lint/correctness/noInvalidUseBeforeDeclaration: Shared Weblate namespace.
6+
var WLT = WLT || {};
7+
8+
WLT.URLs = (() => {
9+
function parse(url, base) {
10+
try {
11+
return new URL(String(url), base);
12+
} catch {
13+
return null;
14+
}
15+
}
16+
17+
function getLocalPath(url) {
18+
const urlString = String(url).trim();
19+
if (
20+
urlString.startsWith("//") ||
21+
(!urlString.startsWith("/") && !/^https?:\/\//i.test(urlString))
22+
) {
23+
return null;
24+
}
25+
const parsedUrl = parse(urlString, window.location.origin);
26+
if (
27+
parsedUrl === null ||
28+
(parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")
29+
) {
30+
return null;
31+
}
32+
const path = parsedUrl.pathname.replace(/^\/+/, "/");
33+
return `${path}${parsedUrl.search}${parsedUrl.hash}`;
34+
}
35+
36+
function getHttpUrl(url) {
37+
const parsedUrl = parse(url, window.location.href);
38+
if (
39+
parsedUrl === null ||
40+
(parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")
41+
) {
42+
return null;
43+
}
44+
return parsedUrl.href;
45+
}
46+
47+
return {
48+
getHttpUrl,
49+
getLocalPath,
50+
};
51+
})();

weblate/static/styles/main.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2405,7 +2405,7 @@ tbody.warning {
24052405
box-shadow: 1px 2px 4px #00000020;
24062406
}
24072407

2408-
#search-preview > #search-result {
2408+
#search-preview > .search-result {
24092409
display: block;
24102410
padding: 5px;
24112411
margin-bottom: 5px;
@@ -2414,11 +2414,11 @@ tbody.warning {
24142414
border-radius: 4px;
24152415
}
24162416

2417-
#search-preview > #search-result:hover {
2417+
#search-preview > .search-result:hover {
24182418
background-color: #cccccc50;
24192419
}
24202420

2421-
#search-preview > #search-result > div {
2421+
#search-preview > .search-result > div {
24222422
margin-left: 10px;
24232423
}
24242424

weblate/templates/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
<script defer
8080
data-cfasync="false"
8181
src="{% static 'js/keyboard-shortcuts.js' %}{{ cache_param }}"></script>
82+
<script defer data-cfasync="false" src="{% static 'js/urls.js' %}{{ cache_param }}"></script>
8283
<script defer
8384
data-cfasync="false"
8485
src="{% static 'editor/tools/search.js' %}{{ cache_param }}"></script>

0 commit comments

Comments
 (0)