SECURITY: Sanitize video placeholder urls

Make sure video placeholder urls are valid. An error message is displayed instead of an infinite loading spinner after clicking play.
This commit is contained in:
Blake Erickson 2025-01-10 11:07:41 -07:00 committed by Roman Rizzi
parent 2c5dbdc23f
commit 8192aedd69
No known key found for this signature in database
GPG Key ID: 64024A71CE7330D3
3 changed files with 86 additions and 2 deletions

View File

@ -1,5 +1,6 @@
import { spinnerHTML } from "discourse/helpers/loading-spinner"; import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import { sanitize } from "discourse/lib/text";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
@ -15,10 +16,39 @@ export default {
parentDiv.style.cursor = ""; parentDiv.style.cursor = "";
overlay.innerHTML = spinnerHTML; overlay.innerHTML = spinnerHTML;
const videoSrc = sanitizeUrl(parentDiv.dataset.videoSrc);
const origSrc = sanitizeUrl(parentDiv.dataset.origSrc);
const dataOrigSrcAttr =
origSrc !== null ? `data-orig-src="${origSrc}"` : "";
if (videoSrc === null) {
const existingNotice = wrapper.querySelector(".notice.error");
if (existingNotice) {
existingNotice.remove();
}
const notice = document.createElement("div");
notice.className = "notice error";
notice.innerHTML =
iconHTML("triangle-exclamation") +
" " +
I18n.t("invalid_video_url");
wrapper.appendChild(notice);
overlay.innerHTML = iconHTML("play");
parentDiv.style.cursor = "pointer";
parentDiv.addEventListener(
"click",
(e) => handleVideoPlaceholderClick(helper, e),
{ once: true }
);
return;
}
const videoHTML = ` const videoHTML = `
<video width="100%" height="100%" preload="metadata" controls style="display:none"> <video width="100%" height="100%" preload="metadata" controls style="display:none">
<source src="${parentDiv.dataset.videoSrc}" ${parentDiv.dataset.origSrc}> <source src="${videoSrc}" ${dataOrigSrcAttr}>
<a href="${parentDiv.dataset.videoSrc}">${parentDiv.dataset.videoSrc}</a> <a href="${videoSrc}">${videoSrc}</a>
</video>`; </video>`;
parentDiv.insertAdjacentHTML("beforeend", videoHTML); parentDiv.insertAdjacentHTML("beforeend", videoHTML);
parentDiv.classList.add("video-container"); parentDiv.classList.add("video-container");
@ -108,6 +138,33 @@ export default {
}); });
} }
function sanitizeUrl(url) {
try {
const parsedUrl = new URL(url, window.location.origin);
if (
["http:", "https:"].includes(parsedUrl.protocol) ||
url.startsWith("/")
) {
const sanitized = sanitize(url);
if (
sanitized &&
sanitized.trim() !== "" &&
!sanitized.includes("&gt;") &&
!sanitized.includes("&lt;")
) {
return sanitized;
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn("Invalid URL encountered:", url, e.message);
}
return null;
}
api.decorateCookedElement(applyVideoPlaceholder, { api.decorateCookedElement(applyVideoPlaceholder, {
onlyStream: true, onlyStream: true,
}); });

View File

@ -50,4 +50,30 @@ acceptance("Video Placeholder Test", function () {
.hasStyle({ display: "block" }, "The video is no longer hidden"); .hasStyle({ display: "block" }, "The video is no longer hidden");
assert.dom(".video-placeholder-wrapper").doesNotExist(); assert.dom(".video-placeholder-wrapper").doesNotExist();
}); });
test("displays an error for invalid video URL and allows retry", async function (assert) {
await visit("/t/54081");
const placeholder = document.querySelector(".video-placeholder-container");
placeholder.setAttribute(
"data-video-src",
'http://example.com/video.mp4"><script>alert(1)</script>'
);
await click(".video-placeholder-overlay");
assert
.dom(".video-placeholder-wrapper .notice.error")
.exists("An error message is displayed for an invalid URL");
assert
.dom(".video-placeholder-wrapper .notice.error")
.hasText(
"This video cannot be played because the URL is invalid or unavailable.",
"Error message is correct"
);
assert
.dom("video")
.doesNotExist("No video element is created for invalid URL");
});
}); });

View File

@ -4360,6 +4360,7 @@ en:
retry: "Retry loading the image" retry: "Retry loading the image"
cannot_render_video: This video cannot be rendered because your browser does not support the codec. cannot_render_video: This video cannot be rendered because your browser does not support the codec.
invalid_video_url: This video cannot be played because the URL is invalid or unavailable.
keyboard_shortcuts_help: keyboard_shortcuts_help:
shortcut_key_delimiter_comma: ", " shortcut_key_delimiter_comma: ", "